
Build Mcp App
- 4.1k installs
- 32.9k repo stars
- Updated July 31, 2026
- anthropics/claude-plugins-official
An MCP app is a standard MCP server that also serves interactive HTML resources (widgets) rendered in the chat iframe. Widgets attach to tools via _meta.ui.resourceUri, receive tool results via ontoolresult, and send use
About
Extend MCP servers with interactive UI resources rendered in the chat iframe sandbox. Register widget-enabled tools alongside HTML resources served via the apps SDK. Use when plain text returns insufficient - structure complex input via forms, visualize spatial data with charts/maps, or add confirmation dialogs for destructive actions. Widgets degrade gracefully in non-supporting hosts; the tool's underlying JSON response persists. Deploy remote (HTTP server) or local (MCPB bundle). The App class provides two-way messaging between widget and host, resource fetching, and safe navigation via callServerTool and openLink.
- Additive UI layer on standard MCP tools—widgets optional, data always returned as JSON
- Iframe sandbox + CSP isolation—bundle ext-apps dependency inline, no external script fetches
- App class bidirectional messaging—sendMessage injects user actions, ontoolresult pipes tool data into widget
- Resource mime-type ui:// scheme—host auto-detects and renders interactive iframes vs. plain text
- Degradation by design—hosts without apps surface show tool text; no widget code breaks fallback
Build Mcp App by the numbers
- 4,115 all-time installs (skills.sh)
- +366 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #179 of 16,565 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
build-mcp-app capabilities & compatibility
- Capabilities
- register widget enabled tools with _meta.ui.reso · serve html resources with mcp app mime type · inline ext apps bundle and rewrite export shim · implement ontoolresult and sendmessage in widget · call server tools from widget via app.callserver · handle theme, host context, and safe area insets · deploy remote (http) or local (mcpb) widget serv · degrade gracefully in non supporting hosts
- Works with
- anthropic
- Use cases
- code review · testing · web design · ui design · project management
npx skills add https://github.com/anthropics/claude-plugins-official --skill build-mcp-appAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4.1k |
|---|---|
| repo stars | ★ 32.9k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 31, 2026 |
| Repository | anthropics/claude-plugins-official ↗ |
What it does
Build interactive UI widgets (pickers, forms, dashboards) for MCP servers that render inline in Claude and ChatGPT.
Who is it for?
Contact/record pickers, visual dashboards, spatial previews (maps, diffs, charts), destructive-action confirmations, live progress tracking, form input when Claude can't reliably infer structure.
Skip if: Simple yes/no or short enum selection (use elicitation), text-only tool results, tools that don't benefit from visual or interactive presentation, single-prompt one-shot interactions.
When should I use this skill?
User wants to build interactive UI for an MCP server, mentions widgets/apps/forms/dashboards for Claude, needs visual feedback or structured input beyond text, or wants to render components inline in chat.
What you get
Developers can build one tool + one focused widget that render inline in supported hosts and degrade to text in others. Widgets persist across the conversation, receive new tool results, and push user actions back into t
- Tool registration with _meta.ui.resourceUri
- Resource handler serving mcp-app mime type HTML
- Inlined ext-apps bundle replacement in widget HTML
By the numbers
- Widgets persist across conversation—no reload on new tool result
- Max-height 300px recommended for scrollable lists in mobile-responsive design
- Resource fetch cached aggressively in Claude Desktop—requires full restart to refresh
Files
Build an MCP App (Interactive UI Widgets)
An MCP app is a standard MCP server that also serves UI resources — interactive components rendered inline in the chat surface. Build once, runs in Claude and ChatGPT and any other host that implements the apps surface.
The UI layer is additive. Under the hood it's still tools, resources, and the same wire protocol. If you haven't built a plain MCP server before, the build-mcp-server skill covers the base layer. This skill adds widgets on top.
Testing in Claude: Add the server as a custom connector in claude.ai (via a Cloudflare tunnel for local dev) — this exercises the real iframe sandbox and hostContext. See https://claude.com/docs/connectors/building/testing.Claude host specifics
_meta.ui.* key | Where | Effect |
|---|---|---|
resourceUri | tool | Which ui:// resource the host renders for this tool's results. |
visibility: ["app"] | tool | Hide a widget-only helper tool (e.g. geometry/image fetcher called via callServerTool) from Claude's tool list. |
prefersBorder: false | resource | Drop the host's outer card border (mobile). |
csp.{connectDomains, resourceDomains, baseUriDomains} | resource | Declare external origins; default is block-all. frameDomains is currently restricted in Claude. |
hostContext.safeAreaInsets: {top, right, bottom, left}(px) — honor these for notches and the composer overlay.- Directory submission requires OAuth or authless (
none) — static bearer is private-deploy only and blocks listing — plus toolannotationsand 3–5 PNG screenshots; seereferences/directory-checklist.md.
---
When a widget beats plain text
Don't add UI for its own sake — most tools are fine returning text or JSON. Add a widget when one of these is true:
| Signal | Widget type |
|---|---|
| Tool needs structured input Claude can't reliably infer | Form |
| User must pick from a list Claude can't rank (files, contacts, records) | Picker / table |
| Destructive or billable action needs explicit confirmation | Confirm dialog |
| Output is spatial or visual (charts, maps, diffs, previews) | Display widget |
| Long-running job the user wants to watch | Progress / live status |
If none apply, skip the widget. Text is faster to build and faster for the user.
---
Widgets vs Elicitation — route correctly
Before building a widget, check if elicitation covers it. Elicitation is spec-native, zero UI code, works in any compliant host.
| Need | Elicitation | Widget |
|---|---|---|
| Confirm yes/no | ✅ | overkill |
| Pick from short enum | ✅ | overkill |
| Fill a flat form (name, email, date) | ✅ | overkill |
| Pick from a large/searchable list | ❌ (no scroll/search) | ✅ |
| Visual preview before choosing | ❌ | ✅ |
| Chart / map / diff view | ❌ | ✅ |
| Live-updating progress | ❌ | ✅ |
If elicitation covers it, use it. See ../build-mcp-server/references/elicitation.md.
---
Architecture: two deployment shapes
Remote MCP app (most common)
Hosted streamable-HTTP server. Widget templates are served as resources; tool results reference them. The host fetches the resource, renders it in an iframe sandbox, and brokers messages between the widget and Claude.
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘MCPB-packaged MCP app (local + UI)
Same widget mechanism, but the server runs locally inside an MCPB bundle. Use this when the widget needs to drive a local application — e.g., a file picker that browses the actual local disk, a dialog that controls a desktop app.
For MCPB packaging mechanics, defer to the `build-mcpb` skill. Everything below applies to both shapes.
---
How widgets attach to tools
A widget-enabled tool has two separate registrations:
1. The tool declares a UI resource via _meta.ui.resourceUri. Its handler returns plain text/JSON — NOT the HTML. 2. The resource is registered separately and serves the HTML.
When Claude calls the tool, the host sees _meta.ui.resourceUri, fetches that resource, renders it in an iframe, and pipes the tool's return value into the iframe via the ontoolresult event.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const server = new McpServer({ name: "contacts", version: "1.0.0" });
// 1. The tool — returns DATA, declares which UI to show
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker",
annotations: { title: "Pick Contact", readOnlyHint: true },
inputSchema: { filter: z.string().optional() },
_meta: { ui: { resourceUri: "ui://widgets/contact-picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter);
// Plain JSON — the widget receives this via ontoolresult
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
// 2. The resource — serves the HTML
registerAppResource(
server,
"Contact Picker",
"ui://widgets/contact-picker.html",
{},
async () => ({
contents: [{
uri: "ui://widgets/contact-picker.html",
mimeType: RESOURCE_MIME_TYPE,
text: pickerHtml, // your HTML string
}],
}),
);The URI scheme ui:// is convention. The mime type MUST be RESOURCE_MIME_TYPE ("text/html;profile=mcp-app") — this is how the host knows to render it as an interactive iframe, not just display the source.
---
Widget runtime — the App class
Inside the iframe, your script talks to the host via the App class from @modelcontextprotocol/ext-apps. This is a persistent bidirectional connection — the widget stays alive as long as the conversation is active, receiving new tool results and sending user actions.
<script type="module">
/* ext-apps bundle inlined at build time → globalThis.ExtApps */
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
// Set handlers BEFORE connecting
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
render(contacts);
};
await app.connect();
// Later, when the user clicks something:
function onPick(contact) {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${contact.id}` }],
});
}
</script>The /*__EXT_APPS_BUNDLE__*/ placeholder gets replaced by the server at startup with the contents of @modelcontextprotocol/ext-apps/app-with-deps — see references/iframe-sandbox.md for why this is necessary and the rewrite snippet. Do not import { App } from "https://esm.sh/..."; the iframe's CSP blocks the transitive dependency fetches and the widget renders blank.
| Method | Direction | Use for |
|---|---|---|
app.ontoolresult = fn | Host → widget | Receive the tool's return value |
app.ontoolinput = fn | Host → widget | Receive the tool's input args (what Claude passed) |
app.sendMessage({...}) | Widget → host | Inject a message into the conversation |
app.updateModelContext({...}) | Widget → host | Update context silently (no visible message) |
app.callServerTool({name, arguments}) | Widget → server | Call another tool on your server |
app.openLink({url}) | Widget → host | Open a URL in a new tab (sandbox blocks window.open) |
app.getHostContext() / app.onhostcontextchanged | Host → widget | Theme, host CSS vars, containerDimensions, displayMode, deviceCapabilities |
app.requestDisplayMode({mode}) | Widget → host | Ask for inline / pip / fullscreen |
app.downloadFile({name, mimeType, content}) | Widget → host | Host-mediated download (base64 content) |
new App(info, caps, {autoResize: true}) | — | Iframe height tracks rendered content |
sendMessage is the typical "user picked something, tell Claude" path. updateModelContext is for state that Claude should know about but shouldn't clutter the chat. openLink is required for any outbound navigation — window.open and <a target="_blank"> are blocked by the sandbox attribute.
What widgets cannot do:
- Access the host page's DOM, cookies, or storage
- Make network calls to arbitrary origins (CSP-restricted — route through
callServerTool) - Open popups or navigate directly — use
app.openLink({url}) - Load remote images reliably — inline as
data:URLs server-side
Keep widgets small and single-purpose. A picker picks. A chart displays. Don't build a whole sub-app inside the iframe — split it into multiple tools with focused widgets.
---
Scaffold: minimal picker widget
Install:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod expressServer (`src/server.ts`):
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 express from "express";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { z } from "zod";
const require = createRequire(import.meta.url);
const server = new McpServer({ name: "contact-picker", version: "1.0.0" });
// Inline the ext-apps browser bundle into the widget HTML.
// The iframe CSP blocks CDN script fetches — bundling is mandatory.
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker. User selects one contact.",
annotations: { title: "Pick Contact", readOnlyHint: true },
inputSchema: { filter: z.string().optional().describe("Name/email prefix filter") },
_meta: { ui: { resourceUri: "ui://widgets/picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter ?? "");
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
registerAppResource(server, "Contact Picker", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(process.env.PORT ?? 3000);For local-only widget apps (driving a desktop app, reading local files), swap the transport to StdioServerTransport and package via the build-mcpb skill.
Widget (`widgets/picker.html`):
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const c of contacts) {
const li = document.createElement("li");
li.innerHTML = `<div>${c.name}</div><div class="sub">${c.email}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>See references/widget-templates.md for more widget shapes.
---
Design notes that save you a rewrite
One widget per tool. Resist the urge to build one mega-widget that does everything. One tool → one focused widget → one clear result shape. Claude reasons about these far better.
Tool description must mention the widget. Claude only sees the tool description when deciding what to call. "Opens an interactive picker" in the description is what makes Claude reach for it instead of guessing an ID.
Widgets are optional at runtime. Hosts that don't support the apps surface simply ignore _meta.ui and render the tool's text content normally. Since your tool handler already returns meaningful text/JSON (the widget's data), degradation is automatic — Claude sees the data directly instead of via the widget.
Don't block on widget results for read-only tools. A widget that just displays data (chart, preview) shouldn't require a user action to complete. Return the display widget and a text summary in the same result so Claude can continue reasoning without waiting.
Layout-fork by item count, not by tool count. If one use case is "show one result in detail" and another is "show many results side-by-side", don't make two tools — make one tool that accepts items[], and let the widget pick a layout: items.length === 1 → detail view, > 1 → carousel. Keeps the server schema simple and lets Claude decide count naturally.
Put Claude's reasoning in the payload. A short note field on each item (why Claude picked it) rendered as a callout on the card gives users the reasoning inline with the choice. Mention this field in the tool description so Claude populates it.
Normalize image shapes server-side. If your data source returns images with wildly varying aspect ratios, rewrite to a predictable variant (e.g. square-bounded) before fetching for the data-URL inline. Then give the widget's image container a fixed aspect-ratio + object-fit: contain so everything sits centered.
Follow host theme. app.getHostContext()?.theme (after connect()) plus app.onhostcontextchanged for live updates. Toggle a .dark class on <html>, keep colors in CSS custom props with a :root.dark {} override block, set color-scheme. Disable mix-blend-mode: multiply in dark — it makes images vanish.
---
Testing
Claude Desktop — current builds still require the command/args config shape (no native "type": "http"). Wrap with mcp-remote and force http-only transport so the SSE probe doesn't swallow widget-capability negotiation:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}Desktop caches UI resources aggressively. After editing widget HTML, fully quit (⌘Q / Alt+F4, not window-close) and relaunch to force a cold resource re-fetch.
Headless JSON-RPC loop — fast iteration without clicking through Desktop:
# test.jsonl — one JSON-RPC message per line
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"your_tool","arguments":{...}}}
(cat test.jsonl; sleep 10) | npx mcp-remote http://localhost:3000/mcp --allow-httpThe sleep keeps stdin open long enough to collect all responses. Parse the jsonl output with jq or a Python one-liner.
Widget dev loop — avoid the ⌘Q-relaunch cycle entirely by serving the inlined widget HTML at a plain GET route with a fake ExtApps shim that fires ontoolresult from a query param:
app.get("/widget-preview", (_req, res) => {
const shim = `globalThis.ExtApps={applyHostStyleVariables:()=>{},App:class{
constructor(){this.h={}} ontoolresult;onhostcontextchanged;
async connect(){const p=new URLSearchParams(location.search).get("payload");
if(p)this.ontoolresult?.({content:[{type:"text",text:p}]});}
getHostContext(){return{theme:"light"}}
sendMessage(m){console.log("sendMessage",m)} updateModelContext(){}
callServerTool(){return Promise.resolve({content:[]})} openLink(){} downloadFile(){}
}};`;
res.type("html").send(widgetHtml.replace("/*__EXT_APPS_BUNDLE__*/", shim));
});Open http://localhost:3000/widget-preview?payload={"rows":[...]} in a normal browser tab and iterate with ordinary devtools.
Host fallback — use a host without the apps surface (or MCP Inspector) and confirm the tool's text content degrades gracefully.
CSP debugging — open the iframe's own devtools console. CSP violations are the #1 reason widgets silently fail (blank rectangle, no error in the main console). See references/iframe-sandbox.md.
---
Reference files
references/iframe-sandbox.md— CSP/sandbox constraints, the bundle-inlining pattern, image handling, host themingreferences/widget-templates.md— reusable HTML scaffolds for picker / confirm / progress / displayreferences/apps-sdk-messages.md— theAppclass API: widget ↔ host ↔ server messaging, lifecycle & supersessionreferences/payload-budgeting.md— host tool-result size caps, prune-then-truncate, heavy assets viacallServerToolreferences/abuse-protection.md— Anthropic egress CIDRs, tiered rate limiting,trust proxy, response cachingreferences/directory-checklist.md— pre-flight for connector-directory submission
Abuse protection for authless hosted servers
An authless StreamableHTTP server is reachable by anything on the internet. There are three resources to protect: your compute, any upstream API quota your tools consume, and egress bandwidth for large callServerTool payloads.
You don't get a per-user identity
In authless mode there is no token and stateless transport gives no session ID. Traffic from claude.ai is proxied through Anthropic's egress — every web user arrives from the same small set of IPs:
160.79.104.0/21
2607:6bc0::/48(See https://platform.claude.com/docs/en/api/ip-addresses.)
Claude Desktop, Claude Code, and other hosts connect directly from the user's machine, so those do have distinct per-user IPs. Per-IP limiting therefore works for direct-connect clients; for claude.ai you can only limit the aggregate Anthropic pool. If true per-user limits matter, that's the trigger to add OAuth.
Tiered token-bucket (per-replica backstop)
const ANTHROPIC_CIDRS = ["160.79.104.0/21", "2607:6bc0::/48"];
const TIERS = {
anthropic: { capacity: 600, refillPerSec: 100 }, // shared pool
other: { capacity: 30, refillPerSec: 2 }, // per-IP
};Match req.ip against the CIDRs, pick a bucket ("anthropic" or "ip:<addr>"), 429 + Retry-After on exhaust. This is a per-replica backstop — cross-replica enforcement belongs at the edge (Cloudflare, Cloud Armor), which keeps the containers stateless.
trust proxy must match your topology
req.ip only honours X-Forwarded-For if app.set('trust proxy', N) is set. true trusts every hop, which lets a direct client send X-Forwarded-For: 160.79.108.42 and claim the Anthropic tier. Set it to the exact number of trusted hops (e.g. 1 behind a single LB, 2 behind Cloudflare → origin LB) and never `true` in production.
Hard-allowlisting Anthropic IPs is a product decision
Blocking everything outside 160.79.104.0/21 locks out Desktop, Claude Code, and every other MCP host. Use the CIDRs to tier rate limits, not to gate access, unless claude.ai-only is an explicit goal.
Cache upstream responses
For tools that wrap a third-party API, an in-process LRU keyed on the normalized query (TTL hours, no secrets in the key) is the primary cost control — repeat queries become free and absorb thundering-herd. Rate limits are the safety net, not the first line.
ext-apps messaging — widget ↔ host ↔ server
The @modelcontextprotocol/ext-apps package provides the App class (browser side) and registerAppTool/registerAppResource helpers (server side). Messaging is bidirectional and persistent.
Construction
const app = new App(
{ name: "MyWidget", version: "1.0.0" },
{}, // capabilities
{ autoResize: true }, // options
);autoResize: true wires a ResizeObserver that emits ui/notifications/size-changed so the host iframe height tracks your rendered content. Without it the frame is fixed-height and tall renders get clipped — set it for any widget whose height depends on data.
---
Widget → Host
app.sendMessage({ role, content })
Inject a visible message into the conversation. This is how user actions become conversation turns.
app.sendMessage({
role: "user",
content: [{ type: "text", text: "User selected order #1234" }],
});The message appears in chat and Claude responds to it. Use role: "user" — the widget speaks on the user's behalf.
app.updateModelContext({ content })
Update Claude's context silently — no visible message. Use for state that informs but doesn't warrant a chat bubble.
app.updateModelContext({
content: [{ type: "text", text: "Currently viewing: orders from last 30 days" }],
});app.callServerTool({ name, arguments })
Call a tool on your MCP server directly, bypassing Claude. Returns the tool result.
const result = await app.callServerTool({
name: "fetch_order_details",
arguments: { orderId: "1234" },
});Use for data fetches that don't need Claude's reasoning — pagination, detail lookups, refreshes.
app.openLink({ url })
Open a URL in a new browser tab, host-mediated. Required for any outbound navigation — the iframe sandbox blocks window.open() and <a target="_blank">.
await app.openLink({ url: "https://example.com/cart" });For anchors in rendered HTML, intercept the click:
card.querySelector("a").addEventListener("click", (e) => {
e.preventDefault();
app.openLink({ url: e.currentTarget.href });
});app.downloadFile({ name, mimeType, content })
Host-mediated download (sandbox blocks direct <a download>). content is a base64 string.
const csv = rows.map((r) => Object.values(r).join(",")).join("\n");
app.downloadFile({
name: "export.csv",
mimeType: "text/csv",
content: btoa(unescape(encodeURIComponent(csv))),
});app.requestDisplayMode({ mode })
Ask the host to switch the widget between "inline", "pip", or "fullscreen". Check getHostContext().availableDisplayModes first; hide the control if the mode isn't offered. The host responds by firing onhostcontextchanged with new displayMode and containerDimensions — re-render at the new size.
if (app.getHostContext()?.availableDisplayModes?.includes("fullscreen")) {
expandBtn.hidden = false;
expandBtn.onclick = () => app.requestDisplayMode({ mode: "fullscreen" });
}---
Host → Widget
app.ontoolresult = ({ content }) => {...}
Fires when the tool handler's return value is piped to the widget. This is the primary data-in path.
app.ontoolresult = ({ content }) => {
const data = JSON.parse(content[0].text);
renderUI(data);
};Set this BEFORE `await app.connect()` — the result may arrive immediately after connection.
app.ontoolinput = ({ arguments }) => {...}
Fires with the arguments Claude passed to the tool. Useful if the widget needs to know what was asked for (e.g., highlight the search term).
app.ontoolinputpartial = ({ arguments }) => {...} / app.ontoolcancelled = () => {...}
ontoolinputpartial fires while Claude is still streaming arguments — use it to show a skeleton ("Preparing: <title>…") before the result lands. ontoolcancelled fires if the call is aborted; clear the skeleton.
app.getHostContext() / app.onhostcontextchanged = (ctx) => {...}
Read and subscribe to host context. Call getHostContext() after connect(). Subscribe for live updates (user toggles dark mode, expands to fullscreen).
ctx. field | Use |
|---|---|
theme | "light" / "dark" — toggle a .dark class |
styles.variables | Host CSS tokens — pass to applyHostStyleVariables() so colors/fonts match host chrome |
displayMode / availableDisplayModes | Current mode and which requestDisplayMode targets are valid |
containerDimensions.{maxHeight,width} | Size your render to this instead of hard-coded px |
deviceCapabilities.touch | Switch hover-only affordances to tap (pointerdown) |
safeAreaInsets | Padding for notches / composer overlay |
const applyTheme = (t) =>
document.documentElement.classList.toggle("dark", t === "dark");
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);
await app.connect();
applyTheme(app.getHostContext()?.theme);Keep colors in CSS custom props with a :root.dark {} override block and set color-scheme: light | dark so native form controls follow.
---
Server → Widget (progress)
For long-running operations, emit progress notifications. The client sends a progressToken in the request's _meta; the server emits against it.
// In the tool handler
async ({ query }, extra) => {
const token = extra._meta?.progressToken;
for (let i = 0; i < steps.length; i++) {
if (token !== undefined) {
await extra.sendNotification({
method: "notifications/progress",
params: { progressToken: token, progress: i, total: steps.length, message: steps[i].name },
});
}
await steps[i].run();
}
return { content: [{ type: "text", text: "Complete" }] };
}No { notify } destructure — extra is RequestHandlerExtra; progress goes through sendNotification.
---
Lifecycle
1. Claude calls a tool with _meta.ui.resourceUri declared 2. Host fetches the resource (your HTML) and mounts a fresh iframe for this call 3. Widget script runs, sets handlers, calls await app.connect() 4. Host pipes the tool's return value → ontoolresult fires 5. Widget renders, user interacts 6. Widget calls sendMessage / updateModelContext / callServerTool as needed 7. Iframe persists in the transcript; the next call to the same tool mounts another iframe alongside it
There's no explicit "submit and close" — each instance is long-lived, but instances are not reused across calls.
Supersession
Because earlier instances stay mounted, a click on a stale widget can sendMessage after a newer one has rendered. Detect this with a BroadcastChannel and make older instances inert:
let superseded = false;
const seq = Date.now() + Math.random();
const bc = new BroadcastChannel("my-widget");
bc.onmessage = (e) => {
if (e.data?.seq > seq) {
superseded = true;
document.body.classList.add("superseded"); // opacity:.45; pointer-events:none
}
};
bc.postMessage({ seq });
// Guard outbound calls:
function safeSend(msg) {
if (!superseded) app.sendMessage(msg);
}---
Sandbox & CSP gotchas
The iframe runs under both an HTML sandbox attribute and a restrictive Content-Security-Policy. The practical effect is that almost nothing external is allowed — widgets should be self-contained.
| Symptom | Cause | Fix |
|---|---|---|
| Widget is a blank rectangle, nothing renders | CDN import of ext-apps blocked (transitive SDK fetches) | Inline the ext-apps/app-with-deps bundle — see iframe-sandbox.md |
| Widget renders but JS doesn't run | Inline event handlers blocked | Use addEventListener — never onclick="..." in HTML |
eval / new Function errors | Script-src restriction | Don't use them; use JSON.parse for data |
fetch() to your API fails | Cross-origin blocked | Route through app.callServerTool() instead |
| External CSS doesn't load | style-src restriction | Inline styles in a <style> tag |
| Fonts don't load | font-src restriction | Use system fonts (font: 14px system-ui) |
External <img src> broken | CSP img-src + referrer hotlink blocking | Fetch server-side, inline as data: URL in the tool result payload |
window.open() does nothing | Sandbox lacks allow-popups | Use app.openLink({url}) |
<a target="_blank"> does nothing | Same | Intercept click → preventDefault() → app.openLink |
| Edited HTML doesn't appear in Desktop | Desktop caches UI resources | Fully quit (⌘Q) + relaunch, not just window-close |
When in doubt, open the iframe's own devtools console (not the main app's) — CSP violations log there. See iframe-sandbox.md for the bundle-inlining pattern.
Connector-directory submission checklist
Pre-flight before submitting a remote MCP app to the Claude connector directory. Each item is a hard review criterion.
| Area | Requirement |
|---|---|
| Auth | OAuth (DCR or CIMD) or `none` (authless). Static bearer tokens are private-deploy only and block listing. Authless is valid for public-data servers — the server holds any upstream API keys. |
| Tool annotations | Every tool sets annotations.title plus the relevant hints: readOnlyHint: true for fetch/search tools, destructiveHint / idempotentHint for writes, openWorldHint: true if the tool reaches an external system. |
| Tool names | ≤ 64 characters, snake/kebab case. |
| Widget layout | Inline height ≤ 500px, no nested scroll containers, 44pt minimum touch targets, WCAG-AA contrast in both themes. |
| Theming | html, body { background: transparent }, <meta name="color-scheme" content="light dark">, adopt host CSS tokens via applyHostStyleVariables. |
| External links | Use app.openLink. Declare each origin (e.g. https://api.example.com) in the connector's Allowed link URIs so the link skips the confirm modal. |
| Helper tools | Widget-only tools (geometry/image fetchers) carry _meta.ui.visibility: ["app"] so they don't appear in Claude's tool list. |
| Screenshots | 3–5 PNGs, ≥ 1000px wide, cropped to the app response only — no prompt text in frame. |
See abuse-protection.md for rate-limit and IP-tiering guidance once the authless endpoint is public.
Iframe sandbox constraints
MCP-app widgets run inside a sandboxed <iframe> in the host (Claude Desktop, claude.ai). The sandbox and CSP attributes lock down what the widget can do. Every item below was observed failing with a silent blank iframe until the fix was applied — the error only appears in the iframe's own devtools console, not the host's.
---
Problem → fix table
| Symptom | Root cause | Fix |
|---|---|---|
| Widget renders as blank rectangle, no error | CSP script-src blocks esm.sh fetching transitive @modelcontextprotocol/sdk deps | Inline the ext-apps/app-with-deps bundle into the HTML |
window.open() does nothing | Sandbox lacks allow-popups | Use app.openLink({ url }) |
<a target="_blank"> does nothing | Same | e.preventDefault() + app.openLink({ url }) on click |
External <img src> broken | CSP img-src + referrer hotlink blocking | Fetch server-side, ship as data: URL in the tool result payload |
| Widget edits don't appear after server restart | Host caches UI resources | Fully quit the host (⌘Q / Alt+F4) and relaunch |
Top-level await throws | Older iframe contexts | Wrap module body in an async IIFE |
---
Inlining the ext-apps bundle
@modelcontextprotocol/ext-apps ships a self-contained browser build at the app-with-deps export (~300KB). It's minified ESM ending in export{…}; to use it from an inline <script type="module"> block, rewrite the export statement into a global assignment at build time:
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"),
"utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((pair) => {
const [local, exported] = pair.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const widgetHtml = readFileSync("./widgets/widget.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);Widget side:
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "…", version: "…" }, {});
// …
})();
</script>The () => bundle replacer form (rather than a bare string) is important — String.replace interprets $… sequences in a string replacement, and the minified bundle is full of them.
---
Outbound links
// ✗ blocked
window.open(url, "_blank");
// ✗ blocked
<a href="…" target="_blank">…</a>
// ✓ host-mediated
await app.openLink({ url });Intercept anchor clicks:
el.addEventListener("click", (e) => {
e.preventDefault();
app.openLink({ url: el.href });
});---
External images
CSP img-src defaults (plus many CDN referrer policies) block <img src="https://external-cdn/…"> from loading. Inline them server-side in the tool handler:
async function toDataUrl(url: string): Promise<string | undefined> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) return undefined;
const buf = Buffer.from(await res.arrayBuffer());
const mime = res.headers.get("content-type") ?? "image/jpeg";
return `data:${mime};base64,${buf.toString("base64")}`;
} catch {
return undefined;
}
}
// in the tool handler
const inlined = await Promise.all(
items.map(async (it) =>
it.thumb ? { ...it, thumb: await toDataUrl(it.thumb) ?? it.thumb } : it,
),
);Add referrerpolicy="no-referrer" on the <img> as a fallback for any URL that survives un-inlined.
---
Theme & host styles
The host renders the iframe inside its own card chrome — paint a transparent background and adopt host CSS tokens so the widget blends in across light/dark and across hosts.
<meta name="color-scheme" content="light dark" />:root {
--ink: var(--color-text-primary, #0f1111);
--sub: var(--color-text-secondary, #5a6270);
--line: var(--color-border-default, #e3e6ea);
}
html, body { background: transparent; color: var(--ink); }
:root.dark .thumb { mix-blend-mode: normal; } /* multiply → images vanish in dark */const { App, applyHostStyleVariables } = globalThis.ExtApps;
function applyHostContext(ctx) {
document.documentElement.classList.toggle("dark", ctx?.theme === "dark");
if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
}
app.onhostcontextchanged = applyHostContext;
await app.connect();
applyHostContext(app.getHostContext());applyHostStyleVariables writes the host's --color-* / --font-* / --border-radius-* tokens onto :root; the hex values above are fallbacks for hosts that don't supply them.
---
Debugging
The iframe has its own console. In Claude Desktop, open DevTools (View → Toggle Developer Tools), then switch the context dropdown (top-left of the Console tab) from "top" to the widget's iframe. CSP violations, uncaught exceptions, and import errors all surface there — the host's main console stays silent.
Payload budgeting
Hosts cap tool-result text. claude.ai and Claude Desktop truncate at roughly 150,000 characters; Claude Code at ~25k tokens. When a tool result exceeds the cap, the host substitutes a file-pointer string in place of your JSON. The widget then receives non-JSON in ontoolresult, JSON.parse throws, and the user sees something like "Bad payload: SyntaxError: Unexpected token 'E'" — with no hint that size was the cause.
Symptom → cause
| Symptom | Likely cause |
|---|---|
Widget shows a JSON parse error on content[0].text | Result over the host cap; host swapped in a file-pointer string |
| Works for one query, breaks for "all of X" | Row count × column count crossed the cap |
| Works in MCP Inspector, breaks in Desktop | Inspector has no cap; Desktop does |
Strategy
Cap your own payload at ~130KB and degrade in order:
1. Ship full rows when JSON.stringify(rows).length is under the cap. 2. Prune columns to those the rendering spec actually references. Walk the spec for both field: "..." keys and datum.X / datum['X'] inside expression strings — if the spec aliases a column via a calculate transform, the alias appears as field: but the source column only appears as datum.X, and dropping it leaves the widget with NaN. 3. Truncate rows as a last resort and include { truncated: N } in the payload so the widget can label it.
const MAX = 130_000;
let out = rows;
if (JSON.stringify(out).length > MAX) {
const keep = referencedFields(spec); // field: + datum.X refs
out = rows.map((r) => pick(r, keep));
if (JSON.stringify(out).length > MAX) {
const per = JSON.stringify(out[0] ?? {}).length || 1;
out = out.slice(0, Math.floor(MAX / per));
}
}Heavy assets go via callServerTool, not the result
Geometry, image bytes, or any blob the widget needs but Claude doesn't should be served by a separate tool the widget calls after mount:
const topo = await app.callServerTool({ name: "get-topojson", arguments: { level } });Mark that helper tool with _meta.ui.visibility: ["app"] so it doesn't appear in Claude's tool list.
Widget Templates
Minimal HTML scaffolds for the common widget shapes. Copy, fill in, ship.
All templates inline the App class from @modelcontextprotocol/ext-apps at build time — the iframe's CSP blocks CDN script imports. They're intentionally framework-free; widgets are small enough that React/Vue hydration cost usually isn't worth it.
---
Serving widget HTML
Widgets are static HTML with one placeholder: /*__EXT_APPS_BUNDLE__*/ gets replaced at server startup with the ext-apps/app-with-deps bundle (rewritten to expose globalThis.ExtApps).
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
const require = createRequire(import.meta.url);
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppResource(server, "Picker", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);Bundle once per server startup (or at build time); reuse the bundle string across all widget templates.
---
Picker (single-select list)
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 280px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Picker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const { items } = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const it of items) {
const li = document.createElement("li");
li.innerHTML = `<div>${it.label}</div><div class="sub">${it.sub ?? ""}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected: ${it.id}` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>Tool returns: { content: [{ type: "text", text: JSON.stringify({ items: [{ id, label, sub? }] }) }] }
---
Confirm dialog
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 16px; }
.actions { display: flex; gap: 8px; margin-top: 16px; }
button { padding: 8px 16px; cursor: pointer; }
.danger { background: #d33; color: white; border: none; }
</style>
<p id="msg"></p>
<div class="actions">
<button id="cancel">Cancel</button>
<button id="confirm" class="danger">Confirm</button>
</div>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Confirm", version: "1.0.0" }, {});
app.ontoolresult = ({ content }) => {
const { message, confirmLabel } = JSON.parse(content[0].text);
document.getElementById("msg").textContent = message;
if (confirmLabel) document.getElementById("confirm").textContent = confirmLabel;
};
await app.connect();
document.getElementById("confirm").addEventListener("click", () => {
app.sendMessage({ role: "user", content: [{ type: "text", text: "Confirmed." }] });
});
document.getElementById("cancel").addEventListener("click", () => {
app.sendMessage({ role: "user", content: [{ type: "text", text: "Cancelled." }] });
});
})();
</script>Tool returns: { content: [{ type: "text", text: JSON.stringify({ message, confirmLabel? }) }] }
Note: For simple confirmation, prefer elicitation over a widget — see ../build-mcp-server/references/elicitation.md. Use this widget when you need custom styling or context beyond what a native form offers.
---
Progress (long-running)
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 16px; }
.bar { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; }
.fill { height: 100%; background: #2a7; transition: width 200ms; }
</style>
<p id="label">Starting…</p>
<div class="bar"><div id="fill" class="fill" style="width:0%"></div></div>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Progress", version: "1.0.0" }, {});
const label = document.getElementById("label");
const fill = document.getElementById("fill");
// The tool result fires when the job completes — intermediate updates
// arrive via the same handler if the server streams them
app.ontoolresult = ({ content }) => {
const state = JSON.parse(content[0].text);
if (state.progress !== undefined) {
label.textContent = state.message ?? `${state.progress}/${state.total}`;
fill.style.width = `${(state.progress / state.total) * 100}%`;
}
if (state.done) {
label.textContent = "Complete";
fill.style.width = "100%";
}
};
await app.connect();
})();
</script>Server side, emit progress via extra.sendNotification({ method: "notifications/progress", ... }) — see apps-sdk-messages.md.
---
Display-only (chart / preview)
Display widgets don't call sendMessage — they render and sit there. The tool should return a text summary alongside the widget so Claude can keep reasoning while the user sees the visual:
registerAppTool(server, "show_chart", {
description: "Render a revenue chart",
inputSchema: { range: z.enum(["week", "month", "year"]) },
_meta: { ui: { resourceUri: "ui://widgets/chart.html" } },
}, async ({ range }) => {
const data = await fetchRevenue(range);
return {
content: [{
type: "text",
text: `Revenue is up ${data.change}% over the ${range}. Chart rendered.\n\n` +
JSON.stringify(data.points),
}],
};
});<!doctype html>
<meta charset="utf-8" />
<style>body { font: 14px system-ui; margin: 12px; }</style>
<canvas id="chart" width="400" height="200"></canvas>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Chart", version: "1.0.0" }, {});
app.ontoolresult = ({ content }) => {
// Parse the JSON points from the text content (after the summary line)
const text = content[0].text;
const jsonStart = text.indexOf("\n\n") + 2;
const points = JSON.parse(text.slice(jsonStart));
drawChart(document.getElementById("chart"), points);
};
await app.connect();
function drawChart(canvas, points) { /* ... */ }
})();
</script>---
Carousel (multi-item display with actions)
For presenting multiple items (product picks, search results) in a horizontal scroll rail. Patterns that tested well:
- Skip nav chevrons — users know how to scroll.
scroll-snap-typecan cause a few-px-off-flush initial render; omit it andscrollLeft = 0after rendering. - Layout-fork by item count —
items.length === 1→ detail/PDP layout,> 1→ carousel. Handle in widget JS, keep the tool schema flat. - Put Claude's reasoning in each item — a
notefield rendered as a small callout on the card gives users the "why" inline. - Silent state via `updateModelContext` — cart/selection changes should inform Claude without spamming the chat. Reserve
sendMessagefor terminal actions ("checkout", "done"). - Outbound links via `app.openLink` —
window.openand<a target="_blank">are blocked by the sandbox.
<style>
.rail { display: flex; gap: 10px; overflow-x: auto; padding: 12px; scrollbar-width: none; }
.rail::-webkit-scrollbar { display: none; }
.card { flex: 0 0 220px; border: 1px solid #ddd; border-radius: 6px; padding: 10px; }
.thumb-box { aspect-ratio: 1 / 1; display: grid; place-items: center; background: #f7f8f8; }
.thumb { max-width: 100%; max-height: 100%; object-fit: contain; }
.note { font-size: 12px; color: #666; border-left: 3px solid orange; padding: 2px 8px; margin: 8px 0; }
</style>
<div class="rail" id="rail"></div>Images: the iframe CSP blocks remote img-src. Fetch thumbnails server-side in the tool handler, embed as data: URLs in the JSON payload, and render from those. Add referrerpolicy="no-referrer" as a fallback.
Related skills
How it compares
Widgets vs. elicitation: use elicitation for simple yes/no and short enums; use widgets for large searchable lists, visual previews, charts, and live-updating state. If text degradation is acceptable, choose the widget t
FAQ
What does build-mcp-app protect on authless MCP servers?
build-mcp-app protects three resources on authless StreamableHTTP servers: server compute, upstream API quota consumed by tools, and egress bandwidth for large callServerTool payloads. Authless mode provides no per-user token or session ID.
Which IP ranges does build-mcp-app reference for Claude.ai traffic?
build-mcp-app notes Claude.ai web users arrive via Anthropic egress ranges 160.79.104.0/21 and 2607:6bc0::/48 per platform.claude.com API documentation. Claude Desktop and Claude Code connect directly from the user's machine.
Is Build Mcp App safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.