MCP Apps: Interactive UIs Inside a Tool Call
For its entire first year MCP was a text protocol: a tool call returned JSON or Markdown and the client rendered whatever it liked. MCP Apps — the first official extension, finalized January 26, 2026 and folded into the 2026-07-28 core spec — adds a UI channel. A server can now ship a chunk of HTML, the client renders it in a sandboxed iframe, and the iframe talks back over JSON-RPC 2.0 on postMessage. Every write still passes through the same audit path as any other tool call. Think interactive report + confirm-before-execute, not "run a full webapp inside Claude."
- What MCP Apps actually is — and the four things it deliberately is NOT
- The wire shape: capability negotiation, ui:// resources, the tool _meta.ui link, and the postMessage bridge
- The security model — sandbox, CSP, Permission Policy, host approval — and where it still leaves you exposed
- When to reach for an App instead of a plain tool result (rare-but-worth-it patterns)
- How this composes with the rest of the 2026-07-28 stateless spec you already speak
The one-paragraph version
The server declares one or more UI resources at ui://<server>/<name> URIs with MIME type text/html;profile=mcp-app. A tool advertises "I have a UI" by putting _meta.ui.resourceUri on its schema. On a tools/call the server can return the usual text/data plus a reference to the UI resource; the client fetches the HTML with resources/read, drops it into a sandboxed <iframe>, and now the iframe and the host chat over postMessage using JSON-RPC 2.0. UI-initiated tool calls still require the same user approval as any other tool call — the App can render, it can propose, it cannot silently execute.
What MCP Apps is NOT
- Not a full webapp runtime. The iframe defaults to no network (`connect-src 'none'`), no top-level navigation, and no third-party scripts. If your idea needs to load React from a CDN and call your own API, this isn't it.
- Not a way to bypass tool-call approval. UI actions that mutate anything still travel as normal JSON-RPC tool calls the host can log, throttle, and require explicit user consent for.
- Not sanitization. The sandbox limits what the UI can DO, not what it can trick a user into typing. Treat every App like third-party code — allowlist which servers can render UI at all.
- Not persistent. There is no session; when the tool call ends, the iframe goes away. Any state you need across calls lives in a server-side handle you return, exactly like the rest of the stateless spec.
The four moving parts
- The client advertises the extension in its per-request _meta.capabilities under the reverse-DNS namespace io.modelcontextprotocol/ui, listing the MIME types it can render. A server that sees no such capability just skips the UI channel and returns plain results — the whole extension is opt-in on both sides.
- The server exposes one or more resources at ui://<server>/<name> URIs with MIME text/html;profile=mcp-app. Resources are pre-declared so the host can prefetch, hash, and review them before any tool ever runs. Content is served through the ordinary resources/read call — no new endpoints.
- Any tool that wants to render a UI attaches _meta.ui = { resourceUri: 'ui://…', visibility: ['model' | 'app'] } to its schema. visibility controls whether the model can 'see' the UI reference (useful for models that reason about UI affordances) or whether it's app-only.
- Inside the sandboxed iframe, UI code speaks JSON-RPC 2.0 to window.parent via postMessage. First message is typically ui/initialize with the tool arguments and any handles the server passed down. From there the UI can request additional resources, call tools (with user approval), and send notifications back.
Wire shapes you'll actually type
Capability advertisement on a client request:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "get_weather", "arguments": { "location": "Milan" } },
"_meta": {
"capabilities": {
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
}
The tool schema, on the server side:
{
"name": "get_weather",
"description": "Get current weather and a 7-day forecast for a location.",
"inputSchema": {
"type": "object",
"properties": { "location": { "type": "string" } },
"required": ["location"]
},
"_meta": {
"ui": {
"resourceUri": "ui://weather-server/dashboard-template",
"visibility": ["model", "app"]
}
}
}
The UI resource declared next to it:
{
"uri": "ui://weather-server/dashboard-template",
"mimeType": "text/html;profile=mcp-app",
"_meta": {
"ui": {
"connectDomains": [],
"permissions": []
}
}
}
And the first message the iframe sends back:
UI → host: JSON-RPC 2.0 over postMessage
window.parent.postMessage(
{
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: {
toolName: "get_weather",
toolArguments: { location: "Milan" },
toolResult: /* whatever the server returned alongside the UI */
}
},
"*"
);Everything after that — asking the host to call another tool, subscribing to a resource, sending a UI-side event — is more JSON-RPC 2.0 on the same channel, structured identically to the wire protocol you already speak on the server side. That's the whole point: UI developers can use the standard @modelcontextprotocol/sdk instead of learning a bespoke shim.
Security model in one screen
- Sandbox: content renders in a sandboxed <iframe>. Default: no top-level navigation, no forms to arbitrary origins, no popups, no plugins.
- CSP by default: connect-src 'none'. The UI cannot fetch() anywhere. To allow specific origins, the server declares them in the resource's _meta.ui.connectDomains and the host builds the CSP header from that list.
- Permission Policy: the resource's _meta.ui.permissions map into the iframe's allow attribute — camera, microphone, geolocation, clipboard-write. Nothing is granted implicitly.
- Every UI-initiated write is a normal tool call. The host validates it, may require user approval, and logs it in the same audit stream as a model-initiated call.
- Templates are prefetchable and hashable. A host that pins to a specific hash catches a swap-under-you attack; a host that doesn't gets whatever the server serves that day.
When to actually reach for an App
The bar is high on purpose — every App is client surface area for you and attack surface for the user. Reach for one only when a UI beats a chat turn by a clear margin:
- Data-grid confirms. "Here are 47 rows I'm about to update — uncheck the ones you don't want." A chat rendering of that is either enormous or dishonest; a grid with checkboxes is honest and fast.
- Chart-driven approval. "Here's the query plan / cost projection / trace waterfall. Approve or reject." Charts are cheap in HTML and terrible as ASCII.
- Structured pickers where the shape of the input is not text. Date-range with a mini calendar, tree-select over a filesystem or an org chart, map picker with a bounding box.
- In-place editors for the small step where a diff view + accept/reject beats regenerating the whole answer.
If your idea is "let me embed a whole dashboard," it doesn't belong here — link out. If your idea is "let me run arbitrary user code in the sandbox," it definitely doesn't belong here.
The 2025-11-25 compatibility contract
MCP Apps rides on the extension framework introduced in the 2026-07-28 core spec, but the extension itself has been Final since 2026-01-26. Practical implication: a client that only speaks the older 2025-11-25 revision plus this extension can already render Apps; a stateless 2026-07-28 client picks it up as one entry in its per-request capability map. Because the extension is opt-in on both sides, a server that adds it never breaks an old client — it just skips the UI branch. This is the shape every future MCP extension will take, so understanding this handshake pays down debt for the ones to come.
Where MCP Apps lands on the AILmanac map
- MCP 2026-07-28: The Stateless Spec — the core protocol Apps rides on; especially the Extensions framework section (SEP-2133).
- MCP & Connecting to Tools — the API-side connector. It's the client that will render the iframe; today's connector abstracts the transport, so Apps flows through unchanged.
- MCP in Claude Code — where Claude Code's MCP support is going; UI rendering is a client capability, not a server capability, so this is the surface that decides which Apps ever render for you.
- Securing MCP Servers — the hardening pattern for the servers on the other end of that iframe.
Quick check
Check yourself
0/4Vocabulary
Sources & further reading
- SEP-1865: MCP Apps — Interactive User Interfaces for MCP — the specification itself.
ext-appsrepository — the frozen 2026-01-26 spec text: capability shape, CSP defaults, postMessage semantics.- MCP Apps: Extending servers with interactive user interfaces — the announcement post that walks through the design rationale.
- The 2026-07-28 MCP Specification Release Candidate — the core release Apps ships alongside; the Extensions framework section explains the negotiation pattern all extensions now follow.
- Anthropic and OpenAI Join Forces to Standardize Interactive AI Interfaces with MCP Apps — context on the multi-vendor collaboration behind SEP-1865.
Next
- MCP 2026-07-28: The Stateless Spec — the core protocol every MCP Apps host and server also speaks.
- Securing MCP Servers — the server-side hardening you want in place before you accept UI-initiated tool calls.
- MCP & Connecting to Tools — how the API-side connector actually reaches the server whose App you're rendering.