How MCP Apps Work: Tools That Return Interactive UI

June 23, 2026 · updated June 25, 2026 · AI Agent Internals: How Coding Agents Really Work (part 5)

▶ Watch on YouTube & subscribe to The Stack Underflow

Most people picture an MCP tool as a function that returns a string. The agent calls it, gets some text back, puts the text in the context window, and the model reads it. That mental model was never quite complete, and as of the ratified MCP Apps specification (dated 2026-01-26), it is definitively out of date.

A modern MCP tool can return an interactive UI — a sortable table, a live dashboard, a form with validation, a 3D viewer, a real-time metrics panel — rendered right inside the chat conversation. The user clicks, types, and navigates directly inside that interface. When they do, those actions travel back to the model over a secure channel. The model stays in the loop; the UI is not a dead end. This tutorial traces the full mechanism from the moment an agent calls a tool to the moment a user clicks a button and the model hears about it.

The one-sentence version: An MCP tool embeds a pointer to a UI resource in its result; the host fetches that resource and renders it in a sandboxed iframe; and the iframe communicates with the host — and therefore the model — via JSON-RPC over postMessage, creating a bidirectional channel between the user’s cursor and the AI.

The problem that text cannot solve

Text is a poor interface for some jobs, and the round-trip cost makes it worse.

When a tool returns 40 rows of data, the model serializes a table into prose. You read the prose. You ask “sort by date.” The model generates another round-trip — more tokens, more latency, and a response that is still just more prose. The right interface for that job is a sortable table you click directly. No tokens, no round-trip.

The same gap shows up in deployment configuration (dozens of interdependent choices that belong in a form), live monitoring (a dashboard that should update continuously without you asking “what’s the status now?”), and rich media (you cannot zoom a PDF inside a sentence). MCP Apps define a protocol for filling that gap without breaking the model out of the loop. The UI becomes an input surface for the AI, not a bypass around it.

Four primitives, one loop

MCP Apps are built from four pieces. Once you see each piece, the whole architecture is legible.

Primitive 1: Tool declaration
  The tool's description includes _meta.ui.resourceUri
  pointing at a ui:// resource the server exposes.

Primitive 2: UI Resource
  A server-side resource (ui://charts/interactive) that
  contains a bundled HTML page (and its JS/CSS). The
  host fetches this via the standard MCP resources/read
  call — using the ui:// scheme.

Primitive 3: Sandboxed iframe
  The host (Claude, VS Code, Goose …) renders the HTML
  page inside a sandboxed iframe in the conversation.
  The sandbox blocks DOM access, cookie/storage reads,
  and navigation of the parent page.

Primitive 4: JSON-RPC over postMessage
  The iframe and host communicate via JSON-RPC using
  window.postMessage — the same base protocol as core
  MCP, but with a ui/ method prefix. Every message is
  auditable; there is no back-channel.

The complete loop looks like this:

User asks: "show me sales by region"
    |
    v
Model calls tools/call  -->  Server returns tool result
                              + _meta.ui.resourceUri: "ui://charts/sales"
    |
    v
Host fetches ui://charts/sales from MCP server
Host renders HTML in sandboxed iframe inside chat
Host pushes tool arguments + result into the iframe
    |
    v
User clicks a region in the chart
    |
    v
iframe sends JSON-RPC message over postMessage to host
    (e.g., tools/call to fetch drill-down data)
    |
    v
Host proxies the call to the MCP server
Server returns fresh data
Host pushes fresh data into the iframe
    |
    v
App updates the chart
App sends ui/update-model-context to keep the model informed

Every step uses existing MCP primitives — resources, tools, JSON-RPC — with one new transport layer (postMessage inside an iframe). That is the entire extension.

The spec in detail: what goes where

Tool-side: declaring the UI

The tool description carries the pointer to the UI resource:

{
  "name": "visualize_sales",
  "description": "Renders an interactive regional sales chart.",
  "_meta": {
    "ui": {
      "resourceUri": "ui://charts/sales",
      "csp": ["https://cdn.example.com"],
      "permissions": []
    }
  }
}
  • resourceUri (required) — the ui:// URI the host fetches. The host can preload this resource before the tool is even called, enabling streaming of tool inputs to the app.
  • csp — an explicit allowlist of external origins the app may load resources from. Everything not listed is blocked. Default is restrictive.
  • permissions — opt-in capabilities beyond the default sandbox (microphone, camera, etc.). Hosts may decline.

Resource-side: what the host fetches

The UI resource is a standard MCP resource at the declared ui:// URI. It contains a self-contained HTML document — typically with its JavaScript and CSS bundled in — that the host can render without any external dependencies unless csp explicitly allows them.

Communication protocol: the ui/ dialect

The iframe and host speak JSON-RPC over postMessage. The protocol is described in the spec as its own dialect of MCP — some method names are shared with core MCP (tools/call, resources/read), some are analogous (ui/initialize), and most are new with the ui/ prefix. Key messages:

Lifecycle
  ui/initialize       Host -> App: delivers host context, capabilities,
                      display mode, container dimensions, theme, locale

  ui/resource-teardown  Host -> App: signals shutdown, lets app preserve state

Data flow
  tools/call          App -> Host -> Server: invoke any server tool
  resources/read      App -> Host -> Server: read any server resource

  ui/message          App -> Host: send a chat message to the conversation

  ui/update-model-context  App -> Host: push information into the model's
                           context (e.g., "user selected Q3 in the chart")

Navigation
  ui/open-link        App -> Host: request to open an external URL
                      (host decides whether to allow it)

The host context delivered in ui/initialize includes theme (light/dark), locale, timezone, platform (web/desktop/mobile), current display mode, and container dimensions — so your app can render responsively without polling.

Display modes

The host controls which display mode the app runs in. Three modes are defined:

ModeRendering locationBest for
inlineEmbedded in the chat flowCharts, previews, forms, short-lived widgets
fullscreenTakes over the host windowComplex editors, games, immersive dashboards
pip (picture-in-picture)Persistent overlayLive monitoring panels, timers, media players

Tool visibility

By default a tool is visible to both the model and the UI app (["model", "app"]). You can restrict this:

visibility valueWho sees the tool
["model", "app"]Both — the default
["app"]UI only; the model never sees it (good for pagination, form submission, UI state ops that would clutter the agent’s context)
["model"]Model only; the app cannot call it

App-only tools are the key to keeping UI interactions out of the model’s context when they don’t need to be there. A “next page” button in a data explorer does not need to consume tokens.

Progressive enhancement: what happens when the host doesn’t support MCP Apps

The spec mandates capability negotiation during the MCP handshake. A host that supports MCP Apps advertises this in its capabilities object. Servers check for the capability before registering UI-enabled tools. If the host does not support MCP Apps, the server falls back to registering standard text-returning tools. The user gets a text response instead of a rendered UI — functionality intact, experience degraded gracefully.

This means MCP Apps are a progressive enhancement, not a breaking change. You ship one server; it gives every client the best experience it can handle.

The security model is the architecture

The sandboxed iframe is not a convenience — it is the security boundary that makes the whole system safe to deploy. Here is what the sandbox enforces:

What the iframe CANNOT do                Why it matters
------------------------------------------------------------------
Read the host's DOM                      Cannot scrape conversation history
Read the host's cookies / storage        Cannot steal session tokens
Navigate the parent page                 Cannot redirect the user to phishing
Execute scripts in parent context        Cannot impersonate the host UI
Make arbitrary network requests          Cannot exfiltrate data to any origin
                                         not declared in _meta.ui.csp

The only communication channel between the iframe and the host is postMessage carrying JSON-RPC. Every message is structured, auditable, and passes through the host’s own consent logic before anything reaches the MCP server. The host may restrict which tools the app can call and may require explicit user consent for UI-initiated tool calls.

Pre-declaration of the UI resource (ui://... is declared in the tool description, not chosen dynamically at runtime) means the host can review and pre-fetch the HTML before a tool is ever called — both a performance win and a security gate.

The SDK and ecosystem

@modelcontextprotocol/ext-apps v1.1.2 is the official SDK. It provides:

  • App class (view-side): handles the postMessage channel and exposes methods like notify, callTool, readResource, sendMessage, and updateModelContext. This is the class your iframe-side code uses.
  • App Bridge (host-side): handles rendering apps in sandboxed iframes, message passing, tool call proxying, and security policy enforcement. Use this if you’re building a client that wants to support MCP Apps.
  • registerAppTool / registerAppResource (server-side): helpers for declaring tools and resources that follow the spec correctly.

The App class is a convenience wrapper. The spec is built on standard web primitives (postMessage, JSON-RPC, sandboxed iframe), so you can implement it with any framework or none at all. The ext-apps repo ships starter templates for React, Vue, Svelte, Preact, Solid, and vanilla JavaScript.

@mcp-ui/client and @mcp-ui/server from the MCP-UI project (mcpui.dev) are the community reference implementation that predated and helped shape the official spec. MCP-UI is now a reference implementation and community playground for the official standard, not a competitor. If you’re on MCP-UI, migration is straightforward; if you’re starting fresh, the official ext-apps SDK is the right starting point.

A minimal working example

The smallest MCP App that actually demonstrates the loop:

// Server: declare a tool with a UI resource pointer
server.tool("pick_priority", "Pick a task priority.", {}, async () => {
  return {
    content: [{ type: "text", text: "Pick a priority:" }],
    _meta: {
      ui: { resourceUri: "ui://priority-picker" }
    }
  };
});

// Server: expose the UI resource at that URI
server.resource("priority-picker", "ui://priority-picker", async () => {
  return {
    contents: [{
      uri: "ui://priority-picker",
      mimeType: "text/html",
      text: `<!DOCTYPE html>
<html>
<body>
  <button id="high">High</button>
  <button id="low">Low</button>
  <script type="module">
    import { App } from "@modelcontextprotocol/ext-apps";
    const app = new App();
    for (const id of ["high", "low"]) {
      document.getElementById(id).onclick = () => {
        app.updateModelContext({ priority: id });
      };
    }
  </script>
</body>
</html>`
    }]
  };
});

When the user clicks “High,” updateModelContext sends a ui/update-model-context JSON-RPC message over postMessage to the host, which injects the update into the model’s context. The model sees { priority: "high" } and can respond — without the user typing a single word.

How to apply this right now

If you’re building an MCP server:

  1. Install @modelcontextprotocol/ext-apps (v1.1.2 is current). Use registerAppTool and registerAppResource to declare your tools and resources with the correct _meta.ui.resourceUri shape.
  2. Bundle your UI as a self-contained HTML document. Keep external dependencies out unless you absolutely need them and declare their origins in _meta.ui.csp.
  3. Use tool visibility: ["app"] for any tools that handle UI-only interactions (pagination, form state, selection changes). Keep those calls out of the model’s context.
  4. Test progressive enhancement: remove MCP Apps support from your test client and confirm the server falls back to text gracefully.

If you’re building an MCP client:

  1. Advertise MCP Apps support in your capabilities object during the handshake.
  2. Integrate the App Bridge module from @modelcontextprotocol/ext-apps — it handles iframe rendering, security policy enforcement, and message proxying. Or use @mcp-ui/client for a React-component wrapper.
  3. Define a consent policy for UI-initiated tool calls. The spec leaves this to the host; be explicit about what you require user approval for.

Check the current client support matrix at modelcontextprotocol.io/extensions/client-matrix before depending on MCP Apps being available in a specific host. As of the 2026-01-26 ratification, production support is confirmed in Claude, Claude Desktop, VS Code (GitHub Copilot), Microsoft 365 Copilot, Goose, and Postman.

Common misconceptions

“MCP tools only return text.” This was the original design. It has not been true since the MCP Apps spec was ratified on 2026-01-26. Tools can return a _meta.ui.resourceUri that the host renders as a full interactive interface.

“The UI replaces the model.” No. The model stays in the loop. Every UI-initiated action goes through the host, which proxies it through the same audit and consent path as a direct tool call. The app can push context updates to the model at any time. The model is a participant, not a spectator who gets cut out when the iframe appears.

“Rendering server HTML in the host is a security hole.” Only if you skip the sandbox. The spec mandates the sandboxed iframe precisely to contain third-party HTML. The sandbox blocks DOM access, cookie reads, and arbitrary network requests. The _meta.ui.csp field locks down what origins the app can reach. Pre-declaration of the ui:// URI means the host can review and pre-fetch the resource before the tool fires. The security model was a first-class design requirement, not an afterthought.

“MCP-UI and MCP Apps are competing standards.” MCP-UI (mcpui.dev) was the community project that pioneered the pattern and directly shaped the official spec. OpenAI’s Apps SDK contributed parallel design work. The MCP steering group merged both lineages into SEP-1865, which became the ratified 2026-01-26 specification. MCP-UI packages now serve as the reference implementation. They are the same thing, at different layers of officialization.

Frequently asked questions

Which clients support MCP Apps today?

As of January 2026, production support is confirmed in Claude (web and desktop), ChatGPT, VS Code with GitHub Copilot, Microsoft 365 Copilot, Goose, and Postman. MCPJam and Archestra.AI also ship support. JetBrains, AWS, and Google DeepMind had announced exploration. Always check the official client matrix — the list is moving fast.

Do I need to use the @modelcontextprotocol/ext-apps SDK, or can I implement the protocol by hand?

You can implement it by hand. The spec is built on postMessage and JSON-RPC — both are standard web primitives with no external dependencies. The App class and App Bridge are convenience wrappers that save you from writing boilerplate message handling. For a production app, use the SDK. For understanding the protocol deeply or avoiding a dependency, reading the spec directly is entirely practical.

What is the ui/update-model-context message and why does it matter?

This is the message the iframe sends to the host when it wants to push information into the model’s context — for example, “the user selected Q3” or “the user approved this expense report.” Without it, UI interactions are invisible to the model. With it, the model stays aware of what the user is doing in the interface and can respond, reason, and take follow-up actions accordingly. It is what makes the UI an input surface for the AI rather than a one-way display.

What happens when the host does not support MCP Apps?

Progressive enhancement. During the MCP capability handshake, the host advertises whether it supports MCP Apps. If it does not, the server can detect this and register standard text-returning tools instead. The user gets a text response. The experience is degraded but the functionality remains. This is by design — you ship one server that gives every client the best experience it can handle.

Is there a token cost to UI interactions?

UI-only interactions — ones handled by tools with visibility: ["app"] — do not touch the model and generate no token cost. Interactions that send ui/message or ui/update-model-context do reach the model, so they consume tokens. The visibility field is your primary lever for controlling which UI interactions become context and which stay entirely in the UI layer.

What is the relationship between MCP Apps and the A2A (Agent-to-Agent) protocol?

They solve different problems at different layers. MCP Apps is about how a tool result gets rendered to a human user — it is a host-to-user interface layer. A2A is about how one agent communicates with another agent across organizational boundaries. Both can coexist in the same system: an A2A orchestrator can delegate tasks to a sub-agent that surfaces results via MCP Apps. See A2A vs MCP: Two Protocols, One Agent Stack for a detailed breakdown.

Where this fits in the series

This tutorial is part of AI Agent Internals: How Coding Agents Really Work. It builds on the foundation of what MCP is and how tools work:

Browse all tutorials to follow the full series.


Primary sources: MCP Apps spec 2026-01-26 · modelcontextprotocol.io/docs/extensions/apps · @modelcontextprotocol/ext-apps v1.1.2 API · MCP Apps announcement Jan 2026 · MCP Apps proposal Nov 2025 · MCP-UI project · WorkOS MCP Apps deep-dive

Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.

Subscribe on YouTube →