Skip to content

Commit 7be4398

Browse files
committed
docs: fill gaps surfaced by developer feedback
Adds guidance for recurring questions and pitfalls that came up while building Apps against the SDK: patterns.md: - Model vs App data visibility (content/structuredContent/_meta split) - Controlling App height, including the autoResize + 100vh feedback loop - Touch device support (touch-action, horizontal overflow) - localStorage key namespacing across shared sandbox origin - Sharing a single ui:// resource across multiple tools - Conditionally showing UI (two-tool workaround) - Opening external links via app.openLink - color-scheme CSS gotcha that breaks iframe transparency overview.md: - Resource versioning/caching note — template and data may be from different code versions New pages: - design-guidelines.md - troubleshooting.md typedoc.config.mjs wired up to include the two new pages.
1 parent e8ca859 commit 7be4398

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

docs/design-guidelines.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
title: Design Guidelines
3+
group: Getting Started
4+
description: UX guidance for MCP Apps — what the host already provides, how to size your content, and how to stay visually consistent with the surrounding chat.
5+
---
6+
7+
# Design Guidelines
8+
9+
MCP Apps live inside a conversation. They should feel like a natural part of the chat, not a separate application wedged into it.
10+
11+
## The host provides the chrome
12+
13+
Hosts typically render a frame around your App that includes:
14+
15+
- A **title bar** showing your App's name (from the tool or server metadata)
16+
- **Display-mode controls** (expand to fullscreen, collapse, close)
17+
- **Attribution** (which connector/server the App came from)
18+
19+
**Don't duplicate these.** Your App doesn't need its own close button, title header, or "powered by" footer. Start your layout with the actual content.
20+
21+
If you need a title _inside_ your content (e.g., "Q3 Revenue by Region" above a chart), that's fine — just don't put your App's brand name there.
22+
23+
## Keep it focused
24+
25+
An MCP App answers one question or supports one task. Resist the urge to build a full dashboard with tabs, sidebars, and settings panels.
26+
27+
Good heuristics:
28+
29+
- **Inline mode should fit in roughly one screen of scroll.** If your content is much taller than the chat viewport, consider whether it belongs in fullscreen mode — or whether you're showing too much.
30+
- **One primary action at most.** A "Confirm" button is fine. A toolbar with eight icons is probably too much for inline mode.
31+
- **Let the conversation drive navigation.** Instead of building a search box inside your App, let the user ask a follow-up question and re-invoke the tool with new arguments.
32+
33+
## Don't replicate the host's UI
34+
35+
Your App must not look like the surrounding chat client. Specifically, avoid:
36+
37+
- Rendering fake chat bubbles or message threads
38+
- Mimicking the host's input box or send button
39+
- Showing fake system notifications or permission dialogs
40+
41+
These patterns confuse users about what's real host UI versus App content, and most hosts prohibit them in their submission guidelines.
42+
43+
## Use host styling where possible
44+
45+
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 makes your App feel native across light mode, dark mode, and different host themes.
46+
47+
You can bring your own brand colors for content (chart series, status badges), but let the host's variables drive backgrounds, text, and borders. Always provide fallback values so your App still renders reasonably on hosts that don't supply every variable.
48+
49+
## Inline vs fullscreen layout
50+
51+
Design for **inline first** — that's where your App appears by default. Inline mode is narrow (often the width of a chat message) and height-constrained.
52+
53+
Treat **fullscreen** as a progressive enhancement for Apps that benefit from more space (editors, maps, large datasets). Check `hostContext.availableDisplayModes` before showing a fullscreen toggle — not every host supports it.
54+
55+
When switching modes, remember to adjust your layout: remove border radius at the edges, expand to fill the viewport, and re-read `containerDimensions` from the updated host context.
56+
57+
## Handle the empty and loading states
58+
59+
Your App mounts before the tool result arrives. Between `ui/initialize` and `ontoolresult`, show something — a skeleton, a spinner, or at minimum a neutral background. A blank white rectangle looks broken.
60+
61+
Similarly, if your tool result can be empty (no search results, no items in cart), design a clear empty state rather than rendering nothing.

docs/overview.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ Resources are declared upfront, during tool registration. This design enables:
110110
- **Separation of concerns** — Templates (presentation) are separate from tool results (data)
111111
- **Review** — Hosts can inspect UI templates during connection setup
112112

113+
**Versioning and caching.** Resource caching behavior is host-defined. A host may re-fetch your `ui://` resource each time it renders, cache it for the session, or persist it alongside the conversation. This means a user revisiting an old conversation may see either your _current_ template code running against the _original_ tool result, or a snapshot of both from when the tool first ran. Design your App to tolerate older `structuredContent` shapes — treat unknown fields gracefully and don't assume the template and the data were produced by the same code version.
114+
113115
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.
114116

115117
## Tool-UI Linkage

docs/patterns.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,35 @@ registerAppTool(
3737
> [!NOTE]
3838
> 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).
3939
40+
## What the model sees vs what the App sees
41+
42+
A tool result has three places to put data, each with different visibility:
43+
44+
| Field | Seen by model | Seen by App | Use for |
45+
| ------------------- | ------------- | ----------- | -------------------------------------------------------------------------- |
46+
| `content` ||| Short text summary the model can reason about and text-only hosts can show |
47+
| `structuredContent` ||| Structured data the App renders (tables, charts, lists) |
48+
| `_meta` ||| Opaque metadata (IDs, timestamps, view identifiers) |
49+
50+
Keep `content` brief — a one-line summary is usually enough. The model uses it to decide what to say next, so avoid dumping raw data there.
51+
52+
> [!WARNING]
53+
> **Don't put large payloads in tool results.** Base64-encoded audio, images, or file contents should be served via MCP resources (see [Serving binary blobs via resources](#serving-binary-blobs-via-resources)) or fetched by the App over the network, not returned inline in `structuredContent`. Even though `structuredContent` is not added to the model's context by spec, large tool results slow down transport, inflate conversation storage, and some host implementations may include more of the result than you expect.
54+
55+
**Write `content` for the model, not the user.** The user is looking at your App, not reading the `content` text. A good `content` string tells the model what just happened so it can respond naturally without repeating what's already on screen:
56+
57+
```ts
58+
return {
59+
content: [
60+
{
61+
type: "text",
62+
text: "Rendered an interactive chart of Q3 revenue by region. The user can see and interact with it directly — do not describe the chart contents in your response.",
63+
},
64+
],
65+
structuredContent: { regions, revenue, quarter: "Q3" },
66+
};
67+
```
68+
4069
## Polling for live data
4170

4271
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() {
402431
> [!NOTE]
403432
> 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).
404433
434+
> [!TIP]
435+
> **Avoid the `color-scheme` CSS property on your root element.** If your App declares `color-scheme: light dark` but the host's document doesn't, browsers insert an opaque backdrop behind the iframe to prevent cross-scheme bleed-through — which breaks transparent backgrounds. Prefer the `[data-theme]` attribute approach shown above and let the host control scheme negotiation.
436+
437+
## Supporting touch devices
438+
439+
Apps that handle pointer gestures (pan, drag, pinch) need to prevent those gestures from also scrolling the surrounding chat. Use [`touch-action`](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action) on interactive surfaces:
440+
441+
```css
442+
/* Chart/canvas that handles its own panning */
443+
.chart-surface {
444+
touch-action: none;
445+
}
446+
447+
/* Horizontal slider that shouldn't trigger vertical page scroll */
448+
.slider-track {
449+
touch-action: pan-y; /* allow vertical scroll, consume horizontal */
450+
}
451+
```
452+
453+
Without this, a user dragging across your chart on mobile will also scroll the chat, and your App may never receive the `pointermove` events.
454+
455+
Also make sure your layout doesn't overflow horizontally — set `overflow-x: hidden` on the root container if you have any fixed-width elements. Horizontal overflow on mobile causes the entire App to wobble when scrolled.
456+
405457
## Entering / exiting fullscreen
406458

407459
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
453505
> [!NOTE]
454506
> 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).
455507
508+
## Controlling App height
509+
510+
By default, the SDK observes your document's content height and reports it to the host so the iframe grows to fit (`autoResize: true`). This works well for content-driven UI like cards, tables, and forms — but it's the wrong choice for viewport-filling UI like canvases, maps, and editors.
511+
512+
Pick one of three strategies:
513+
514+
**1. Auto-resize (default)** — for content that has a natural height. Let the iframe grow to fit. Don't set `height: 100vh` or `height: 100%` on your root element, or you'll create a feedback loop where the reported height keeps growing.
515+
516+
**2. Fixed height** — for UI that should always be the same size inline. Disable auto-resize and set an explicit height:
517+
518+
```ts
519+
const app = new App(
520+
{ name: "my-app", version: "0.1.0" },
521+
{},
522+
{ autoResize: false },
523+
);
524+
```
525+
526+
```css
527+
html,
528+
body {
529+
height: 500px;
530+
margin: 0;
531+
}
532+
```
533+
534+
**3. Host-driven height** — for UI that should fill whatever space the host gives it (common for fullscreen-capable Apps). Disable auto-resize and read the host-provided dimensions from {@link types!McpUiHostContext `hostContext.containerDimensions`}, updating on {@link app!App.onhostcontextchanged `onhostcontextchanged`}.
535+
536+
> [!WARNING]
537+
> **Never 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.
538+
539+
If you're using the React `useApp` hook, note that it 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.
540+
456541
## Passing contextual information from the App to the model
457542

458543
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) => {
569654

570655
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.
571656

657+
> [!WARNING]
658+
> **Always namespace your `localStorage` keys.** Hosts typically serve all MCP Apps from the same sandbox origin, which means every App shares the same `localStorage`. Using generic keys like `"state"` or `"settings"` will collide with other Apps. The server-generated `viewUUID` pattern above avoids this, but if you use any other keys, prefix them with a string unique to your App.
659+
>
660+
> Availability of `localStorage` is also host-dependent — it may be unavailable in some sandbox configurations. Always wrap access in `try`/`catch` and degrade gracefully.
661+
572662
> [!NOTE]
573663
> 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).
574664
@@ -601,6 +691,61 @@ app.onteardown = async () => {
601691
> [!NOTE]
602692
> 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).
603693
694+
## Sharing one UI resource across multiple tools
695+
696+
You can point several tools at the same `ui://` resource — for example, a single "document viewer" App that renders results from `open-document`, `search-documents`, and `recent-documents`.
697+
698+
The App needs to know which tool produced its data so it can parse the payload correctly. The host may provide this via `hostContext.toolInfo`, but it's optional and not guaranteed on every host. The reliable pattern is to include a discriminator in your tool result:
699+
700+
```ts
701+
// In each tool handler, tag the result with its origin
702+
return {
703+
content: [{ type: "text", text: "Opened annual-report.pdf" }],
704+
structuredContent: {
705+
kind: "open-document", // discriminator
706+
document: { id, title, pageCount },
707+
},
708+
};
709+
```
710+
711+
```ts
712+
// In the App, branch on the discriminator
713+
app.ontoolresult = (result) => {
714+
const data = result.structuredContent as { kind: string };
715+
switch (data.kind) {
716+
case "open-document":
717+
renderViewer(data);
718+
break;
719+
case "search-documents":
720+
renderSearchResults(data);
721+
break;
722+
}
723+
};
724+
```
725+
726+
## Conditionally showing UI
727+
728+
The tool-to-resource binding is declared at registration time — a tool either has a `_meta.ui.resourceUri` or it doesn't. You can't decide per-call whether to render UI.
729+
730+
If you need both behaviors, register two tools:
731+
732+
- `query-data` — no `_meta.ui`, returns text/structured data for the model to reason about
733+
- `visualize-data` — has `_meta.ui`, returns the same data rendered as an interactive App
734+
735+
Give each a clear description so the model picks the right one based on user intent ("show me" → visualize, "tell me" → query).
736+
737+
If the decision truly must be server-side (e.g., only show UI when the result set exceeds a threshold), the current workaround is to always attach the UI resource but have the App render a minimal, collapsed placeholder when there's nothing worth showing. Keep the placeholder small so it doesn't add visual noise to the conversation.
738+
739+
## Opening external links
740+
741+
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 your behalf.
742+
743+
Hosts typically show an interstitial confirmation before navigating so users can review the destination — don't assume the navigation is instant, and don't chain multiple `openLink` calls.
744+
745+
```ts
746+
await app.openLink({ url: "https://example.com/docs" });
747+
```
748+
604749
## Lowering perceived latency
605750

606751
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.

0 commit comments

Comments
 (0)