-
Notifications
You must be signed in to change notification settings - Fork 278
docs: fill gaps surfaced by developer feedback #563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
7be4398
65d9e31
8cef8ed
a14c46d
828adb7
3ce78cd
0bd03ce
7486dee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| --- | ||
| title: Design Guidelines | ||
| group: Getting Started | ||
| description: UX guidance for MCP Apps, covering host-provided chrome, content sizing, and visual consistency with the surrounding chat. | ||
| --- | ||
|
|
||
| # Design Guidelines | ||
|
|
||
| An MCP App is part of a conversation. It should read as a continuation of the chat, not as a separate application embedded inside it. | ||
|
|
||
| ## Host chrome | ||
|
|
||
| Hosts render a frame around your App that typically includes: | ||
|
|
||
| - A title bar showing the App name (from tool or server metadata) | ||
| - Display-mode controls (expand, collapse, close) | ||
| - Attribution indicating which connector or server provided the App | ||
|
|
||
| Do not duplicate these elements. Your App does not need its own close button, header bar, or "powered by" footer. Begin the layout with content. | ||
|
|
||
| A title inside the content area (for example, "Q3 Revenue by Region" above a chart) is acceptable. The App's brand name is not. | ||
|
|
||
| ## Scope | ||
|
|
||
| An MCP App answers one question or supports one task. Avoid building a full dashboard with tabs, sidebars, and settings panels. | ||
|
|
||
| - Inline mode should fit within roughly one viewport of scroll. Content that is significantly taller than the chat viewport belongs in fullscreen mode, or should be trimmed. | ||
| - Limit inline mode to one primary action. A "Confirm" button is appropriate; a toolbar with eight icons is not. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe explain the intent behind this rather than the arbitrary limitation to achieve it? (if it's because inline chats may have limited space and a toolbar would be a bit cramped, maybe emphasize the fact the app layout should be progressive / cater to small widths, and be mobile friendly? |
||
| - Let the conversation handle navigation. Rather than adding a search box inside the App, let the user ask a follow-up question that re-invokes the tool with new arguments. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Counter example: Ctrl+F in PDF-viewer. And its interact pattern or upcoming #72 primitive to invoke search programmatically on an existing component. I'd focus on discouraging multipage navigation in inline mode (might be okay in fullscreen). |
||
|
|
||
| ## Host UI imitation | ||
|
|
||
| Your App must not resemble the surrounding chat client. Do not render: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slightly at odds w/ encouraging apps to use native styles, font and colors. Maybe reformulate as avoid confusion between the app and the surrounding chat. |
||
|
|
||
| - Chat bubbles or message threads | ||
| - Anything that resembles the host's text input or send button | ||
| - System notifications or permission dialogs | ||
|
|
||
| These patterns blur the line between host UI and App content, and most hosts prohibit them in their submission guidelines. | ||
|
|
||
| ## Host styling | ||
|
|
||
| Hosts provide CSS custom properties for colors, fonts, spacing, and border radius (see [Adapting to host context](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas)). Using them keeps your App consistent across light mode, dark mode, and different host themes. | ||
|
|
||
| Brand colors are appropriate for content elements such as chart series or status badges. Backgrounds, text, and borders should use host variables. Always provide fallback values so the App renders correctly on hosts that omit some variables. | ||
|
|
||
| ## Display modes | ||
|
|
||
| Design for inline mode first. It is the default, and it is narrow (often the width of a chat message) and height-constrained. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. inline isn't height-constrained (or we constrain it to like 6000px, on purpose). |
||
|
|
||
| Treat fullscreen as a progressive enhancement for Apps that benefit from more space: editors, maps, large datasets. Check `hostContext.availableDisplayModes` before rendering a fullscreen toggle, since not every host supports it. | ||
|
|
||
| When the display mode changes, update your layout: remove edge border radius, expand to fill the viewport, and re-read `containerDimensions` from the updated host context. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: another way to fill the viewport is to just listen to containerDimensions changes |
||
|
|
||
| ## Loading and empty states | ||
|
|
||
| The App mounts before the tool result arrives. Between `ui/initialize` and `ontoolresult`, render a loading indicator such as a skeleton, spinner, or neutral background. A blank rectangle looks broken. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| If the tool result can be empty (no search results, empty cart), design an explicit empty state rather than rendering nothing. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,35 @@ registerAppTool( | |
| > [!NOTE] | ||
| > For full examples that implement this pattern, see: [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) and [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server). | ||
|
|
||
| ## Tool result data visibility | ||
|
|
||
| A tool result has three fields for data, each with different visibility: | ||
|
|
||
| | Field | Seen by model | Seen by App | Use for | | ||
| | ------------------- | ------------- | ----------- | ------------------------------------------------------------- | | ||
| | `content` | Yes | Yes | Short text summary for the model and for text-only hosts | | ||
| | `structuredContent` | No | Yes | Structured data the App renders (tables, charts, lists) | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. structuredContent is structured data the model may see (latest MCP guideline - and cai behaviour - is model gets to see this iff content is empty). |
||
| | `_meta` | No | Yes | Opaque metadata such as IDs, timestamps, and view identifiers | | ||
|
|
||
| Keep `content` brief. The model uses it to decide what to say next, so a one-line summary is preferable to raw data. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd add the summary should mention the tool displayed a view of / w/ <...> |
||
|
|
||
| > [!WARNING] | ||
| > Do not return large payloads in tool results. Serve base64-encoded audio, images, or file contents via MCP resources (see [Serving binary blobs via resources](#serving-binary-blobs-via-resources)) or have the App fetch them over the network. Although `structuredContent` is excluded from the model's context by the specification, large tool results still slow down transport, inflate conversation storage, and some host implementations include more of the result than the specification requires. | ||
|
|
||
| Write `content` for the model, not the user. The user sees your App, not the `content` text. Use `content` to tell the model what happened so it can respond without repeating what is already on screen: | ||
|
|
||
| ```ts | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: "Rendered an interactive chart of Q3 revenue by region. The user can see and interact with it; do not describe the chart contents in your response.", | ||
| }, | ||
| ], | ||
| structuredContent: { regions, revenue, quarter: "Q3" }, | ||
| }; | ||
| ``` | ||
|
|
||
| ## Polling for live data | ||
|
|
||
| For real-time dashboards or monitoring views, use an app-only tool (with `visibility: ["app"]`) that the App polls at regular intervals. | ||
|
|
@@ -402,6 +431,29 @@ function MyApp() { | |
| > [!NOTE] | ||
| > For full examples that implement this pattern, see: [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) and [`examples/basic-server-react/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react). | ||
|
|
||
| > [!TIP] | ||
| > Avoid setting the `color-scheme` CSS property on your root element. If the App declares `color-scheme: light dark` and the host document does not, the browser inserts an opaque backdrop behind the iframe to prevent cross-scheme bleed-through, which breaks transparent backgrounds. Use the `[data-theme]` attribute approach shown above and let the host control scheme negotiation. | ||
|
|
||
| ## Supporting touch devices | ||
|
|
||
| Apps that handle pointer gestures (pan, drag, pinch) must prevent those gestures from also scrolling the surrounding chat. Set [`touch-action`](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action) on interactive surfaces: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
in inline mode the app shouldn't prevent scrolling nor should it have nested scrolling. |
||
|
|
||
| ```css | ||
| /* Chart or canvas that handles its own panning */ | ||
| .chart-surface { | ||
| touch-action: none; | ||
| } | ||
|
|
||
| /* Horizontal slider that should not trigger vertical page scroll */ | ||
| .slider-track { | ||
| touch-action: pan-y; /* allow vertical scroll, consume horizontal */ | ||
| } | ||
| ``` | ||
|
|
||
| Without `touch-action`, dragging across the App on a mobile device also scrolls the chat, and the App may never receive `pointermove` events. | ||
|
|
||
| Prevent horizontal overflow by setting `overflow-x: hidden` on the root container if the layout contains any fixed-width elements. Horizontal overflow on mobile causes the entire App to shift when the page is scrolled. | ||
|
|
||
| ## Entering / exiting fullscreen | ||
|
|
||
| Toggle fullscreen mode by calling {@link app!App.requestDisplayMode `requestDisplayMode`}: | ||
|
|
@@ -453,6 +505,39 @@ In fullscreen mode, remove the container's border radius so content extends to t | |
| > [!NOTE] | ||
| > For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server), [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server), and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server). | ||
|
|
||
| ## Controlling App height | ||
|
|
||
| By default, the SDK observes the document's content height and reports it to the host so the iframe grows to fit (`autoResize: true`). This is appropriate for content-driven UI such as cards, tables, and forms. It is the wrong choice for viewport-filling UI such as canvases, maps, and editors. | ||
|
|
||
| There are three height strategies: | ||
|
|
||
| **Auto-resize (default).** For content with a natural height. The iframe grows to fit. Do not set `height: 100vh` or `height: 100%` on the root element; doing so creates a feedback loop where the reported height keeps increasing. | ||
|
|
||
| **Fixed height.** For UI that should remain the same size when inline. Disable auto-resize and set an explicit height: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you need to call app.sendSizeChanged for that or else host won't know what size to give to the iframe |
||
|
|
||
| ```ts | ||
| const app = new App( | ||
| { name: "my-app", version: "0.1.0" }, | ||
| {}, | ||
| { autoResize: false }, | ||
| ); | ||
| ``` | ||
|
|
||
| ```css | ||
| html, | ||
| body { | ||
| height: 500px; | ||
| margin: 0; | ||
| } | ||
| ``` | ||
|
|
||
| **Host-driven height.** For UI that should fill the space the host provides (common for fullscreen-capable Apps). Disable auto-resize and read dimensions from {@link types!McpUiHostContext `hostContext.containerDimensions`}, updating on {@link app!App.onhostcontextchanged `onhostcontextchanged`}. | ||
|
|
||
| > [!WARNING] | ||
| > Do not combine `autoResize: true` with `height: 100vh` or `100%` on the root element. The SDK reports the document height, the host grows the iframe to match, the document sees a taller viewport and grows again. This loops until the host's maximum height cap. | ||
|
|
||
| The React `useApp` hook always creates the App with `autoResize: true`. For fixed or host-driven height, construct the `App` manually or use the `useAutoResize` hook with a specific element. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
we should fix this btw 🙈 |
||
|
|
||
| ## Passing contextual information from the App to the model | ||
|
|
||
| Use {@link app!App.updateModelContext `updateModelContext`} to keep the model informed about what the user is viewing or interacting with. Structure the content with YAML frontmatter for easy parsing: | ||
|
|
@@ -569,6 +654,11 @@ app.ontoolresult = (result) => { | |
|
|
||
| For state that represents user effort (e.g., saved bookmarks, annotations, custom configurations), consider persisting it server-side using [app-only tools](#tools-that-are-private-to-apps) instead. Pass the `viewUUID` to the app-only tool to scope the saved data to that view instance. | ||
|
|
||
| > [!WARNING] | ||
| > Namespace all `localStorage` keys. Hosts typically serve every MCP App from the same sandbox origin, so all Apps share a single `localStorage`. Generic keys such as `"state"` or `"settings"` will collide with other Apps. The server-generated `viewUUID` pattern above avoids this; any additional keys should be prefixed with a string unique to your App. | ||
| > | ||
| > `localStorage` availability is host-dependent and may be disabled in some sandbox configurations. Wrap access in `try`/`catch` and degrade gracefully. | ||
|
|
||
| > [!NOTE] | ||
| > For full examples using `localStorage`, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) (persists current page) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (persists camera position). | ||
|
|
||
|
|
@@ -601,6 +691,61 @@ app.onteardown = async () => { | |
| > [!NOTE] | ||
| > For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) and [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server). | ||
|
|
||
| ## Sharing one UI resource across multiple tools | ||
|
|
||
| Several tools can reference the same `ui://` resource. For example, a single document viewer App might render results from `open-document`, `search-documents`, and `recent-documents`. | ||
|
|
||
| The App needs to know which tool produced its data in order to parse the payload correctly. The host may provide this via `hostContext.toolInfo`, but the field is optional and not available on every host. The reliable approach is to include a discriminator in the tool result: | ||
|
|
||
| ```ts | ||
| // In each tool handler, tag the result with its origin | ||
| return { | ||
| content: [{ type: "text", text: "Opened annual-report.pdf" }], | ||
| structuredContent: { | ||
| kind: "open-document", // discriminator | ||
| document: { id, title, pageCount }, | ||
| }, | ||
| }; | ||
| ``` | ||
|
|
||
| ```ts | ||
| // In the App, branch on the discriminator | ||
| app.ontoolresult = (result) => { | ||
| const data = result.structuredContent as { kind: string }; | ||
| switch (data.kind) { | ||
| case "open-document": | ||
| renderViewer(data); | ||
| break; | ||
| case "search-documents": | ||
| renderSearchResults(data); | ||
| break; | ||
| } | ||
| }; | ||
| ``` | ||
|
|
||
| ## Conditionally showing UI | ||
|
|
||
| The tool-to-resource binding is declared at registration time. A tool either has a `_meta.ui.resourceUri` or it does not; the server cannot decide per-call whether to render UI. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Stateful servers can decide to register different tools based on the initialize capabilities if the MCP Apps extension is declared (cf. getUiCapability helper in /server) |
||
|
|
||
| If both behaviors are needed, register two tools: | ||
|
|
||
| - `query-data` with no `_meta.ui`, returning text and structured data for the model to reason about | ||
| - `visualize-data` with `_meta.ui`, returning the same data rendered as an interactive App | ||
|
|
||
| Write distinct descriptions so the model selects the correct tool based on user intent ("show me" maps to visualize, "tell me" maps to query). | ||
|
|
||
| If the decision must be made server-side (for example, showing UI only when the result set exceeds a threshold), the workaround is to always attach the UI resource and have the App render a minimal collapsed placeholder when there is nothing to show. Keep the placeholder small to avoid adding visual noise to the conversation. | ||
|
|
||
| ## Opening external links | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe also mention downloadFile, and capability fencing for both |
||
|
|
||
| Use {@link app!App.openLink `app.openLink()`} instead of `window.open()` or `<a target="_blank">`. The sandbox blocks direct navigation; `openLink` asks the host to open the URL on the App's behalf. | ||
|
|
||
| Hosts typically show an interstitial confirmation so users can review the destination before navigating. Do not assume navigation is instant, and do not chain multiple `openLink` calls. | ||
|
|
||
| ```ts | ||
| await app.openLink({ url: "https://example.com/docs" }); | ||
| ``` | ||
|
|
||
| ## Lowering perceived latency | ||
|
|
||
| Use {@link app!App.ontoolinputpartial `ontoolinputpartial`} to receive streaming tool arguments as they arrive. This lets you show a loading preview before the complete input is available, such as streaming code into a `<pre>` tag before executing it, partially rendering a table as data arrives, or incrementally populating a chart. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| --- | ||
| title: Troubleshooting | ||
| group: Getting Started | ||
| description: Diagnose common MCP App issues including blank iframes, CSP errors, missing tool callbacks, and cross-host rendering differences. | ||
| --- | ||
|
|
||
| # Troubleshooting | ||
|
|
||
| ## Blank iframe | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. failure to call connect is my own most frequent cause for this (esp. if iframe has zero size) |
||
|
|
||
| The most common causes, in the order you should check them: | ||
|
|
||
| 1. **Uncaught JavaScript error.** Open browser developer tools inside the iframe: right-click the App area, choose _Inspect_, then switch the console context dropdown (top-left of the Console tab) from `top` to the sandboxed frame. An uncaught error stops the App before it paints. | ||
|
|
||
| 2. **CSP violation.** Look for `Refused to connect to…` or `Refused to load…` in the console. Any network request, including to `localhost` during development, must be declared in `_meta.ui.csp.connectDomains` or `resourceDomains`. See the [CSP & CORS guide](./csp-cors.md). | ||
|
|
||
| 3. **Resource URI mismatch.** The `_meta.ui.resourceUri` on the tool must match the URI passed to `registerAppResource` exactly. A trailing slash or case difference prevents the host from finding the HTML. | ||
|
|
||
| 4. **Wrong MIME type.** The resource's `mimeType` must be `text/html;profile=mcp-app` (exported as {@link app!RESOURCE_MIME_TYPE `RESOURCE_MIME_TYPE`}). Plain `text/html` is not recognized as an App resource. | ||
|
|
||
| ## `ontoolinput` / `ontoolresult` never fires | ||
|
|
||
| - **Handlers registered too late.** Attach `app.ontoolresult` before calling `connect()`. If the handler is attached after `connect()` resolves, the notification may have already been delivered and discarded. The React `useApp` hook handles this ordering automatically. | ||
| - **Tool was not called.** If the model chose a different tool, or none, there is no result to deliver. Check the host's tool-call log. | ||
| - **SDK version mismatch.** Older SDK versions used stricter schemas for host notifications. If the App was built against a significantly older `@modelcontextprotocol/ext-apps` than the host expects, the initialize handshake can fail silently. Keep the SDK version current. | ||
|
|
||
| ## App works in one host but not another | ||
|
|
||
| MCP Apps are portable only if they use the SDK exclusively. Common portability mistakes: | ||
|
|
||
| - **Host-specific globals.** Do not reference `window.openai`, `window.claude`, or any other host-injected object. Use the `App` class from this SDK, which speaks the standard protocol to any compliant host. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure about mentioning hypothetical |
||
| - **Hardcoded CDN URLs.** Bundle assets into the App or declare their origins in `resourceDomains`. | ||
| - **Hardcoded sandbox origin.** The origin that serves the App varies by host. Use `_meta.ui.domain` to request a stable origin rather than hardcoding one in CORS allowlists. See [CSP & CORS](./csp-cors.md). | ||
|
|
||
| ## App grows unbounded or has the wrong height | ||
|
|
||
| See [Controlling App height](./patterns.md#controlling-app-height). The most common cause is `height: 100vh` combined with the default `autoResize: true`. | ||
|
|
||
| ## Network requests fail with CORS errors | ||
|
|
||
| CSP and CORS are separate controls with different error messages and different fixes: | ||
|
|
||
| - **CSP** (`Refused to connect`): The browser blocked the request because the domain is not in `connectDomains`. Add the domain to `_meta.ui.csp` on the MCP server. | ||
| - **CORS** (`No 'Access-Control-Allow-Origin' header`): The API server rejected the request because it does not recognize the sandbox origin. Add the origin to the API server's allowlist, or use `_meta.ui.domain` to get a predictable origin that can be allowlisted. | ||
|
|
||
| See the [CSP & CORS guide](./csp-cors.md) for configuration examples. | ||
|
|
||
| ## Opaque background instead of transparent | ||
|
|
||
| If the App declares `color-scheme: light dark` (or `color-scheme: dark`) and the host document does not, browsers insert an opaque backdrop behind the iframe to prevent cross-scheme bleed-through. Remove the `color-scheme` declaration and use the `[data-theme]` attribute pattern from the [host context guide](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas). | ||
|
|
||
| ## Getting help | ||
|
|
||
| - Test against the reference host: run `npm start` in this repository to serve `examples/basic-host` at `http://localhost:8080`. It logs all protocol traffic to the console. | ||
| - Search [GitHub Discussions](https://github.com/modelcontextprotocol/ext-apps/discussions) for similar issues. | ||
| - File a bug with a minimal reproduction in [GitHub Issues](https://github.com/modelcontextprotocol/ext-apps/issues). | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure about this one. I think it's fine to have longer apps, as long as they don't have nested scrolling in inline mode (big no-no).