Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

Handbooks  /  MCP Apps
Handbook~13 min readMCPworked protocol + runnable code
Handbook

MCP
apps.

Assistants have mostly spoken in text. MCP Apps change that: a tool can now render a real interactive UI — a form, a chart, a booking widget — right inside Claude or ChatGPT. But an interactive surface shipped by a third party, running inside your trusted assistant, is a security problem wearing a friendly face. The answer is an old, well-understood pattern: put it in a sandboxed iframe, and make every request it wants to send cross a single bridge the host controls.

01

UI, not just text

An MCP server gives a model tools and data. An MCP App adds the missing layer: a rendered, interactive UI the human sees and clicks, displayed by the host assistant. Proposed on November 21, 2025 as SEP-1865, written by MCP core maintainers at Anthropic and OpenAI together with the creators of the community MCP-UI project, and launched as an official MCP extension on January 26, 2026, it lets a tool return a control panel instead of a paragraph.

That's a genuine capability jump, and a genuine trust problem. The UI is third-party code you didn't write, running inside an app that holds the user's conversation, credentials, and context. You can't just drop it into the page. Everything about how MCP Apps are built is shaped by that one fact — the UI is untrusted, and the host is not.

02

The sandbox: a locked room with one door

MCP App UIs render in a sandboxed iframe. By construction, a sandboxed iframe cannot reach into the host page's DOM, read its storage, or make network calls on the host's behalf. It's isolated — a locked room. The only way anything gets in or out is a message channel the host explicitly wires up.

Untrusted UI, isolated — one monitored door out
Host (trusted)
conversation, creds
│
postMessage bridge
the ONLY door
│
Sandboxed iframe
3rd-party UI (untrusted)

This is sandboxing applied to UI: assume the code inside is hostile, give it no ambient authority, and force every request through a single, inspectable channel. The isolation isn't a nice-to-have — it's the entire reason it's safe to render someone else's UI inside your assistant.

03

The bridge speaks JSON-RPC

The door between iframe and host is postMessage, and the language spoken across it is JSON-RPC — the same structured request/response protocol MCP itself uses. The iframe posts a message naming a method and params with an id; the host posts back a response carrying the same id and either a result or an error.

iframe → host: { "jsonrpc":"2.0", "id":7, "method":"getWeather", "params":{…} }
host → iframe: { "jsonrpc":"2.0", "id":7, "result":{…} } // same id

Structured, typed, correlated by id. Nothing free-form crosses the boundary — just named method calls the host knows how to reason about.

Using a real RPC protocol rather than ad-hoc messages is what makes the boundary auditable. The host isn't parsing arbitrary strings from untrusted code; it's receiving discrete, named requests it can accept or reject one at a time.

04

The host mediates every request

Here's where the security actually lives. The host doesn't execute whatever the iframe asks — it checks each method against an allowlist of capabilities it has chosen to expose. Method on the list? Execute and return a result. Method not on the list? Return an error and do nothing. The iframe cannot reach a capability the host didn't deliberately hand it, because the sandbox gives it no other path.

host_handle(req): method ∈ allowlist ? execute → result : error(−32601)

Least privilege at the UI boundary: the app gets exactly the capabilities the host exposes, and a request for anything else — including host internals — is refused by default.

The key idea

Sandbox + bridge + allowlist is the whole model. The sandbox removes ambient authority; the JSON-RPC bridge makes every request explicit; the allowlist decides which requests are honored. An MCP App is powerful because it can ask the host to do things — and safe because the host, not the app, decides what "things" means.

The runnable host below is that mediator: it round-trips an allowlisted call, refuses everything else with a structured error, and shows that even a host-internal method name can't be reached through the bridge.

05

Build one: a tool, a ui:// resource and a View

An MCP App is two registrations on an ordinary MCP server, tied together by one URI. The tool does the work and points at its UI through _meta.ui.resourceUri. The resource serves the UI itself: one HTML document at a ui:// URI, with the MIME type text/html;profile=mcp-app. Here is the shape of the official quickstart, using the @modelcontextprotocol/ext-apps SDK:

const resourceUri = "ui://get-time/mcp-app.html";

registerAppTool(server, "get-time", {
  description: "Returns the current server time.",
  inputSchema: z.object({}),
  _meta: { ui: { resourceUri } },  // links the tool to its UI
}, async () => ({ content: [{ type: "text", text: new Date().toISOString() }] }));

registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE },
  async () => ({ contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }));

Server side, condensed from the ext-apps quickstart. html is the View, bundled into a single file (the quickstart uses Vite with a single-file plugin).

The View is a plain web page. It creates an App, registers its handlers and connects. app.connect() runs the handshake described in the next section, and callServerTool sends a tools/call through the host.

const app = new App({ name: "Get Time App", version: "1.0.0" });
app.ontoolresult = (result) => render(result);  // set before connect()
button.onclick = async () =>
  render(await app.callServerTool({ name: "get-time", arguments: {} }));
app.connect();

If the View needs the network, the resource says so in its own metadata, _meta.ui.csp: connectDomains for fetch and WebSocket origins, resourceDomains for scripts, styles, images and fonts, and frameDomains for nested iframes. The host builds the iframe's Content-Security-Policy from those lists and, in the spec's words, "MUST NOT allow undeclared domains." Declare nothing and you get the restrictive default, which includes connect-src 'none': the View can't call out at all. Declaring domains up front also lets the host warn the user, and log, where a UI can reach before it renders.

06

The real message flow, from tools/list to teardown

The bridge example above and the runnable below use a stand-in method, getWeather. On the wire, the View (the spec's name for the app's iframe) speaks a small, fixed vocabulary: a set of ui/ methods plus a few standard MCP messages. Here is one render, in order, per the 2026-01-26 specification:

host ⇄ server  tools/list (tool carries _meta.ui.resourceUri), resources/read (the HTML)
host → server  tools/call get-time  // in parallel with rendering the View
view → host    ui/initialize  // host replies with its context and capabilities
view → host    ui/notifications/initialized
host → view    ui/notifications/tool-input-partial (0..n), then ui/notifications/tool-input
host → view    ui/notifications/tool-result  // or ui/notifications/tool-cancelled
view → host    tools/call, ui/message, ui/open-link, ui/update-model-context
host → view    ui/resource-teardown
MessageDirectionWhat the host does
ui/initializeView → hostThe handshake. It replaces MCP-UI's old iframe-ready message, and the View confirms with ui/notifications/initialized.
tool-input, tool-resulthost → ViewPushes the arguments the model chose and the tool's result, so the View renders data it never fetched.
tools/callView → host → serverProxied to the View's own server. The host MUST reject calls to tools whose visibility lacks "app", and cross-server calls to app-only tools are always blocked.
ui/messageView → hostAdds a message to the conversation. The host MAY ask the user first.
ui/open-linkView → hostOpens a URL in the user's browser, or returns an error such as "Link opening denied by user".
ui/update-model-contextView → hostOverwrites what the model is told about the View's current state.

Two details carry the security model. First, on a web host the View never shares an origin with the host. The host renders a sandbox proxy on a different origin, which signals ui/notifications/sandbox-proxy-ready, receives the HTML in ui/notifications/sandbox-resource-ready, and loads it in an inner iframe under the declared CSP. Second, this page's "allowlist" has a concrete form: a tool's visibility, which defaults to ["model", "app"], decides who may call it. A tool marked ["app"] never appears in the model's tool list, and one marked ["model"] can't be called from the View. On top of that, the spec lets hosts require explicit user approval for UI-initiated tool calls.

07

Where it runs, and what it unified

MCP Apps went live as an official extension on January 26, 2026, with support in Claude (web and desktop), Goose and VS Code Insiders, and ChatGPT starting that week. It unified two earlier approaches: the community MCP-UI project, whose adopters included Postman, Hugging Face, Shopify, Goose and ElevenLabs, and OpenAI's Apps SDK, which already used MCP as its backbone. One server can now ship one UI to every host that negotiates the extension (io.modelcontextprotocol/ui), and a host that doesn't support it still gets a working, text-only tool.

It also sharpens the map of the MCP world. The MCP server is the capability backend; the MCP App is the interactive frontend; and the untrusted-component discipline from agent supply-chain security applies to both — an app you install is another dependency whose blast radius the host's allowlist is there to bound.

Read next

The protocol underneath: MCP. The isolation pattern: Sandboxing. The trust discipline: Agent Supply-Chain Security.

RUN IT YOURSELF

The host as gatekeeper: mediate every iframe request

This is the entire MCP App security model in one function. A sandboxed iframe posts JSON-RPC requests; the host handles each by checking the method against an allowlist of capabilities it chose to expose. An allowlisted call round-trips (same id, a result); anything else — including a made-up destructive method or a host-internal name — comes back as a structured error and never executes. Change the allowlist and the requests, and watch what does and doesn't get through the door.

CPython · WebAssembly
Frequently asked

Quick answers

What are MCP Apps?

Interactive UIs that render inside an assistant like Claude or ChatGPT instead of plain text — a form, chart, or control panel shipped by an MCP server and shown in a sandboxed iframe. The first official UI extension to the Model Context Protocol.

How is the app isolated?

It runs in a sandboxed iframe that can't touch the host page, its DOM, storage, or network directly. The only way out is a message channel — a locked room with a single monitored door.

How does it talk to the host?

Via postMessage carrying JSON-RPC: the iframe sends a method + params + id, the host checks the method against an allowlist, executes if permitted, and returns a response with the same id. Everything crosses this one mediated bridge.

How is it different from an MCP server?

The server is the capability backend (tools/data over JSON-RPC); the app is the interactive frontend (the UI the human clicks, sandboxed). They compose, but the app adds presentation plus a new untrusted-code boundary to mediate.

How do you build an MCP App?

Register two things on an MCP server: a tool whose _meta.ui.resourceUri points at a ui:// resource, and that resource, which returns a single HTML document with the MIME type text/html;profile=mcp-app. Inside the HTML, the View creates an App from the @modelcontextprotocol/ext-apps SDK and connects. It then receives the tool's input and result as notifications, and can call its own server's tools back through the host. Declare any network origins the View needs in the resource's CSP metadata, or the host blocks them.

The MCP Apps Handbook · Vibe Engines · 2026 · protocol details from the MCP Apps specification (SEP-1865, 2026-01-26) and the ext-apps SDK docs · more handbooks →
Finished this one? 0 / 208 Handbooks done
NextThe Agent Skills Handbook Next on the “Tools, protocols and interop” ladder · rung 4 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Handbooks