|
| 1 | +# MCP Apps |
| 2 | + |
| 3 | +An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML |
| 4 | +document the host renders as an interactive surface. |
| 5 | + |
| 6 | +Two parts, always two parts: |
| 7 | + |
| 8 | +1. **A tool** that does the work and returns data, like any other tool. |
| 9 | +2. **A `ui://` resource** containing the HTML the host shows for it. |
| 10 | + |
| 11 | +The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches |
| 12 | +it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's |
| 13 | +result into that iframe via `postMessage`. Your server never sends or receives any |
| 14 | +`ui/*` messages: that traffic is between the host and the iframe. You serve a tool |
| 15 | +and an HTML document; the host does the theater. |
| 16 | + |
| 17 | +The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`). |
| 18 | +If [Extensions](extensions.md) are new to you, skim that page first. One minute, |
| 19 | +then come back. |
| 20 | + |
| 21 | +## A clock with a face |
| 22 | + |
| 23 | +```python title="server.py" hl_lines="17 20 23-24 28 30" |
| 24 | +--8<-- "docs_src/apps/tutorial001.py" |
| 25 | +``` |
| 26 | + |
| 27 | +Four moves: |
| 28 | + |
| 29 | +* `Apps()`: one instance holds your UI-bound tools and their resources. |
| 30 | +* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the |
| 31 | + `_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title, |
| 32 | + description, ...) passes through. |
| 33 | +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching |
| 34 | + resource, served as `text/html;profile=mcp-app`. That exact MIME type is what |
| 35 | + tells a host "this is an app, render it". |
| 36 | +* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises |
| 37 | + `io.modelcontextprotocol/ui` under `capabilities.extensions`. |
| 38 | + |
| 39 | +The HTML itself listens for the host's `postMessage` and shows the result. For real |
| 40 | +apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) |
| 41 | +browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`, |
| 42 | +`getHostContext`, and `onhostcontextchanged` instead of raw message events. |
| 43 | + |
| 44 | +## Graceful degradation |
| 45 | + |
| 46 | +Not every client renders apps. The spec is blunt about what that means for you: |
| 47 | + |
| 48 | +> Tools **MUST** return a meaningful `content` array even when UI is available. |
| 49 | +
|
| 50 | +The model reads `content`; the iframe is for humans. A UI-capable host still feeds |
| 51 | +the text result to the model, and a text-only client gets *only* that. So the |
| 52 | +canonical pattern is one tool, two answers: |
| 53 | + |
| 54 | +```python |
| 55 | +@apps.tool(resource_uri="ui://clock/app.html") |
| 56 | +def get_time(ctx: Context) -> str: |
| 57 | + now = current_time() |
| 58 | + if not client_supports_apps(ctx): |
| 59 | + return f"The time is {now}." # a sentence for humans without the UI |
| 60 | + return now # raw data the app renders |
| 61 | +``` |
| 62 | + |
| 63 | +`client_supports_apps(ctx)` is `True` only when the client declared the |
| 64 | +`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app` |
| 65 | +in its `mimeTypes` settings. The field is required, so a client that omits it |
| 66 | +does not count. |
| 67 | + |
| 68 | +!!! warning |
| 69 | + Never return a placeholder like `"[Rendered UI]"` as the only content. If the |
| 70 | + fallback text is useless, the tool is useless to every text-only client and to |
| 71 | + the model itself. Write the sentence. |
| 72 | + |
| 73 | +A client declares support like any extension capability: |
| 74 | + |
| 75 | +```python |
| 76 | +from mcp import Client |
| 77 | +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID |
| 78 | + |
| 79 | +async with Client(target, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: |
| 80 | + ... |
| 81 | +``` |
| 82 | + |
| 83 | +## Locking the iframe down |
| 84 | + |
| 85 | +The resource side carries the security metadata: what the iframe may load, which |
| 86 | +browser permissions it wants, how it would like to be framed: |
| 87 | + |
| 88 | +```python title="server.py" hl_lines="9 19-22" |
| 89 | +--8<-- "docs_src/apps/tutorial002.py" |
| 90 | +``` |
| 91 | + |
| 92 | +`csp` and `permissions` are **requests to the host**, not server behaviour. The host |
| 93 | +builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it |
| 94 | +may refuse. Feature-detect in your JS rather than assuming a grant. |
| 95 | + |
| 96 | +`ResourceCsp`, field by field (Python name, wire key, what the host does with it): |
| 97 | + |
| 98 | +| Python | Wire (`_meta.ui.csp`) | Controls | |
| 99 | +|---|---|---| |
| 100 | +| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go | |
| 101 | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets | |
| 102 | +| `frame_domains` | `frameDomains` | `frame-src`: nested iframes | |
| 103 | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at | |
| 104 | + |
| 105 | +`ResourcePermissions`: each field requests a browser permission for the iframe. |
| 106 | + |
| 107 | +| Python | Wire (`_meta.ui.permissions`) | |
| 108 | +|---|---| |
| 109 | +| `camera` | `camera` | |
| 110 | +| `microphone` | `microphone` | |
| 111 | +| `geolocation` | `geolocation` | |
| 112 | +| `clipboard_write` | `clipboardWrite` | |
| 113 | + |
| 114 | +!!! note |
| 115 | + CSP and permissions live on the **resource**, never on the tool. The spec's tool |
| 116 | + metadata has no slot for them, and hosts ignore them there. The SDK makes the |
| 117 | + mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter. |
| 118 | + |
| 119 | +### Visibility |
| 120 | + |
| 121 | +`visibility=["app"]` on a tool says "this exists for the iframe, not the model": |
| 122 | + |
| 123 | +* `"model"`: the model may call it. |
| 124 | +* `"app"`: the iframe may call it (via `callServerTool`). |
| 125 | +* Omitted: both, which is the default. |
| 126 | + |
| 127 | +Filtering is the **host's** job. Your server lists app-only tools in `tools/list` |
| 128 | +like any other; the host hides them from the model. Don't filter server-side. |
| 129 | + |
| 130 | +## The rules the SDK enforces |
| 131 | + |
| 132 | +All of these fail at startup, not in production: |
| 133 | + |
| 134 | +* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at |
| 135 | + decoration/registration time. |
| 136 | +* A tool bound to a URI with **no matching registered resource** is a `ValueError` |
| 137 | + when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising |
| 138 | + HTML that 404s on `resources/read` is a misconfiguration, so it refuses to |
| 139 | + construct. |
| 140 | +* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns |
| 141 | + `_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys |
| 142 | + merge fine alongside. |
| 143 | + |
| 144 | +Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd |
| 145 | +rather you find out before a host does. |
| 146 | + |
| 147 | +## Beyond inline HTML |
| 148 | + |
| 149 | +`add_html_resource` covers the common case: a string of HTML. For anything else, |
| 150 | +HTML on disk or generated content, build the resource yourself and hand it over: |
| 151 | + |
| 152 | +```python |
| 153 | +from mcp.server.mcpserver.resources import FileResource |
| 154 | + |
| 155 | +apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=html_path)) |
| 156 | +``` |
| 157 | + |
| 158 | +`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource |
| 159 | +doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource |
| 160 | +under any other MIME type is one no host will render. |
| 161 | + |
| 162 | +!!! tip |
| 163 | + Targeting a pre-GA host that still reads the deprecated flat |
| 164 | + `_meta["ui/resourceUri"]` key? Merge it yourself: |
| 165 | + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. |
| 166 | + The nested `ui` object is the spec shape; the flat key is on its way out. |
| 167 | + |
| 168 | +## See it run |
| 169 | + |
| 170 | +The `apps` story in `examples/stories/` is this page as a runnable pair: a server |
| 171 | +with a UI-bound clock tool and a client that negotiates Apps, reads the tool's |
| 172 | +`_meta.ui.resourceUri`, fetches the HTML, and calls the tool. |
| 173 | + |
| 174 | +```bash |
| 175 | +uv run python -m stories.apps.client |
| 176 | +``` |
0 commit comments