MCP Apps — Interactive UI Resources via `ui://`
A tool that can only return text caps what an agent can show you. MCP Apps (SEP-1724, official January 26, 2026) let a tool return sandboxed interactive HTML that renders inline in Claude Desktop, ChatGPT, Cursor, Goose, and VS Code. Dashboards, forms, maps, and 3D scenes all go through the same extension. This lesson covers the `ui://` resource scheme, the `text/html;profile=mcp-app` MIME type, the postMessage protocol between iframe and host, and the security surface you take on when a server gets to render HTML.
Learning Objectives
- Return a `ui://` resource from a tool call and set the correct MIME and metadata.
- Declare a tool's associated UI with `_meta.ui.resourceUri`, `_meta.ui.csp`, and `_meta.ui.permissions`.
- Implement the iframe sandbox postMessage JSON-RPC for UI-to-host communication.
- Apply CSP and permissions-policy defaults that defend against UI-originated attacks.
The Problem
A 2025-era visualize_timeline tool could return "Here are 14 notes organized chronologically: ...". That is a paragraph, and what the user wanted was the interactive timeline. Before MCP Apps you had two options: client-specific widget APIs (Claude artifacts, OpenAI Custom GPT HTML), or no UI at all.
MCP Apps (SEP-1724, shipped January 26, 2026) standardize the contract. A tool result contains a resource whose URI is ui://... and whose MIME type is text/html;profile=mcp-app. The host renders it in a sandboxed iframe with a limited CSP and no network access unless the server asks for it and the host grants it. The UI inside the iframe talks to the host through a small postMessage JSON-RPC dialect.
Compatible clients (Claude Desktop, ChatGPT, Goose, VS Code) render the same ui:// resource the same way. You write one server and one HTML bundle, and the UI works across them.
The Concept
The ui:// resource scheme
A tool returns:
{
"content": [
{"type": "text", "text": "Here is your notes timeline:"},
{"type": "ui_resource", "uri": "ui://notes/timeline"}
],
"_meta": {
"ui": {
"resourceUri": "ui://notes/timeline",
"csp": {
"defaultSrc": "'self'",
"scriptSrc": "'self' 'unsafe-inline'",
"connectSrc": "'self'"
},
"permissions": []
}
}
}The host then calls resources/read on the ui://notes/timeline URI and gets back:
{
"contents": [{
"uri": "ui://notes/timeline",
"mimeType": "text/html;profile=mcp-app",
"text": "<!doctype html>..."
}]
}Iframe sandbox
The host renders the HTML inside a sandboxed <iframe> with:
sandbox="allow-scripts allow-same-origin"(or stricter per server declaration)- Server-declared CSP applied via response headers.
- No cookies, no localStorage from the host's origin.
- Network access limited to
connectSrcin CSP.
postMessage protocol
The iframe communicates with the host via window.postMessage. A tiny JSON-RPC 2.0 dialect:
Pin targetOrigin to the peer's exact origin, and on the receiving side validate event.origin against an allowlist before processing any payload. Don't use "*" on either side of this channel, because the payload carries tool calls and resource reads.
// iframe to host (pin to host origin)
window.parent.postMessage({
jsonrpc: "2.0",
id: 1,
method: "host.callTool",
params: { name: "notes_update", arguments: { id: "note-14", title: "..." } }
}, "https://host.example.com");
// host to iframe (pin to iframe origin)
iframe.contentWindow.postMessage({
jsonrpc: "2.0",
id: 1,
result: { content: [...] }
}, "https://iframe.example.com");
// receiver on both sides
window.addEventListener("message", (event) => {
if (event.origin !== "https://expected-peer.example.com") return;
// safe to process event.data
});Available host-side methods the UI can call:
host.callTool(name, arguments)invokes a server tool.host.readResource(uri)reads an MCP resource.host.getPrompt(name, arguments)fetches a prompt template.host.close()dismisses the UI.
These calls go through the MCP protocol and inherit the server's permissions.
Permissions
The _meta.ui.permissions list requests extra capabilities:
camera: access the user's camera (used for scan-a-document UIs).microphone: voice input.geolocation: location.network:*: wider network access thanconnectSrcalone allows.
Each permission shows the user a prompt before the UI renders.
Security risks
HTML in an iframe is still HTML, and it opens four new attack surfaces:
- Prompt injection via UI. A malicious server UI can show text that looks like a system message and trick the user. Hosts should visibly distinguish server UI from host UI.
- Exfiltration via
connectSrc. If the CSP permitsconnect-src: *, the UI can send data anywhere. The default should be strict. - Clickjacking. The UI can overlay host chrome. Hosts need to block z-index manipulation and enforce opacity rules.
- Focus stealing. The UI grabs keyboard focus and captures the user's next message. Hosts need to intercept this.
Phase 13 · 15 covers these in depth under MCP security. Here they are just named.
ui/initialize handshake
Once the iframe loads, it sends ui/initialize over postMessage:
{"jsonrpc": "2.0", "id": 0, "method": "ui/initialize",
"params": {"theme": "dark", "locale": "en-US", "sessionId": "..."}}The host responds with capabilities and a session token, and the UI sends that token on later host calls.
AppRenderer / AppFrame SDK primitives
The ext-apps SDK has two convenience primitives:
AppRenderer(server side) wraps a React / Vue / Solid component and emits aui://resource with the right MIME and metadata.AppFrame(client side) receives the resource, mounts the iframe, and mediates postMessage.
Use them, or hand-roll the HTML and JSON-RPC.
Ecosystem status
MCP Apps shipped January 26, 2026. Client support as of April 2026:
- Claude Desktop. Full support since January 2026.
- ChatGPT. Full support via the Apps SDK (same underlying MCP Apps protocol).
- Cursor. Beta; enable via settings.
- VS Code. Insider builds only.
- Goose. Full support.
- Zed, Windsurf. Roadmapped.
Servers in production include dashboards, map visualizations, data tables, chart builders, and sandbox IDE previews.
Use It
code/main.py extends the notes server with a visualize_timeline tool that returns a ui://notes/timeline resource, plus a resources/read handler for that URI that returns a small but complete HTML bundle with an SVG timeline. The HTML is stdlib-templated, so there is no build system. The postMessage client is inline JS that the stdlib script can't run, because nothing here drives a browser.
What to look at:
_meta.uion the tool response carriesresourceUri, the CSP, and the permissions list. The script's CSP dict uses header-style names likedefault-src, while the Concept example writes the same fields in camelCase (defaultSrc).- The HTML renders without network access because all data is inlined.
- The inline JS defines a
hostCallhelper that sendswindow.parent.postMessagerequests, plus aui/initializemessage on load. Nothing callshostCallyet, and the demo only prints the HTML, so none of it runs.
Ship It
This lesson produces outputs/skill-mcp-apps-spec.md. Given a tool that would benefit from an interactive UI, the skill produces the full MCP Apps contract: ui:// URI, CSP, permissions, postMessage entrypoints, and a security checklist.
I'd run main.py first, then call timeline_html(NOTES) and read the full HTML before the spec. The script prints the tool response, the MIME type, the HTML length, the first 200 characters of the HTML, and the CSP, so you can see the whole contract in one screen without a host or a browser.
Then read the postMessage block in the HTML next to the Concept section, because the two disagree. The body says to pin targetOrigin and check event.origin. The toy sends with '*' and its reply handler matches only on id. I left that in because the demo can't know a real host origin, but I wouldn't ship it. Exercise 4 (auditing the attack surface) is the place to catch it, and pinning both origins is the fix. The CSP has the same shape: it allows 'unsafe-inline' for scripts, which exercise 2 asks you to remove with a nonce.
Also note that the click hint in the HTML mentions notes_open, but no handler is attached to the SVG nodes. Wiring that up is the smallest useful change to the demo, and it forces you to decide what the host should do with the reply.
Exercises
-
Run
code/main.pyand inspect the HTML emitted. Open the HTML directly in a browser; verify the SVG renders. Then sketch the postMessage contract the UI would use to callhost.callTool("notes_update", ...). -
Tighten the CSP: remove
'unsafe-inline'and use a nonce-based script policy. What changes in the HTML generation code? -
Add a second UI resource
ui://notes/editorwith a form for editing a note in place. When the user submits, the iframe callshost.callTool("notes_update", ...). -
Audit the UI's attack surface. Where could a malicious server inject content? What does the iframe sandbox defend against and what does it not?
-
Read the SEP-1724 spec and identify one capability in the MCP Apps SDK that this toy implementation does not use. (Hint: component-level state sync.)
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| MCP Apps | "Interactive UI resources" | SEP-1724 extension shipped 2026-01-26 |
ui:// | "App URI scheme" | Resource scheme for UI bundles |
text/html;profile=mcp-app | "The MIME" | Content-type for MCP App HTML |
| Iframe sandbox | "Render container" | Browser sandboxing of the UI with CSP and permissions |
| postMessage JSON-RPC | "UI-to-host wire" | Tiny JSON-RPC-over-postMessage dialect for host calls |
_meta.ui | "Tool-UI binding" | Metadata linking a tool result to a UI resource |
| CSP | "Content-Security-Policy" | Declares allowed sources for scripts, network, styles |
| AppRenderer | "Server SDK primitive" | Converts a framework component into a ui:// resource |
| AppFrame | "Client SDK primitive" | Iframe mount helper that mediates postMessage |
ui/initialize | "Handshake" | First postMessage from UI to host |
Further Reading
- MCP ext-apps: GitHub: reference implementation and SDK
- MCP Apps specification 2026-01-26: formal spec document
- MCP: Apps extension overview: high-level documentation
- MCP blog: MCP Apps launch: January 2026 launch post
- MCP Apps API reference: JSDoc-style SDK reference
Code
- main.py (python, 155 lines)