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

MCP server tool get-weather resource ui://weather/app.html _meta.ui.resourceUri host (Claude, ChatGPT, VS Code…) model sandboxed iframe · view CSP from _meta.ui.csp postMessage · JSON-RPC tools/call · resources/read
An app is a tool plus a ui:// resource. The host reads the HTML and renders it in a sandboxed iframe that talks to it over postMessage.

An app is always two pieces on the same server:

  1. A tool whose _meta.ui.resourceUri points to a ui:// resource.
  2. A ui:// resource that returns the HTML with the MIME type text/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 initialize handshake 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/discover is 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 supports io.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

view host 1 · register ontoolinput / ontoolresult ui/initialize → ← hostContext (theme, size, mode) ui/notifications/initialized → ← tool-input (once) ← tool-result callServerTool / updateModelContext →
Order matters: the view registers its handlers, connects, and only then receives the tool input and result.

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-input is 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 in onAppCreated.
  • 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, localhost included during development. With no CSP declared, the host applies connect-src 'none'.
  • connectDomains covers fetch and WebSocket, resourceDomains covers scripts, styles, images and fonts, and frameDomains covers nested iframes.
  • The CSP goes on the contents item returned when the resource is read. Put it in the registerAppResource config 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: 100vh with autoResize creates an endless growth loop. Use a fixed min-height.
  • Height gets stuck after leaving fullscreen (ext-apps#502). Since 1.7 you can turn off auto-resize with useApp({ autoResize: false }) and call sendSizeChanged yourself.
  • Some hosts have measured the document height instead of honouring size-changed (claude-ai-mcp#69). In the meantime, set document.documentElement.style.height after rendering and keep sending the size.

What the model sees

  • Per the spec, structuredContent is not added to the model’s context. In ChatGPT it is: only _meta stays hidden. So always provide a text content, keep structuredContent small, 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 updateModelContext to say what the user sees or changed, and sendMessage only 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

  • localStorage may not exist in the iframe. Wrap it in try/catch and 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.authInfo moves to extra.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-host shows 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