diff --git a/docs/design-guidelines.md b/docs/design-guidelines.md new file mode 100644 index 000000000..1bf16f697 --- /dev/null +++ b/docs/design-guidelines.md @@ -0,0 +1,61 @@ +--- +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. If the App should dismiss itself after a task completes, call {@link app!App.requestTeardown `app.requestTeardown()`}; the host decides whether to honor the request. + +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 an application shell around it: global navigation, sidebars, and settings panels belong to the host, not the App. + +- Inline content can be tall, but it must scroll with the surrounding conversation. Do not introduce nested scroll containers in inline mode; a scrollable region inside a scrollable chat is difficult to use on every input device. See [Supporting touch devices](./patterns.md#supporting-touch-devices). +- Design the inline layout to remain usable at narrow widths. Chat columns can be as narrow as a mobile message bubble, so dense toolbars and side-by-side panels should collapse or move to fullscreen mode rather than overflow. +- Avoid multi-page navigation (routes, wizards, tab stacks) in inline mode. The conversation already provides history and back-navigation. In-App search or filtering over the current data set is fine; navigating to a different document or view is better handled by a follow-up tool call, or reserved for fullscreen mode. + +## Host UI imitation + +Use host-provided styles so your App matches the surrounding theme, but keep the boundary between App content and host UI unambiguous. Do not render: + +- Chat bubbles or message threads +- Anything that resembles the host's text input or send button +- System notifications or permission dialogs + +A user must never mistake App-rendered surfaces for host controls. Most hosts prohibit these patterns 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). Most hosts let inline height grow with content up to a high safety cap, but a host may also pin the iframe to a fixed height via `containerDimensions`; see [Controlling App height](./patterns.md#controlling-app-height). + +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 and expand to fill the viewport. To size the App to the space the host provides, listen for `hostcontextchanged` via {@link app!App.addEventListener `app.addEventListener`} and read `containerDimensions` from the event payload, which reports either fixed `width`/`height` or `maxWidth`/`maxHeight` bounds depending on the host. + +## Loading and empty states + +The App mounts before the tool result arrives, and even before the tool inputs are sent. Render a loading indicator such as a skeleton, spinner, or neutral background between `ui/initialize` and the first terminal event. A blank rectangle looks broken. + +The terminal events are `toolresult` (success or `isError`) and `toolcancelled` (user stopped the request). Handle both: an App that clears its loading state only on `toolresult` will spin indefinitely if the user cancels. + +If the tool result can be empty (no search results, empty cart), design an explicit empty state rather than rendering nothing. diff --git a/docs/overview.md b/docs/overview.md index 28f5b2379..1afff67eb 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -94,7 +94,7 @@ sequenceDiagram 1. **Discovery** — The Host learns about tools and their UI resources when connecting to the server. 2. **Initialization** — When a UI tool is called, the Host renders the iframe. The View sends `ui/initialize` and receives host context (theme, capabilities, container dimensions). This handshake ensures the View is ready before receiving data. -3. **Data delivery** — The Host sends tool arguments and, once available, tool results to the View. Results include both `content` (text for the model's context) and optionally `structuredContent` (data optimized for UI rendering). This separation lets servers provide rich data to the UI without bloating the model's context. +3. **Data delivery** — The Host sends tool arguments and, once available, tool results to the View. Results include both `content` (text for the model's context) and optionally `structuredContent` (data optimized for UI rendering, passed to the model only when `content` is empty). This separation lets servers provide rich data to the UI without bloating the model's context. 4. **Interactive phase** — The user interacts with the View. The View can call tools, send messages, or update context. 5. **Teardown** — Before unmounting, the Host notifies the View so it can save state or release resources. @@ -110,6 +110,8 @@ Resources are declared upfront, during tool registration. This design enables: - **Separation of concerns** — Templates (presentation) are separate from tool results (data) - **Review** — Hosts can inspect UI templates during connection setup +**Versioning and caching.** Resource caching behavior is host-defined. A host may re-fetch the `ui://` resource on each render, cache it for the session, or persist it alongside the conversation. When a user revisits an old conversation, the host may run the current template code against the original tool result, or it may replay a snapshot of both from the time of the original tool call. Design the App to tolerate older `structuredContent` shapes: handle unknown fields gracefully and do not assume the template and the data were produced by the same code version. + See the [UI Resource Format](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#ui-resource-format) section of the specification for the full schema. ## Tool-UI Linkage diff --git a/docs/patterns.md b/docs/patterns.md index 87a6d8232..0a6287dc7 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -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` | Only when `content` is empty | Yes | Structured data the App renders (tables, charts, lists) | +| `_meta` | No | Yes | Opaque metadata such as IDs, timestamps, and view identifiers | + +Per the core MCP guideline, hosts fall back to passing `structuredContent` to the model only when `content` is empty, so populating `content` is the way to keep large render payloads out of the model's context. The App receives all three fields regardless. Keep `content` brief: the model uses it to decide what to say next, so a one-line summary is preferable to raw data. + +> [!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. Even when `structuredContent` is kept out of the model's context, 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. State explicitly that a view was displayed and what it contains so the model does not re-describe 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,31 @@ 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 + +In inline mode, the App scrolls with the surrounding conversation. Do not capture vertical pan gestures or add nested scroll containers to inline layouts; let touch-drag pass through so the user can scroll the chat past your App. + +For interactive surfaces that handle horizontal drag or pinch (sliders, canvases, maps), set [`touch-action`](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action) so the browser delivers `pointermove` events to the App while still allowing vertical scroll to reach the page: + +```css +/* Horizontal slider: consume horizontal drag, leave vertical to the chat */ +.slider-track { + touch-action: pan-y; +} + +/* Fullscreen canvas that handles its own pan and pinch */ +.fullscreen .chart-surface { + touch-action: none; +} +``` + +Reserve `touch-action: none` for fullscreen mode, where the App owns the viewport and there is no outer scroll to conflict with. + +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 +507,61 @@ 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. + +| UI type | Strategy | `autoResize` | Root CSS height | +| ------------------------------------- | ----------- | ------------ | -------------------------- | +| Cards, tables, forms (natural height) | Auto-resize | `true` | unset | +| Fixed-size widgets | Fixed | `false` | explicit `px` | +| Canvases, maps, editors (fill space) | Host-driven | `false` | from `containerDimensions` | + +**Auto-resize (default).** For content with a natural height. The iframe grows to fit. + +**Fixed height.** For UI that should remain the same size when inline. Disable auto-resize, set an explicit height, and report it to the host with {@link app!App.sendSizeChanged `sendSizeChanged`} so the iframe is allocated the correct size: + +```ts +const app = new App( + { name: "my-app", version: "0.1.0" }, + {}, + { autoResize: false }, +); +await app.connect(); +app.sendSizeChanged({ width: document.body.clientWidth, height: 500 }); +``` + +```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 size the root element from {@link types!McpUiHostContext `hostContext.containerDimensions`}, which may report fixed `width`/`height` or `maxWidth`/`maxHeight` bounds: + +```ts +const app = new App( + { name: "my-app", version: "0.1.0" }, + {}, + { autoResize: false }, +); +const root = document.getElementById("root")!; + +app.addEventListener("hostcontextchanged", (ctx) => { + const dims = ctx.containerDimensions; + if (dims && "height" in dims) root.style.height = `${dims.height}px`; + if (dims && "width" in dims) root.style.width = `${dims.width}px`; +}); +await app.connect(); +``` + +> [!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` and does not currently expose an option to disable it. For fixed or host-driven height, construct the `App` manually with `{ autoResize: false }` instead of using `useApp`. + ## 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 +678,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 +715,118 @@ 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.addEventListener("toolresult", (result) => { + if (result.isError || !result.structuredContent) { + return renderError(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. + +A stateful server can decide at connection time. During `initialize`, the client declares whether it supports the Apps extension; use {@link server-helpers!getUiCapability `getUiCapability`} to read that declaration and register UI-backed tools only for clients that can render them: + + +```ts source="../src/server/index.examples.ts#getUiCapability_checkSupport" +server.server.oninitialized = () => { + const clientCapabilities = server.server.getClientCapabilities(); + const uiCap = getUiCapability(clientCapabilities); + + if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { + // App-enhanced tool + registerAppTool( + server, + "weather", + { + description: "Get weather information with interactive dashboard", + _meta: { ui: { resourceUri: "ui://weather/dashboard" } }, + }, + weatherHandler, + ); + } else { + // Text-only fallback + server.registerTool( + "weather", + { + description: "Get weather information", + }, + textWeatherHandler, + ); + } +}; +``` + +If both behaviors are needed for a single UI-capable client, 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 and downloading files + +Use {@link app!App.openLink `app.openLink()`} instead of `window.open()` or ``, and {@link app!App.downloadFile `app.downloadFile()`} instead of synthesizing `` clicks. The sandbox blocks direct navigation and downloads; these methods ask the host to perform the action on the App's behalf. + +Both are optional host capabilities. Check {@link app!App.getHostCapabilities `getHostCapabilities`} before rendering the corresponding controls so the App degrades gracefully on hosts that do not implement them: + +```ts +const caps = app.getHostCapabilities(); + +// Hide controls the host cannot honor +docsLink.hidden = !caps?.openLinks; +downloadButton.hidden = !caps?.downloadFile; + +docsLink.onclick = () => app.openLink({ url: "https://example.com/docs" }); + +downloadButton.onclick = () => + app.downloadFile({ + contents: [ + { + type: "resource", + resource: { + uri: "file:///report.csv", + mimeType: "text/csv", + text: csv, + }, + }, + ], + }); +``` + +Hosts typically show an interstitial confirmation for `openLink` so users can review the destination before navigating. Do not assume navigation is instant, and do not chain multiple `openLink` calls. + ## 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 `
` tag before executing it, partially rendering a table as data arrives, or incrementally populating a chart.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 000000000..23227fbb9
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,61 @@
+---
+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
+
+> [!TIP]
+> The fastest way to diagnose any of the issues below is to load your App in the reference host, which logs all protocol traffic to the browser console. See [Test with basic-host](./testing-mcp-apps.md#test-with-basic-host).
+
+The most common causes, in the order you should check them:
+
+1. **`connect()` was never called.** The host waits for the App to send `ui/initialize` before giving the iframe a non-zero size, so an App that constructs `new App(...)` but never calls `app.connect(transport)` renders as an empty sliver. Confirm `connect()` runs on page load and that its promise resolves.
+
+2. **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.
+
+3. **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).
+
+4. **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.
+
+5. **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.
+
+## `toolinput` / `toolresult` events never fire
+
+- **Listeners registered too late.** Call `app.addEventListener("toolresult", …)` before calling `connect()`. If the listener 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 host-injected globals such as `window.openai`. Use the `App` class from this SDK, which speaks the standard protocol to any compliant host.
+- **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).
diff --git a/typedoc.config.mjs b/typedoc.config.mjs
index 5775caec7..3664c0262 100644
--- a/typedoc.config.mjs
+++ b/typedoc.config.mjs
@@ -14,6 +14,8 @@ const config = {
     "docs/agent-skills.md",
     "docs/testing-mcp-apps.md",
     "docs/patterns.md",
+    "docs/design-guidelines.md",
+    "docs/troubleshooting.md",
     "docs/authorization.md",
     "docs/csp-cors.md",
     "docs/migrate_from_openai_apps.md",