For a year, an MCP tool could only hand back text. Your server did the work — queried the rows, ran the numbers — and then flattened everything into a string for the model to paraphrase. MCP Apps ends that. A tool can now return a live interface — a dashboard, a form, a chart, a multi-step flow — that renders right inside the conversation, and calls back into your server when the user clicks. It's the first official MCP extension, and with the 2026-07-28 spec formalizing the extensions framework, it's production, not preview.

The 10-second answer: Add _meta.ui.resourceUri: "ui://your/app" to a tool, register a ui://your/app resource that returns HTML/JS, and in that HTML use @modelcontextprotocol/ext-appsapp.connect(), app.ontoolresult, app.callServerTool, app.updateModelContext. The host renders it in a sandboxed iframe. One resource works in Claude, VS Code, Goose, and ChatGPT.

The two primitives (this is the whole model)#

You don't learn a new server. You add two things to the MCP server you already have.

1. A tool that points at a UI. Same tool definition as always — a name, a description, an input schema — plus a _meta.ui.resourceUri:

{
  name: "visualize_data",
  description: "Visualize a dataset as an interactive chart",
  inputSchema: { /* your params */ },
  _meta: {
    ui: { resourceUri: "ui://charts/interactive" }
  }
}

2. A resource that IS the UI. Register a resource at that exact ui:// URI whose contents are the bundled HTML and JavaScript for your interface. When the client runs visualize_data, it sees the linked resource, fetches it, and renders it. That's it — the tool result flows into the UI, and the UI is a real web app that can talk back.

The mental model: the tool returns data, the resource returns the app, and the host wires them together. If you've ever compared the emerging UI standards, this is the shape we walked through in A2UI vs MCP Apps: the agent-UI standards, compared — and the repos experimenting with generative UI for agents mostly converge on it.

The front end: four calls you'll actually use#

Inside the iframe, @modelcontextprotocol/ext-apps wraps the JSON-RPC-over-postMessage channel so you never touch raw messages:

import { App } from "@modelcontextprotocol/ext-apps";

const app = new App();
await app.connect();                     // handshake with the host

// 1. Receive the tool's result the host rendered you for
app.ontoolresult = (result) => {
  renderChart(result.data);
};

// 2. Call back into your server when the user interacts
async function onRowClick(id) {
  const detail = await app.callServerTool({
    name: "fetch_details",
    arguments: { id },
  });
  renderDetail(detail);
}

// 3. Tell the MODEL what the user did, so the chat stays coherent
async function onSelect(option) {
  await app.updateModelContext({
    content: [{ type: "text", text: `User selected ${option}` }],
  });
}

That third call is the one people forget. The model didn't see the click — the iframe did. If the user picks "Option B" in your panel and you don't call updateModelContext, the next thing the model says will be wrong, because as far as it knows nothing happened. Mirror every salient action back into context. The interaction lives in the UI; the decision has to live in the conversation.

Why this isn't a security hole#

The obvious objection: you're rendering server-provided HTML inside a chat client that also holds the user's context and credentials. The extension is built around exactly that fear:

The net: an MCP App is a guest that can only speak the house language. That's the property that makes it safe to ship one to Claude, VS Code, or ChatGPT without writing a separate trust model for each.

Ship one in 20 minutes#

  1. Start from an example. The ext-apps repo ships working servers — map-server, pdf-server, system-monitor-server, sheet-music-server, threejs-server. Pick the one whose shape matches yours and read its ui:// resource.
  2. Add the _meta.ui.resourceUri to one existing tool and register the matching resource. Don't build a new server — extend the one you have.
  3. Write the smallest useful UI. A table with clickable rows beats a bespoke dashboard for a first ship. Wire ontoolresult → render, click → callServerTool, selection → updateModelContext.
  4. Let your coding agent do the bundling. The official guidance is blunt: the fastest way to build an MCP App is to hand the spec and an example to your coding agent. It's HTML/JS in a sandbox — well inside what a terminal agent one-shots.
  5. Test across two hosts. Because it's a standard extension, if it renders in Claude it should render in VS Code — but confirm, and watch that updateModelContext actually lands.

The one idea worth taking away#

MCP Apps changes what a tool result is. It used to be data the model reads. Now it can be a surface the user operates, with the model kept in the loop by hand. That splits your design decision cleanly: text when the model needs to reason over the answer, a UI when the value is in the interaction — and a single line back to updateModelContext so the two never fall out of sync. Return text by default; return a UI when a click is worth a thousand tokens.