writing/tutorial/2026/08
TutorialAug 1, 2026·30 min read

MCP Apps: Build Interactive UIs for Your MCP Server with TypeScript

A complete walkthrough of MCP Apps (SEP-1865), the official extension that lets MCP servers ship interactive UIs. Build a revenue dashboard that renders inside Claude and ChatGPT: tool plus UI resource registration, the postMessage JSON-RPC bridge, app-only tools, host theming, CSP declarations, and model context updates.

Your MCP server can only speak in text

You built an MCP server. It queries your database, calls your billing API, pulls numbers out of your warehouse. It works. Then a user asks the assistant for last quarter's revenue by region, and your beautifully engineered tool returns a wall of markdown table cells that the model then re-summarizes, badly, into a paragraph.

Some data does not want to be a paragraph. A retention heatmap does not want to be a paragraph. A map, a budget slider, a PDF page, a seating chart, a 3D scene — these want pixels and a mouse, not tokens.

That gap is what MCP Apps closes. Ratified as SEP-1865 and stable since the 2026-01-26 spec revision, it is the first official extension to the Model Context Protocol, co-designed by the MCP-UI community, Anthropic, and OpenAI. It lets your server ship an HTML view alongside a tool, and lets that view talk back to your server over the same JSON-RPC protocol MCP already uses. Claude, ChatGPT, VS Code, Goose, and Postman all render it.

This tutorial builds one end to end.

What you'll build

A revenue dashboard MCP App: a tool named get-revenue that returns quarterly numbers and renders them as an interactive React view inside the host, with:

  • A chart and a region filter the user can click, with no round trip to the model
  • A refresh button that calls back into your MCP server directly from the UI
  • An app-only tool that the model cannot see or call, reserved for UI-driven actions
  • Automatic theming so the view matches the host's light or dark mode and fonts
  • A declared Content Security Policy, because the host sandboxes you by default
  • A ui/update-model-context call so the assistant knows what the user is looking at
  • View state that survives a scroll away and back

By the end you will have around 250 lines of application code and an accurate mental model of the trust boundary between a host, a view, and a server.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ and a package manager (this guide uses pnpm, but npm works identically)
  • Experience building an MCP server — tools and resources should be familiar. If not, start with our Build an MCP Server with TypeScript guide first
  • Working React and TypeScript — the view is a normal React app, just bundled unusually
  • An MCP host that supports the Apps extension for the final test: Claude Desktop, VS Code, or the reference host shipped in the ext-apps repository

You do not need to understand iframes deeply. The SDK handles the transport; the parts you must understand are the security consequences, and this tutorial covers those explicitly.

Step 1: The mental model

An MCP App is two MCP primitives you already know, tied together by one metadata field.

  1. A resource whose URI starts with ui:// and whose MIME type is text/html;profile=mcp-app. Its content is a complete, self-contained HTML5 document — your bundled view.
  2. A tool whose _meta.ui.resourceUri points at that resource URI.

When the model calls the tool on an Apps-capable host, the host does three things: it reads the referenced resource, renders the HTML in a sandboxed iframe, and hands the tool result to the running view. The view then behaves like an MCP client: it can call tools/call and resources/read, and the host proxies those to your server.

The lifecycle, in order:

StepActorMessage
1Modeltools/call for get-revenue
2Hostresources/read on ui://revenue/dashboard.html
3HostRenders HTML inside the sandbox
4Viewui/initialize request to the host
5HostReturns hostContext — theme, locale, container size
6Viewui/notifications/initialized
7HostDelivers the tool result to the view
8ViewCalls tools/call / ui/update-model-context as the user interacts

Two design decisions in there are worth pausing on. Templates are predeclared, so a host can inspect and prefetch every UI a server might render before any tool ever runs. And every message between the view and the host is JSON-RPC, so it is loggable and auditable — nothing happens through an opaque side channel.

The extension is identified as io.modelcontextprotocol/ui and is negotiated at connection time. A host that does not support it simply falls back to your tool's text content, which means an Apps-aware server degrades gracefully on older clients.

Step 2: Project setup

Create the project and install dependencies:

mkdir revenue-app && cd revenue-app
pnpm init
pnpm pkg set type=module
 
pnpm add @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors react react-dom
pnpm add -D typescript vite vite-plugin-singlefile @vitejs/plugin-react \
  @types/express @types/cors @types/node @types/react @types/react-dom \
  tsx concurrently cross-env

The unusual dependency is vite-plugin-singlefile. Your view must be delivered as one HTML document — the host reads a single resource, not a directory of assets. That plugin inlines every script and stylesheet into the HTML output.

Add the build scripts:

pnpm pkg set scripts.build="tsc --noEmit && cross-env INPUT=mcp-app.html vite build"
pnpm pkg set scripts.dev='concurrently --raw "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch" "tsx watch main.ts"'

Then vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
 
const INPUT = process.env.INPUT;
if (!INPUT) throw new Error("INPUT environment variable is not set");
 
const isDevelopment = process.env.NODE_ENV === "development";
 
export default defineConfig({
  plugins: [react(), viteSingleFile()],
  build: {
    // Inline sourcemaps only in dev — the bundle ships inside a JSON-RPC payload
    sourcemap: isDevelopment ? "inline" : undefined,
    cssMinify: !isDevelopment,
    minify: !isDevelopment,
    rollupOptions: { input: INPUT },
    outDir: "dist",
    emptyOutDir: false,
  },
});

Keep emptyOutDir: false. Your compiled server output lands in the same dist folder, and you do not want the view build wiping it on every rebuild.

Step 3: Register the tool and the UI resource

This is the heart of it. Create server.ts:

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { fetchRevenue } from "./data.js";
 
const DIST_DIR = path.join(import.meta.dirname, "dist");
 
// One URI, referenced from both registrations. Keep it in a constant —
// a typo here fails silently as "tool works, no UI appears".
const RESOURCE_URI = "ui://revenue/dashboard.html";
 
export function createServer(): McpServer {
  const server = new McpServer({
    name: "Revenue Dashboard",
    version: "1.0.0",
  });
 
  registerAppTool(
    server,
    "get-revenue",
    {
      title: "Get Revenue",
      description: "Show quarterly revenue broken down by region.",
      inputSchema: { quarter: z.string().describe("e.g. 2026-Q2") },
      _meta: { ui: { resourceUri: RESOURCE_URI } }, // [!code highlight]
    },
    async ({ quarter }) => {
      const rows = await fetchRevenue(quarter);
      const total = rows.reduce((sum, r) => sum + r.revenue, 0);
 
      return {
        // `content` is what the model reads — keep it short
        content: [
          { type: "text", text: `Revenue for ${quarter}: ${total} TND across ${rows.length} regions.` },
        ],
        // `structuredContent` is what the view reads — put the full payload here
        structuredContent: { quarter, rows, total },
      };
    },
  );
 
  registerAppResource(
    server,
    "revenue-dashboard",
    RESOURCE_URI,
    { mimeType: RESOURCE_MIME_TYPE },
    async () => {
      const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
      return {
        contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
      };
    },
  );
 
  return server;
}

RESOURCE_MIME_TYPE is the constant for text/html;profile=mcp-app. Use it rather than typing the string; the profile parameter is easy to get subtly wrong.

The split between content and structuredContent is the single most valuable habit in this tutorial. content goes into the model's context window and costs tokens on every turn. structuredContent is passed to your view like a React prop and, in supporting hosts, is kept out of the model's context. A tool returning 400 rows of data should return one sentence in content and the whole array in structuredContent.

If you have seen the older _meta["ui/resourceUri"] flat key in blog posts from late 2025, it still works but is deprecated and slated for removal before GA. Use the nested _meta.ui.resourceUri form.

Step 4: Build the view

The HTML shell, mcp-app.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="color-scheme" content="light dark" />
    <title>Revenue Dashboard</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/mcp-app.tsx"></script>
  </body>
</html>

Now src/mcp-app.tsx. The useApp hook creates an App instance, lets you register handlers before connecting, and then connects:

import type { App } from "@modelcontextprotocol/ext-apps";
import { useApp, useHostStyleVariables } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
 
interface RevenueRow {
  region: string;
  revenue: number;
}
interface RevenuePayload {
  quarter: string;
  rows: RevenueRow[];
  total: number;
}
 
function RevenueApp() {
  const [data, setData] = useState<RevenuePayload | null>(null);
 
  const { app, error } = useApp({
    appInfo: { name: "Revenue Dashboard", version: "1.0.0" },
    capabilities: {},
    onAppCreated: (app) => {
      // Register BEFORE connect() or you miss the initial tool result
      app.ontoolresult = (result: CallToolResult) => { // [!code highlight]
        setData(result.structuredContent as unknown as RevenuePayload);
      };
      app.onerror = console.error;
    },
  });
 
  // Adopt the host's CSS variables and color scheme
  useHostStyleVariables(app, app?.getHostContext());
 
  if (error) return <p>Failed to connect: {error.message}</p>;
  if (!app || !data) return <p>Loading…</p>;
 
  return <Dashboard app={app} data={data} onData={setData} />;
}
 
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RevenueApp />
  </StrictMode>,
);

The ordering comment matters more than it looks. The host delivers the tool result as soon as the view sends ui/notifications/initialized. If you assign ontoolresult after connect() resolves, you have a race, and it will fire on a slow machine and pass on yours. onAppCreated exists precisely to eliminate that window.

Prefer vanilla JS or another framework? The same lifecycle works without React:

import { App } from "@modelcontextprotocol/ext-apps";
 
const app = new App({ name: "Revenue Dashboard", version: "1.0.0" });
app.ontoolresult = (result) => render(result.structuredContent);
app.connect();

There are official starter examples for Vue, Svelte, Preact, Solid, and vanilla JS in the ext-apps repository — the server half is identical in every one.

Step 5: Let the view talk back

A view that only renders is a picture. The interesting part is the return path. app.callServerTool() sends a tools/call through the host to your server and resolves with the result:

function Dashboard({ app, data, onData }: {
  app: App;
  data: RevenuePayload;
  onData: (d: RevenuePayload) => void;
}) {
  const [busy, setBusy] = useState(false);
  const [selected, setSelected] = useState<string | null>(null);
 
  async function refresh() {
    setBusy(true);
    try {
      const result = await app.callServerTool({
        name: "get-revenue",
        arguments: { quarter: data.quarter },
      });
      onData(result.structuredContent as unknown as RevenuePayload);
    } catch (e) {
      console.error("Refresh failed", e);
    } finally {
      setBusy(false);
    }
  }
 
  const rows = selected ? data.rows.filter((r) => r.region === selected) : data.rows;
  const max = Math.max(...data.rows.map((r) => r.revenue));
 
  return (
    <main style={{ fontFamily: "var(--font-sans)", color: "var(--color-text-primary)" }}>
      <header>
        <h2>{data.quarter}</h2>
        <button onClick={refresh} disabled={busy}>
          {busy ? "Refreshing…" : "Refresh"}
        </button>
      </header>
 
      {rows.map((row) => (
        <div key={row.region} onClick={() => setSelected(row.region)}>
          <span>{row.region}</span>
          <div style={{ width: `${(row.revenue / max) * 100}%` }} role="presentation" />
          <span>{row.revenue.toLocaleString()} TND</span>
        </div>
      ))}
 
      {selected && <button onClick={() => setSelected(null)}>Clear filter</button>}
    </main>
  );
}

Filtering by region costs zero tokens and zero latency, because it never leaves the iframe. That is the whole economic argument for MCP Apps: interactions that are presentational should not be paid for in inference.

The App class exposes more than tool calls. The ones you will reach for most:

MethodWhat it does
callServerTool()Invoke a tool on your MCP server through the host
readServerResource()Read any server resource — useful for binary blobs
sendMessage()Insert a message into the conversation as if the user typed it
updateModelContext()Tell the model what the view is showing, without a chat turn
openLink()Ask the host to open an external URL
sendLog()Emit a log line the host can surface to the developer
requestDisplayMode()Request inline, fullscreen, or pip

Every one of these can be refused. The host is the trust boundary, and openLink and sendMessage return a result carrying isError when denied rather than throwing. Check it and degrade gracefully — showing the raw URL for manual copying beats a dead button.

Step 6: App-only tools

Now the pattern that most people miss on a first read. Your refresh button calls get-revenue, a tool the model can also call. Often you want the opposite: an action that only the UI should ever trigger — persisting a filter, acknowledging an alert, paging through a large result — with no reason to sit in the model's tool list burning schema tokens and inviting misuse.

Set visibility: ["app"]:

registerAppTool(
  server,
  "save-dashboard-filter",
  {
    description: "Persist the user's selected region filter.",
    inputSchema: { viewId: z.string(), region: z.string().nullable() },
    _meta: {
      ui: {
        resourceUri: RESOURCE_URI,
        visibility: ["app"], // [!code highlight]
      },
    },
  },
  async ({ viewId, region }) => {
    await saveFilter(viewId, region);
    return { content: [{ type: "text", text: "ok" }] };
  },
);

The host must now exclude this tool from tools/list as the agent sees it, and must reject any tools/call for it that does not originate from an app on the same server connection. Cross-server calls to app-only tools are always blocked. Visibility defaults to ["model", "app"] when you omit it, so an ordinary tool is callable from both sides.

Step 7: Adopt the host's theme

A view that renders as a white rectangle inside a dark Claude conversation looks broken, no matter how good the chart is. During ui/initialize the host returns a hostContext containing a styles.variables map of CSS custom properties, a theme of light or dark, optional @font-face CSS, the user's locale and timeZone, the platform, and safeAreaInsets for mobile.

useHostStyleVariables — wired up in Step 4 — writes those variables onto document.documentElement and sets color-scheme so the CSS light-dark() function resolves correctly. After that, style against the variables:

:root {
  color-scheme: light dark;
}
 
main {
  background: var(--color-background-primary, #fff);
  color: var(--color-text-primary, #171717);
  font-family: var(--font-sans, system-ui, sans-serif);
}

Always supply a fallback in var(). Hosts are not required to send every variable, and a missing one with no fallback renders as transparent text on a transparent background.

For Arabic-language hosts, read hostContext.locale and set direction explicitly — the host will not do it for you:

const locale = app.getHostContext()?.locale ?? "en";
const dir = locale.startsWith("ar") ? "rtl" : "ltr";
// then: <main dir={dir}>

Host context is not static. Register app.onhostcontextchanged to react when the user toggles dark mode or resizes the window mid-session.

Step 8: Declare your CSP

Here is where a working local build meets a blank panel in production. The host sandboxes your view and, if you declare nothing, applies a deliberately hostile default:

default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self' data:;
connect-src 'none';

Read connect-src 'none' carefully: your view cannot make any network request at all by default. No fetch to your API, no CDN chart library, no remote image. That is intentional — a hostile server should not be able to exfiltrate a conversation through an image URL.

Anything external must be declared in the resource's _meta.ui:

registerAppResource(
  server,
  "revenue-dashboard",
  RESOURCE_URI,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [{
      uri: RESOURCE_URI,
      mimeType: RESOURCE_MIME_TYPE,
      text: html,
      _meta: {
        ui: {
          csp: {
            connectDomains: ["https://api.example.com"],
            resourceDomains: ["https://cdn.jsdelivr.net", "https://*.example-cdn.com"],
          },
          permissions: { clipboardWrite: {} },
          prefersBorder: true,
        },
      },
    }],
  }),
);

Three rules to internalize. Hosts may further restrict but must never loosen what you declare, so a domain you forgot is a domain you do not get. permissions — camera, microphone, geolocation, clipboard write — are requests, not grants; feature-detect at runtime and degrade. And prefersBorder should be set explicitly, because host defaults differ and an unspecified value means your card may or may not get a frame.

The path of least resistance is to have zero external dependencies: bundle everything through viteSingleFile, pass all data through structuredContent, and declare no domains at all. Your view then runs under the strictest possible policy and works in every host without negotiation.

Step 9: Keep the model in the loop

The user filters to Sfax, then types "why is that one down?" — and the model has no idea what "that one" is, because the filtering happened inside an iframe it cannot see.

updateModelContext fixes exactly this. It pushes a context update without creating a visible chat turn:

async function selectRegion(region: string) {
  setSelected(region);
 
  const row = data.rows.find((r) => r.region === region);
  const markdown = `---
selected-region: ${region}
revenue: ${row?.revenue ?? 0}
quarter: ${data.quarter}
---
 
The user is now viewing the ${region} region in the revenue dashboard.`;
 
  await app.updateModelContext({ content: [{ type: "text", text: markdown }] });
}

YAML frontmatter is the convention the spec's own examples use — it parses reliably and reads well in a context window. The same method is the right way to report a degraded view: if getUserMedia is denied or a chart library fails to load, tell the model "this capability is unavailable" so it stops suggesting the user click a button that will not work.

Use it deliberately, though. Every update consumes context. Fire it on meaningful state changes, not on every mouse move.

Step 10: Sizing, persistence, and display modes

Sizing. hostContext.containerDimensions tells you which axes you control. A height field means the host fixed it and your view should fill it with 100vh. A maxHeight field means you control the height up to that bound. Neither means unbounded. With flexible dimensions the SDK sends ui/notifications/size-changed automatically via a ResizeObserver, debounced — autoResize is on by default, so in practice this just works.

Persistence. For recoverable state — a scroll position, a selected region, a map camera — have the tool return a stable identifier and key localStorage on it:

// Server, in the tool callback
return {
  content: [{ type: "text", text: summary }],
  structuredContent: payload,
  _meta: { viewUUID: randomUUID() },
};
// View
app.ontoolresult = (result) => {
  const viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
  const saved = viewUUID ? localStorage.getItem(viewUUID) : null;
  if (saved) restore(JSON.parse(saved));
};

For state that represents real user effort — annotations, saved configurations, uploads — do not use localStorage. Persist it server-side through an app-only tool, scoped by that same view identifier.

Display modes. Declare what you support in appCapabilities.availableDisplayModes during initialize, then call app.requestDisplayMode() when the user hits your expand button. A dashboard should declare ["inline", "fullscreen"]; a video player might add pip. Only offer the control if hostContext.availableDisplayModes says the host can honor it.

Testing your implementation

Build and start the server:

pnpm build
pnpm dev
# MCP server listening on http://localhost:3001/mcp

The fastest feedback loop is the reference host in the ext-apps repository, which renders your view in a browser with full DevTools access:

git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps && npm install
cd examples/basic-host && npm start
# open http://localhost:8080

Pick get-revenue from the tool dropdown, click Call Tool, and your dashboard renders in the sandbox below. Because it is a real iframe in a real browser, console.log and breakpoints work normally.

To test in an actual assistant over stdio, point the client at your built entry point:

{
  "mcpServers": {
    "revenue": {
      "command": "node",
      "args": ["/absolute/path/to/revenue-app/dist/index.js", "--stdio"]
    }
  }
}

Then ask the assistant for revenue for a quarter and watch the tool fire.

Troubleshooting

The tool runs but no UI appears. The resourceUri in the tool's _meta does not match a registered resource, or the host does not support the Apps extension. Check exact string equality first — this is the most common failure and it is silent by design, since text-only fallback is the specified behavior.

The view renders blank. Your bundle is not single-file. Confirm dist/mcp-app.html contains inlined script contents rather than src attributes pointing at sibling files. External asset references resolve against the sandbox origin, not your server, and fail.

The view loads but never receives data. ontoolresult was assigned after connect(). Move it into onAppCreated.

Network requests fail with a CSP error. The origin is not in connectDomains. Remember that subdomains need an explicit wildcard: https://*.example.com.

Fonts and colors look wrong. You did not call useHostStyleVariables, or you styled with hardcoded values instead of var(--color-*).

Everything works locally, breaks in one specific host. Check whether that host requires a dedicated sandbox origin via _meta.ui.domain. Claude and ChatGPT use different formats — hash-based and URL-derived subdomains respectively — and this is explicitly host-dependent in the spec.

Security notes worth reading twice

MCP Apps was designed with a clear threat model, and building against it correctly means understanding what protects whom.

Your view runs in an iframe on a different origin from the host, wrapped by a sandbox proxy with allow-scripts and allow-same-origin only. It cannot read the conversation, touch the host's DOM, or reach the network beyond your declared origins. Everything it asks for goes through auditable JSON-RPC, and hosts may require explicit user approval before proxying a tool call. Templates being predeclared means a host can review every UI a server might render at connection time rather than at execution time.

What that does not protect against is a compromised or malicious server author — which is you. Your view runs with your server's privileges and can call your tools. Validate arguments on the server exactly as you would for a tool the model calls; a request arriving from your own UI is not more trustworthy than one from the model. And never inline secrets into the HTML bundle: the whole document is readable by anyone who can call resources/read.

Next steps

  • Add a real charting library, but check the bundle size first — the whole HTML document travels inside a JSON-RPC message on every render
  • Explore the official example servers: a CesiumJS globe, a Three.js scene, a live shader renderer, a cohort heatmap, a PDF viewer with chunked loading
  • Migrating an existing ChatGPT app? The Apps SDK's _meta["openai/outputTemplate"] maps onto _meta.ui.resourceUri, and the repository ships an agent skill that does the conversion
  • Pair this with MCP stateless servers if you deploy to serverless, since a view can outlive a single request
  • Wiring browser-side tools instead of server-side UI? WebMCP solves the mirror-image problem

Conclusion

MCP Apps takes the two primitives you already know — tools and resources — and adds exactly one metadata field to connect them. That restraint is why it landed as the protocol's first official extension instead of a competing standard: nothing about your existing server changes, and hosts without support fall back to text automatically.

The habits that matter are small and specific. Keep content short and put the payload in structuredContent. Register handlers in onAppCreated, never after connect. Declare every external origin, or better, declare none. Style with the host's variables. Tell the model what the user is doing through updateModelContext instead of hoping it guesses.

Get those right and your server stops narrating data and starts showing it.