Everything below ran against @modelcontextprotocol/ext-apps 1.7.5, @modelcontextprotocol/sdk 1.32.0, React 19.3 and Vite 8.3 on 2026-10-02. I typechecked the code, built it, and called the server with the SDK's own client. I did not render it inside a host, and that gap is spelled out near the end.
TL;DR: An MCP App is a normal MCP tool with
_meta.ui.resourceUriset, plus aui://resource holding bundled HTML. The host renders it in a sandboxed iframe and talks to your React code throughuseApp. The server side is boring. The part that bites is the bundle: one HTML file, everything inlined.
What Does an MCP App Change for a React Developer?
It moves your component into somebody else's page. That's the whole shift.
Until now an MCP tool returned text, an image, or structured data, and the host decided how to show it. Per the MCP docs, MCP Apps extend that: a tool declares a reference to an interactive UI, and the host renders it in place, inside the conversation. The cited use cases are dashboards, forms, media viewers and multi-step review flows.
So you're writing a React app that has no router, no URL bar and no cookies. It gets data pushed in, and it can call tools back. The host owns the frame.
How Does the Server Wire a Tool to a UI?
Two registrations that point at the same ui:// string. The URI path is arbitrary; the scheme is what tells hosts this is an app.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
const resourceUri = "ui://bundle-report/app.html";
const rows = [
{ name: "react-dom", kb: 182 },
{ name: "zod", kb: 57 },
{ name: "date-fns", kb: 31 },
{ name: "clsx", kb: 1 },
];
export function buildServer(): McpServer {
const server = new McpServer({ name: "bundle-report", version: "1.0.0" });
registerAppTool(
server,
"bundle-report",
{
title: "Bundle report",
description: "Lists dependency sizes in KB, sorted by the given key.",
inputSchema: { sort: z.enum(["name", "kb"]).default("kb") },
_meta: { ui: { resourceUri } },
},
async ({ sort }) => {
const sorted = [...rows].sort((a, b) =>
sort === "name" ? a.name.localeCompare(b.name) : b.kb - a.kb,
);
return {
content: [
{
type: "text",
text: sorted.map((r) => `${r.name}: ${r.kb} KB`).join("\n"),
},
],
structuredContent: { sort, rows: sorted },
};
},
);
registerAppResource(
server,
"Bundle report UI",
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async () => {
const html = await fs.readFile(
path.join(import.meta.dirname, "dist", "mcp-app.html"),
"utf-8",
);
return {
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
};
},
);
return server;
}
registerAppTool copies the URI into the tool's metadata. When I listed tools, _meta came back as {"ui":{"resourceUri":"ui://bundle-report/app.html"},"ui/resourceUri":"ui://bundle-report/app.html"}, so the package writes a flat legacy key next to the nested one. RESOURCE_MIME_TYPE resolved to text/html;profile=mcp-app when I read the resource back.
Notice structuredContent next to the text content. The text is what the model reads. The structured object is what your component parses. Keep both, because a host without app support shows only the text.
How Does the React Side Receive the Result?
Through useApp. It creates the App, connects it to the host and hands you { app, isConnected, error }.
import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import type { App } from "@modelcontextprotocol/ext-apps";
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
type Row = { name: string; kb: number };
type Report = { sort: "name" | "kb"; rows: Row[] };
function isReport(value: unknown): value is Report {
return (
typeof value === "object" &&
value !== null &&
Array.isArray((value as Report).rows)
);
}
function Table({ app, report }: { app: App; report: Report }) {
const [current, setCurrent] = useState(report);
const [busy, setBusy] = useState(false);
async function resort(sort: "name" | "kb") {
setBusy(true);
try {
const result = await app.callServerTool({
name: "bundle-report",
arguments: { sort },
});
if (isReport(result.structuredContent)) {
setCurrent(result.structuredContent);
}
} finally {
setBusy(false);
}
}
return (
<table aria-busy={busy}>
<thead>
<tr>
<th><button onClick={() => resort("name")}>Package</button></th>
<th><button onClick={() => resort("kb")}>KB</button></th>
</tr>
</thead>
<tbody>
{current.rows.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>{r.kb}</td>
</tr>
))}
</tbody>
</table>
);
}
function Root() {
const [report, setReport] = useState<Report | null>(null);
const { app, error } = useApp({
appInfo: { name: "Bundle report", version: "1.0.0" },
capabilities: {},
onAppCreated: (created) => {
created.ontoolresult = (result) => {
if (isReport(result.structuredContent)) {
setReport(result.structuredContent);
}
};
},
});
useHostStyles(app);
if (error) {
return <p role="alert">Connection failed: {error.message}</p>;
}
if (!app || !report) {
return <p>Waiting for the tool result...</p>;
}
return <Table app={app} report={report} />;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Root />
</StrictMode>,
);
Two details matter. Handlers go in onAppCreated, which runs before the connection, so the first pushed result can't slip past you. And the type guard isn't decoration: structuredContent arrives as an untyped object, and a host can push anything.
useHostStyles(app) applies the host's theme variables and fonts, so the table follows light or dark mode without a prefers-color-scheme query of your own. It's one line, and it's the difference between a widget and a pasted-in rectangle.
The best MCP App is a React component that stops pretending it owns the page: no router, no cookies, no theme of its own.
Why Does the Bundle Have to Be One File?
Because the iframe is locked down. The docs describe a deny-by-default CSP, so an app that wants external scripts or styles has to declare origins in _meta.ui.csp. The simpler route is to inline everything.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: { outDir: "dist", rollupOptions: { input: "mcp-app.html" } },
});
Then INPUT isn't needed; the config names mcp-app.html directly. My build ended with dist/mcp-app.html 670.83 kB | gzip: 178.22 kB, and reading the resource over MCP returned 642,916 characters of text. For a four-row table. React and the MCP client code ride along inside the string.
Is that acceptable? Probably, for a tool called a few times per conversation. I'd still watch it, because every host that preloads the resource, as the overview says it can, pulls that string before the tool even runs.
How Does the UI Call Back Into the Server?
With app.callServerTool. The sort buttons in the component above send { name: "bundle-report", arguments: { sort } } and re-render from the response. I called the same tool with the SDK client instead of a host: sort: "name" returned clsx, date-fns, react-dom, zod in that order, and omitting sort fell back to the "kb" default.
Each click is a round trip through the host to your server. Show aria-busy, disable repeat clicks if the tool is slow, and don't assume the result is instant.
What Breaks When You Upgrade to ext-apps 2.0?
Dependencies. The package's own metadata is the evidence: 1.7.5 peers on @modelcontextprotocol/sdk ^1.29.0, while 2.0.3, published 2026-09-25, peers on @modelcontextprotocol/client and core ^2.0.0, an optional @modelcontextprotocol/server ^2.0.0, and zod ^4.2.0. The build guide still shows the sdk import paths. I haven't migrated this server to 2.x, so treat the 1.x code as the verified path and read the 2.x release notes before bumping.
What Didn't I Verify?
Three things. I never rendered the app inside Claude, VS Code or the basic-host, so the sandbox behavior, useHostStyles theming and ontoolresult delivery are taken from the docs and type definitions, not from a screen. I didn't test CSP-restricted external assets. And the data is a four-row fixture, not a real bundle analyzer.
If you want to see it live, the docs suggest the basic-host example in the ext-apps repository: set SERVERS='["http://localhost:3001/mcp"]' and open localhost:8080.
Should You Build One?
If your tool's answer is a list the user will read once, no. Text is faster. If the answer is something people sort, filter or approve, yes, and React is a fine way to do it. Hosts will keep changing the frame around you. Keep the component small enough to survive that.