Oct 2026
MCP Apps: interfaces inside the chat, and the problems you will run into
A regular MCP tool returns text. An MCP App also returns an interface: HTML that the host (Claude, ChatGPT, VS Code, Goose…) renders inside the conversation, in a sandboxed iframe, and that can call your tools back. A date picker, a map, a form or a live dashboard, without leaving the chat.
MCP Apps is the protocol’s first official extension (SEP-1865, id io.modelcontextprotocol/ui) and has been stable since 26 January 2026. This guide uses the latest as of October 2026:
| Piece | Version |
|---|---|
| MCP specification | 2026-07-28 |
| MCP Apps extension | 2026-01-26 (stable) |
@modelcontextprotocol/ext-apps |
2.0.3 |
TypeScript SDK (server, client, node, express) |
2.x |
zod |
4.2 or later |
How it fits together
An app is always two pieces on the same server:
- A tool whose
_meta.ui.resourceUripoints to aui://resource. - A
ui://resource that returns the HTML with the MIME typetext/html;profile=mcp-app.
When the model calls the tool, the host reads the resource, mounts the HTML in an iframe with its own origin and a strict CSP, and hands the view the tool’s arguments and result. From there the view can call server tools, tell the model what the user sees, or post a message to the chat.
What changes with the 2026-07-28 spec
The July 2026 revision makes the protocol stateless:
- The
initializehandshake is gone. Every request carries the protocol version and the client capabilities in_meta. - Sessions are gone (
Mcp-Session-Id). If you need state across calls, the server mints a handle and gets it back as an argument. server/discoveris new, and the HTTP+SSE transport is deprecated. Use Streamable HTTP for remote servers and stdio for local ones.- Capabilities declare
extensions. That is how a host announces it supportsio.modelcontextprotocol/ui.
For an app this boils down to one rule: keep nothing in memory per session. Create a server per request and carry state in handles that travel in the arguments.
Tutorial: a weather app
Dependencies
Install with npm rather than writing versions by hand, and don’t mix the old @modelcontextprotocol/sdk 1.x with ext-apps 2.x: they share no classes or types.
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/server@^2 \
@modelcontextprotocol/client@^2 @modelcontextprotocol/node@^2 \
@modelcontextprotocol/express@^2 zod@^4.2 express cors
npm install -D typescript vite vite-plugin-singlefile tsx
The server
// server.ts
import fs from "node:fs/promises";
import { z } from "zod";
import { McpServer, type CallToolResult, type ReadResourceResult } from "@modelcontextprotocol/server";
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
const resourceUri = "ui://weather/app.html";
// Replace with your real API.
async function fetchWeather(city: string) {
return { city, tempC: 23, sky: "clear" };
}
export function createServer() {
const server = new McpServer({ name: "weather", version: "1.0.0" });
// 1. The tool the model sees, linked to the interface.
registerAppTool(
server,
"get-weather",
{
title: "Weather",
description: "Shows the weather for a city.",
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ city: z.string(), tempC: z.number(), sky: z.string() }),
_meta: { ui: { resourceUri } },
},
async ({ city }): Promise<CallToolResult> => {
const data = await fetchWeather(city);
return {
// Text for the model and for hosts without UI. Never leave it out.
content: [{ type: "text", text: `${city}: ${data.tempC} °C, ${data.sky}` }],
// Data for the view.
structuredContent: data,
};
},
);
// 2. A tool only for the interface: the model doesn't see it.
registerAppTool(
server,
"refresh-weather",
{
description: "Refreshes the weather from the view.",
inputSchema: z.object({ city: z.string() }),
_meta: { ui: { resourceUri, visibility: ["app"] } },
},
async ({ city }): Promise<CallToolResult> => {
const data = await fetchWeather(city);
return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
},
);
// 3. The resource, with the HTML bundled into a single file.
registerAppResource(server, "Weather view", resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise<ReadResourceResult> => ({
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: await fs.readFile("dist/app.html", "utf-8"),
// The CSP goes HERE, on the contents item, not in the resource config.
_meta: {
ui: {
csp: { connectDomains: ["https://api.open-meteo.com"] },
prefersBorder: false,
},
},
},
],
}));
return server;
}
The transport
This is the pattern from the official ext-apps 2.0.3 examples: a new server per request, no sessions, and stdio as the local alternative.
// main.ts
import cors from "cors";
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./server.js";
if (process.argv.includes("--stdio")) {
await createServer().connect(new StdioServerTransport());
} else {
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req, res) => {
const server = createServer();
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3001);
}
SDK 2.x also ships createMcpHandler, which speaks the 2026-07-28 revision natively and still accepts older stateless clients. It is the way forward; the official examples still use the pattern above, which works just as well.
The view
// src/app.ts
import { App, applyDocumentTheme, applyHostStyleVariables, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Weather", version: "1.0.0" });
const out = document.querySelector<HTMLElement>("#weather")!;
let city = "";
const render = (d?: { city: string; tempC: number; sky: string }) => {
out.textContent = d ? `${d.city} · ${d.tempC} °C · ${d.sky}` : "No data";
};
const applyContext = (ctx: McpUiHostContext) => {
if (ctx.theme) applyDocumentTheme(ctx.theme);
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
};
// 1. Handlers BEFORE connecting: tool-input arrives only once.
app.ontoolinput = ({ arguments: args }) => { city = String(args?.city ?? ""); };
app.ontoolresult = (result) => render(result.structuredContent as never);
app.onhostcontextchanged = applyContext;
app.onteardown = async () => ({});
// 2. Connect.
await app.connect();
const ctx = app.getHostContext();
if (ctx) applyContext(ctx);
// 3. Interact.
document.querySelector("#refresh")!.addEventListener("click", async () => {
const result = await app.callServerTool({ name: "refresh-weather", arguments: { city } });
render(result.structuredContent as never);
// Let the model know what the user is looking at.
await app.updateModelContext({ content: [{ type: "text", text: `The user sees: ${out.textContent}` }] });
});
With React, useApp from @modelcontextprotocol/ext-apps/react does the same. Register the handlers in onAppCreated, which runs before connecting.
Bundling
The HTML reaches the host as a string, with no server behind it to serve your files. So everything has to go inside one HTML file:
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: { rollupOptions: { input: "app.html" }, outDir: "dist", emptyOutDir: false },
});
The lifecycle
Trying it out
# Official test host, with panels for input, result and model context
git clone https://github.com/modelcontextprotocol/ext-apps && cd ext-apps && npm install
cd examples/basic-host && SERVERS='["http://localhost:3001/mcp"]' npm start # http://localhost:8080
# To try it on claude.ai, which can't see your localhost
npx cloudflared tunnel --url http://localhost:3001
Common problems
These are the ones that come up most in the Claude and ChatGPT docs and in open GitHub issues.
You see the tool call but not the app
- No
app.connect(), or the root ends up 0 px tall. No event arrives until you connect. - Handlers registered after connecting.
tool-inputis sent only once; if you are late, you miss it (ext-apps#476). Since 1.7 there is a warning, and{ strict: true }turns it into an error. In React, register them inonAppCreated. - Assets that don’t load. Without
vite-plugin-singlefile, relative<script src>tags 404 because nobody is serving them.
The CSP silently blocks everything
- Every network request needs its domain in
_meta.ui.csp,localhostincluded during development. With no CSP declared, the host appliesconnect-src 'none'. connectDomainscoversfetchand WebSocket,resourceDomainscovers scripts, styles, images and fonts, andframeDomainscovers nested iframes.- The CSP goes on the
contentsitem returned when the resource is read. Put it in theregisterAppResourceconfig or in the tool result and it is ignored without warning.
CORS, even with the CSP right
The iframe has its own opaque origin. If your API filters by origin, declare _meta.ui.domain. In Claude the domain is sha256(connector URL) truncated to 32 hex characters, followed by .claudemcpcontent.com; the hash uses the exact URL, and the trailing slash counts. Stdio connectors can’t use it. On iOS, WebKit doesn’t send Referer across origins: filter by Origin.
Height and sizing
height: 100vhwithautoResizecreates an endless growth loop. Use a fixedmin-height.- Height gets stuck after leaving fullscreen (ext-apps#502). Since 1.7 you can turn off auto-resize with
useApp({ autoResize: false })and callsendSizeChangedyourself. - Some hosts have measured the document height instead of honouring
size-changed(claude-ai-mcp#69). In the meantime, setdocument.documentElement.style.heightafter rendering and keep sending the size.
What the model sees
- Per the spec,
structuredContentis not added to the model’s context. In ChatGPT it is: only_metastays hidden. So always provide a textcontent, keepstructuredContentsmall, and move anything large or sensitive to_meta. - Helper tools for the interface get
visibility: ["app"]. If a tool’s visibility doesn’t include"app", the host refuses calls to it from the view. - Use
updateModelContextto say what the user sees or changed, andsendMessageonly when you want the model to reply.
Large results
On claude.ai, with code execution on, a result over roughly 150,000 characters is written to a file and the app only gets a pointer. Claude Code cuts off at 25,000 tokens by default. Paginate, or let the view fetch the data through ["app"] tools.
Several live copies
Every call mounts a new iframe, and the earlier ones stay alive and keep sending context. Claude documents how to retire them: the server mints { createdAt, seq } and the copies coordinate over a BroadcastChannel. In ChatGPT, consecutive calls unmount and remount the view; make rendering idempotent and able to recover from the result.
State and caching
localStoragemay not exist in the iframe. Wrap it intry/catchand keep anything important on the server.- Hosts cache the resource by URI. After a breaking change, publish a new URI (
ui://weather/app-v2.html).
Transport, versions and auth
- A remote host can’t see your
localhost: use a tunnel. - On stdio, never log to stdout: it breaks the protocol.
- ext-apps 2.x needs SDK 2.x and zod 4.2 or later. With zod 4.0 or 4.1, schema generation fails.
- In SDK 2.x,
extra.authInfomoves toextra.http?.authInfo.
Debugging
app.sendLog()surfaces the view’s logs in the host.- In Claude Desktop: Help → Troubleshooting → enable Developer Mode, then open the tools with
Cmd+Opt+I. Your app is the inner iframe. On iOS, use Safari’s Web Inspector. - The official
basic-hostshows input, result, messages and model context in separate panels.
Theme
Don’t hard-code colours. Use the host’s variables with a fallback, for example var(--color-background-primary, #fff), apply applyDocumentTheme and respect safeAreaInsets.
Sources
- Repository and spec: modelcontextprotocol/ext-apps
- Changes in the 2026-07-28 spec
- MCP Apps overview and supported hosts, and the January 2026 announcement
- Claude: MCP Apps troubleshooting
- OpenAI: MCP Apps in ChatGPT