MCP Apps: How to Build Interactive UIs for MCP Servers
MCP Apps (SEP-1865) is a recent extension to the Model Context Protocol that lets a server hand the host an interactive UI rather than only text or structured JSON. The host renders that UI inside its own surface — Claude Desktop, ChatGPT and Cursor have all been moving in this direction — and the UI can call back into the server through the existing MCP transport.
The reason it matters is that a lot of the things people want agents to do are not really text-shaped. A pipeline view, a comparison chart, a form with validation, a sortable table — these have always been awkward to express as a tool response. MCP Apps gives them a first-class home without throwing out the auth, sandboxing or audit story that MCP already had.

The problem it's solving
Until this extension, every host had to invent its own rich-UI story. MCP-UI took one approach, OpenAI's Apps SDK took a different one, and a few smaller hosts built one-off iframe bridges. If you wanted your server's UI to render in more than one of them, you wrote adapters. SEP-1865 is mostly an attempt to consolidate those efforts into a single spec the hosts can implement once.
How it works
There are three pieces to the spec, and they slot into the existing MCP primitives rather than replacing them.
1. UI Resources
A new type of resource identified by the ui:// URI scheme. When your server declares a UI resource, it's saying "I have an interactive interface available at this location."
{
uri: "ui://affinity-dashboard",
name: "affinity_dashboard_ui",
description: "Interactive Affinity CRM dashboard",
mimeType: "text/html+mcp"
}
The initial spec focuses on HTML (text/html+mcp), but the architecture leaves room for future formats like declarative UI or remote DOM.
2. Tool Metadata Linking
Tools reference UI resources through a _meta field:
{
name: "affinity_dashboard",
description: "Open interactive Affinity CRM dashboard",
_meta: {
"ui/resourceUri": "ui://affinity-dashboard"
}
}
When an AI calls this tool, the host knows there's a UI available. The host can then fetch the UI resource and render it.
3. Bidirectional Communication
The UI runs in a sandboxed iframe but isn't isolated from the rest of the MCP session. It can issue standard JSON-RPC messages to the host over postMessage, and the host proxies them back through the same MCP transport that the original tool call came in on.
From inside the iframe, you can:
// Call another MCP tool
window.parent.postMessage({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "affinity_list_companies",
arguments: { limit: 100 }
}
}, '*');
// Read a resource
window.parent.postMessage({
jsonrpc: "2.0",
method: "resources/read",
params: { uri: "data://companies" }
}, '*');
The host proxies these requests back to your MCP server and sends results via notifications:
// Host sends result back to iframe
{
method: "ui/notifications/tool-result",
params: {
requestId: "...",
result: { /* tool response */ }
}
}
The result is a normal request/response loop the UI can use to stay live, except every request is observable and gateable by the host.
The security model
Everything in the UI runs inside a sandboxed iframe with a tight Content Security Policy. The iframe has no direct network access — it can't fetch() your backend, can't open WebSockets, can't load arbitrary scripts. Anything it wants from the outside world has to go through postMessage to the host, which means it has to go through MCP.

In practice this gives the host a clean point at which to log, rate-limit, prompt the user, or refuse a request. Because every call is a JSON-RPC message rather than an opaque network request, an audit log of "what did this UI actually do" is essentially free; the host already has it.
What This Means for Developers
If you're building MCP servers, you can now:
- Return HTML from a resource endpoint
@mcp.resource("ui://dashboard")
async def get_dashboard():
html = await render_dashboard()
return {
"uri": "ui://dashboard",
"mimeType": "text/html+mcp",
"text": html
}
- Link tools to UIs via metadata
@mcp.tool()
async def show_dashboard():
return {
"content": [{"type": "text", "text": "Dashboard loaded"}],
"_meta": {"ui/resourceUri": "ui://dashboard"}
}
- Build interactive UIs that call back to your server
Your HTML can use JavaScript to make tool calls and resource reads, creating a fully interactive experience.
The HTML you return is just a string, so how you generate it is your problem, not the protocol's. We mostly use a small Python templating helper; other teams I've talked to are using full component libraries on the server side. The spec is deliberately silent here.
Real-World Example: CRM Dashboard
Let's say you're building an Affinity CRM integration. You want a dashboard showing:
- Recent companies
- Active opportunities
- Pipeline status

Step 1: Define the tool
@mcp.tool()
async def affinity_dashboard():
"""Open interactive Affinity CRM dashboard"""
return {
"content": [{"type": "text", "text": "Dashboard opened"}],
"_meta": {"ui/resourceUri": "ui://affinity-dashboard"}
}
Step 2: Create the UI resource
@mcp.resource("ui://affinity-dashboard")
async def get_affinity_dashboard():
# Fetch initial data
companies = await call_tool("affinity_list_companies", {"limit": 10})
opportunities = await call_tool("affinity_list_opportunities", {"limit": 10})
# Generate HTML however you want
html = generate_dashboard_html(companies, opportunities)
return {
"uri": "ui://affinity-dashboard",
"mimeType": "text/html+mcp",
"text": html
}
The HTML you return can include JavaScript that uses window.parent.postMessage() to trigger tool calls for filtering, sorting, or loading more data. The host will proxy those requests back to your server and send results via notifications.
What hosts have to do
The work on the host side isn't huge if the host already speaks MCP. It needs to notice the _meta.ui/resourceUri annotation when a tool returns, fetch that resource via the existing resources/read, and render the HTML inside a sandboxed iframe. After that it's a postMessage listener that forwards MCP-shaped requests from the iframe down the existing transport, and a small notification path (ui/notifications/*) for sending results back.
There's no new transport to implement and no new auth model — everything piggybacks on the JSON-RPC plumbing the host already has for tool calls.
What changes for the rest of us
The interesting consequence is that you stop writing per-host adapters. If you'd already invested in an MCP-UI integration or in OpenAI's Apps SDK, that work isn't wasted (both projects are aligning toward the spec), but new servers don't have to pick a side.
It's also cleanly opt-in on both ends. A server that doesn't return ui/resourceUri looks identical to a pre-extension server. A host that doesn't yet implement the extension just ignores the annotation, and the tool still returns its text content as it always did. So you can ship a UI on day one and not worry about breaking older clients in the field.
What's still being worked out
The current spec deliberately ships HTML and stops there. Several follow-on threads are visible on the SEP issues — declarative UI descriptions in JSON, Shopify-style remote DOM, server-pushed streaming updates, and ways to persist UI state across sessions — but none of these are part of the v1 surface. If you have opinions on any of them, the spec discussion is the place to put them; it's still moving.
For now: HTML, sandbox, postMessage, JSON-RPC. That's the whole shape.
Want to try MCP Apps? Check out MCPBundles to see how we're implementing dynamic UI resources across hundreds of provider integrations.