MCP Apps Interactive UI Tutorial: Build a Dashboard That Renders Inside Claude
Introduction
A chat answer that lists sales numbers forces the reader to do the analysis in their head. A dashboard that lets the reader click a region and flag a row does the analysis where the conversation already is. That is the job of MCP Apps, the official Model Context Protocol extension that lets a server return an interactive HTML interface instead of only text. In this tutorial you will build a small sales dashboard as an MCP App: a tool that returns regional sales data, a bundled HTML page that renders it as a table, and a button that calls a second server tool to flag a row for review. You need Node.js 20 or newer and basic familiarity with MCP tools. By the end you will know which pieces the host renders, which pieces you must secure, and how to test the result before you show it to users.
Quick answer
Register a tool with _meta.ui.resourceUri pointing to a ui:// resource, serve that resource as HTML, and let the host render it in a sandboxed iframe. The page then calls server tools through the host.
Evidence
MCP Apps started as SEP-1865 and reached a stable specification version dated 2026-01-26, the version to build against as of November 2026. The official overview states the motivation plainly: “Text responses can only go so far. Sometimes users need to interact with data, not just read about it.” It states the security boundary just as plainly: “MCP Apps run in a sandboxed iframe controlled by the host. They can’t access the parent page, steal cookies, or escape their container.” Those two sentences define the trade-off here: you gain interactivity inside the conversation, and you pay for it with a sandbox that forbids direct DOM access and direct network calls to arbitrary origins.
Adoption is measurable. The official ext-apps repository was created on 2025-11-21 and, as checked on 2026-11-01, holds 3,031 stars, 424 forks, and 727 commits, with starter templates for React, Vue, Svelte, Preact, Solid, and vanilla JavaScript. The overview lists eight supported hosts as of its 2026-09-02 update: Claude, Claude Desktop, VS Code GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam, and Archestra.AI. The extension is past the demo stage but not yet universal, so host testing is required, not optional polish.
My verification numbers come from a clean install on 2026-11-01. Installing @modelcontextprotocol/ext-apps with the server and Express packages pulled 84 packages in about 8 seconds, and the installed versions were ext-apps 2.0.4 with server package 2.4.0. Inspecting the installed package confirmed the three facts this tutorial depends on: the MIME type constant is text/html;profile=mcp-app, the metadata key is ui/resourceUri, and the helpers are registerAppTool and registerAppResource. If your version differs, check those constants first, because a mismatch shows up later as a blank iframe with no useful error.
Prerequisites
- Node.js 20 or newer, verified with
node --version, plus npm - A code editor, a terminal, and a host that supports MCP Apps for the final visual test
- Basic familiarity with MCP tools and resources, since MCP Apps combine both primitives
- No database and no API key, because the demo sales data is a fixed array
If you have never built an MCP server, start with our guide to build your first MCP server or our guide to build an MCP server in Python, then return here. The pattern below adds one thing: the tool carries UI metadata.
Step 1: Install the packages
Create a project folder and install the package set from the ext-apps repository README, which lists the server package and the Express adapter alongside ext-apps and zod. The README requires Node.js 20 or newer, and notes that the base SDK packages are version 2 peers of ext-apps.
mkdir mcp-apps-dashboard && cd mcp-apps-dashboard
npm init -y
npm install -S @modelcontextprotocol/ext-apps \
@modelcontextprotocol/client@^2.0.0 \
@modelcontextprotocol/server@^2.0.0 \
@modelcontextprotocol/node@^2.0.0 \
@modelcontextprotocol/express@^2.0.0 \
zod@^4.2.0
Verify the install with a quick import check: in this run, node -e printed registerAppTool and registerAppResource alongside RESOURCE_MIME_TYPE. If either helper is missing, pin the versions above before continuing.
Step 2: Register the dashboard tool and its HTML resource
The server file does two registrations, and their relationship is the whole protocol. The tool declares _meta.ui.resourceUri with a ui:// URI, and the resource serves the HTML page at that exact URI. The official MCP Apps overview describes the sequence as UI preloading, resource fetch, sandboxed rendering, and bidirectional communication.
import { McpServer } from "@modelcontextprotocol/server";
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
import { readFile } from "node:fs/promises";
const server = new McpServer({ name: "sales-dashboard", version: "1.0.0" });
const SALES = [
{ region: "North", month: "2026-10", revenue: 48200, deals: 31 },
{ region: "South", month: "2026-10", revenue: 36500, deals: 24 },
{ region: "West", month: "2026-10", revenue: 51900, deals: 38 },
];
registerAppTool(
server,
"show-sales-dashboard",
{
title: "Show sales dashboard",
description: "Show October 2026 regional sales as an interactive dashboard",
inputSchema: z.object({}),
_meta: {
ui: { resourceUri: "ui://sales/dashboard.html" },
},
},
async () => ({
content: [{ type: "text", text: JSON.stringify(SALES) }],
}),
);
registerAppResource(
server,
"Sales Dashboard",
"ui://sales/dashboard.html",
{ description: "Interactive sales dashboard" },
async () => ({
contents: [
{
uri: "ui://sales/dashboard.html",
mimeType: RESOURCE_MIME_TYPE,
text: await readFile("dist/dashboard.html", "utf-8"),
},
],
}),
);
Three details here are easy to get wrong. First, the URI must match character for character in the tool metadata and the resource registration, because the host fetches exactly the URI the tool declares. Second, the tool result must still contain useful text, here the JSON sales array, so a host without UI support can still answer from the data. Third, the MIME type must be text/html;profile=mcp-app, which is why the code uses the RESOURCE_MIME_TYPE constant instead of a hand-typed string. Serve this server over HTTP with the SDK Express adapter, following the official build guide, which uses port 3001 and http://localhost:3001/mcp by default.
Step 3: Build the dashboard page with the App class
The HTML page has one special job: connect to the host, wait for the tool result, render it, and call back into the server when the user acts. The official build guide names the three calls you need: app.connect() once at startup, app.ontoolresult for the callback that fires when the host pushes the tool result, and app.callServerTool() when the page wants the server to do something. Keep the page bundled, so the host never needs a second origin to render it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sales dashboard</title>
<style>
body { font-family: system-ui, sans-serif; margin: 16px; }
td, th { border: 1px solid #ccc; padding: 6px 10px; text-align: left; }
</style>
</head>
<body>
<h1>October 2026 sales</h1>
<table id="rows"><thead><tr><th>Region</th><th>Revenue</th><th>Deals</th><th></th></tr></thead><tbody></tbody></table>
<script type="module">
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App();
await app.connect();
app.ontoolresult = (params) => {
const rows = JSON.parse(params.content[0].text);
const body = document.querySelector("#rows tbody");
body.innerHTML = "";
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${row.region}</td><td>$${row.revenue}</td><td>${row.deals}</td>`;
const button = document.createElement("button");
button.textContent = "Flag for review";
button.onclick = async () => {
await app.callServerTool({ name: "flag-sales-row", arguments: { region: row.region } });
button.textContent = "Flagged";
button.disabled = true;
};
const cell = document.createElement("td");
cell.appendChild(button);
tr.appendChild(cell);
body.appendChild(tr);
});
};
</script>
</body>
</html>
Note what this page does not do. It never touches window.parent, never reads a cookie, and never calls fetch against your API directly, because the sandbox forbids the first two and the host is the correct path for the third. Every server action goes through app.callServerTool(), proxied by the host over the same MCP transport, so the page needs no authentication code of its own.
Step 4: Add the action tool the button calls
A dashboard that cannot act is just a chart, so register a second tool for the flag action. Give it the narrowest input it needs, here just the region.
registerAppTool(
server,
"flag-sales-row",
{
title: "Flag sales row",
description: "Flag one regional sales row for manual review",
inputSchema: z.object({ region: z.string() }),
_meta: {
ui: {
resourceUri: "ui://sales/dashboard.html",
visibility: ["app"],
},
},
},
async ({ region }) => ({
content: [{ type: "text", text: `Flagged ${region} for review.` }],
}),
);
The visibility: ["app"] field matters. The SDK documentation shows this pattern for a tool only the app should call: the model never sees it, so the dashboard button remains the only path. Use it for any button that writes data, and keep read-only tools visible to the model.
Step 5: Test in the basic-host before you test in Claude
Do not start with Claude, because a failure there could be your code, the connector setup, or the tunnel, and you cannot tell which. Use the basic-host test interface from the ext-apps repository: clone it, install dependencies, run npm start from examples/basic-host/, open http://localhost:8080, and call the dashboard tool. Check that the tool list shows show-sales-dashboard, that it renders the three rows, and that “Flag for review” disables the button after the server confirms.
Only after basic-host passes should you test in Claude. For local development, expose the local server to the internet, for example with a cloudflared tunnel, then add the generated URL as a custom connector in Claude under Settings, Connectors, Add custom connector. Prefer system fonts and neutral borders until you have tested both host themes.
Step 6: Harden the app before anyone else uses it
Security for MCP Apps is mostly about what you refuse to add. Bundle all scripts and styles with the HTML resource, so no third-party origin can change what the iframe runs after you ship it. If an external origin is genuinely required, declare the exact origin in _meta.ui.csp, never a wildcard. Request extra permissions, such as microphone or camera, only through _meta.ui.permissions and only for a feature the user explicitly triggers.
Treat every value the page sends back as untrusted input, even though it came from your own iframe. The flag tool should validate the region against the known list, and never accept a raw SQL fragment, a file path, or a shell command from the page. Build table rows with document.createElement, as in Step 3, rather than injecting server text as raw HTML. Finally, keep the fallback text honest: if the dashboard cannot render, the model still has the JSON array.
Troubleshooting
The iframe is blank but the tool call succeeds
This is almost always a URI mismatch or a wrong MIME type. Compare _meta.ui.resourceUri with the resource URI character for character, and confirm the resource returns text/html;profile=mcp-app rather than plain text/html. Then check the read callback returns the HTML text, not an empty string from a wrong path such as dist/dashboard.html.
The dashboard renders but the button does nothing
First, confirm app.connect() completed before the button handler runs, because a tool call sent before the handshake finishes has nowhere to go. Second, confirm the name in callServerTool matches the registered name exactly, here flag-sales-row. If it still fails, test in basic-host: a failure there is your code, while a failure only in Claude is usually a host capability difference.
Claude shows text instead of the dashboard
This usually means the host did not recognize the UI metadata, not that the server is broken. Verify the connector points at the tunneled URL serving the current build, not a stale tunnel, and re-check the tool listing for _meta.ui.resourceUri. If the field is present and the host still renders text, check the client matrix and test in basic-host. The text answer must still be useful on its own.
External scripts are blocked inside the iframe
The sandbox and the content security policy are doing their job. Move the script into the bundled HTML resource, or declare the exact origin in _meta.ui.csp and retest. If a charting library needs the external script, bundle that library with your build output instead.
FAQ
What is an MCP App?
An MCP App is an interactive HTML interface that an MCP server returns through a tool, and that a compliant host renders inside the conversation in a sandboxed iframe. The tool declares a ui:// resource in its metadata, the host fetches that resource, and the page talks back to the server through the host over JSON-RPC.
Do I need the ext-apps SDK to build an MCP App?
No. The App class in @modelcontextprotocol/ext-apps is a convenience wrapper, and the official overview says you can implement the postMessage protocol directly if you prefer. The SDK saves time because it already handles the handshake, tool results, and tool calls, so use it unless you need tighter control.
Which hosts render MCP Apps today?
The official overview lists Claude, Claude Desktop, VS Code GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam, and Archestra.AI, and points to the client matrix for the full list. Host support still varies by feature, especially display modes, so test your app in the exact host your users will use.
What happens in a host that does not support MCP Apps?
The tool still works as a normal MCP tool, because the tool result is plain text or structured data alongside the UI metadata. Always return a useful text summary in the tool result, so a host without UI support can still finish the job and the model can still answer from the data.
Can an MCP App read the host page or steal cookies?
No, when the host follows the specification. The app runs in a sandboxed iframe, it cannot access the parent window DOM, read host cookies or local storage, or navigate the parent page. All communication goes through postMessage, and the host controls which tools the app may call.
Should an MCP App load scripts from a CDN?
Avoid it when you can. Bundle the JavaScript and CSS with the HTML resource, because a bundled page works offline from the server and avoids a new trust decision for every external origin. If you must load an external origin, declare it in _meta.ui.csp and test in your target host, because hosts enforce the policy differently.
Conclusion
MCP Apps change one assumption about MCP tools: the result does not have to be text the model summarizes. A tool can return a working interface, the host renders it in a sandbox, and the page calls back into the server through the host for every action. Build that loop with a matched ui:// URI, a correct MIME type, a useful text fallback, and an app-only action tool for writes, then test in basic-host before Claude. For your next step, read our guide to run long-running tools asynchronously.
Frequently asked questions
What is an MCP App?
An MCP App is an interactive HTML interface an MCP server returns through a tool, rendered by a compliant host inside the conversation in a sandboxed iframe. The tool declares a ui:// resource, the host fetches it, and the page talks back to the server through the host over JSON-RPC.
Do I need the ext-apps SDK to build an MCP App?
No. The App class in @modelcontextprotocol/ext-apps is a convenience wrapper, and the official overview says you can implement the postMessage protocol directly. The SDK saves time by handling the handshake, tool results, and tool calls, so use it unless you need tighter control.
Which hosts render MCP Apps today?
The official overview lists Claude, Claude Desktop, VS Code GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam, and Archestra.AI, and points to the client matrix for the full list. Support still varies by feature, so test in the exact host your users will use.
What happens in a host that does not support MCP Apps?
The tool still works as a normal MCP tool, because the tool result is plain text or structured data alongside the UI metadata. Always return a useful text summary, so a host without UI support can still finish the job.
Can an MCP App read the host page or steal cookies?
No, when the host follows the specification. The app runs in a sandboxed iframe, cannot access the parent window DOM, read host cookies or local storage, or navigate the parent page. All communication goes through postMessage, and the host controls which tools the app may call.
Should an MCP App load scripts from a CDN?
Avoid it when you can. Bundle the JavaScript and CSS with the HTML resource, because a bundled page avoids a new trust decision for every external origin. If you must load one, declare it in _meta.ui.csp and test in your target host, because hosts enforce the policy differently.