Generated banner for Build MCP App
Generated logo for Build MCP App

Build MCP App

AnthropicOfficialPublished by Anthropic

  • Skill
  • Tool integration
  • Automation and integrations
  • Apache-2.0
  • No issues found

From the author

This skill should be used when the user wants to build an "MCP app", add "interactive UI" or "widgets" to an MCP server, "render components in chat", build "MCP UI resources", make a tool that shows a "form", "picker", "dashboard" or "confirmation dialog" inline in the conversation, or mentions "apps SDK" in the context of MCP. Use AFTER the build-mcp-server skill has settled the deployment model, or when the user already knows they want UI widgets.
— the description by Anthropic

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides the framework for extending standard MCP servers with interactive UI components. By using the @modelcontextprotocol/ext-apps SDK, developers can create widgets that render as sandboxed iframes within the chat surface. These widgets allow for richer user interactions—such as selecting from large lists, confirming sensitive actions, or viewing visual data—that go beyond simple text-based responses. The architecture relies on a dual-registration pattern where a tool declares a UI resource, and a separate resource handler serves the HTML/JS widget, which then communicates with the host via a persistent bidirectional connection.

What it does

  • Enables interactive UI widgets like forms, pickers, and confirmation dialogs in chat.
  • Provides the App class for bidirectional communication between the widget and the host.
  • Supports host-mediated actions like opening links, downloading files, and requesting display modes.
  • Allows for silent context updates using updateModelContext.
  • Facilitates tool-to-tool communication via callServerTool within the widget.
  • Ensures graceful degradation for hosts that do not support the apps surface.

When to use it

  • When a tool requires structured input that Claude cannot reliably infer.
  • When the user needs to select from a large or searchable list.
  • When a destructive or billable action requires explicit user confirmation.
  • When output is visual or spatial, such as charts, maps, or diffs.
  • When you need to provide a live-updating status for long-running jobs.

Advantages

  • Provides a native-feeling interactive experience within the chat interface.
  • Works across multiple hosts that implement the apps surface.
  • Maintains compatibility with standard MCP servers.
  • Enables complex interactions that are impossible with plain text or elicitation.

Usage examples

“How do I add a contact picker widget to my existing MCP server?”
The agent guides you through registering an app tool with a resourceUri, creating the corresponding HTML resource, and using the App class to handle tool results and user selections.
“My widget is rendering blank in the chat. How do I fix it?”
The agent helps you debug CSP violations, verify the bundle-inlining pattern for the ext-apps SDK, and check the iframe sandbox constraints.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/anthropics/build-mcp-app/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip build-mcp-app.zip -d .agents/skills

Documentation by Anthropic

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.* keyWhereEffect
resourceUritoolWhich ui:// resource the host renders for this tool's results.
visibility: ["app"]toolHide a widget-only helper tool (e.g. geometry/image fetcher called via callServerTool) from Claude's tool list.
prefersBorder: falseresourceDrop the host's outer card border (mobile).
csp.{connectDomains, resourceDomains, baseUriDomains}resourceDeclare 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 tool annotations and 3–5 PNG screenshots; see references/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:

SignalWidget type
Tool needs structured input Claude can't reliably inferForm
User must pick from a list Claude can't rank (files, contacts, records)Picker / table
Destructive or billable action needs explicit confirmationConfirm dialog
Output is spatial or visual (charts, maps, diffs, previews)Display widget
Long-running job the user wants to watchProgress / 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.

NeedElicitationWidget
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.

MethodDirectionUse for
app.ontoolresult = fnHost → widgetReceive the tool's return value
app.ontoolinput = fnHost → widgetReceive the tool's input args (what Claude passed)
app.sendMessage({...})Widget → hostInject a message into the conversation
app.updateModelContext({...})Widget → hostUpdate context silently (no visible message)
app.callServerTool({name, arguments})Widget → serverCall another tool on your server
app.openLink({url})Widget → hostOpen a URL in a new tab (sandbox blocks window.open)
app.getHostContext() / app.onhostcontextchangedHost → widgetTheme, host CSS vars, containerDimensions, displayMode, deviceCapabilities
app.requestDisplayMode({mode})Widget → hostAsk for inline / pip / fullscreen
app.downloadFile({name, mimeType, content})Widget → hostHost-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 express

Server (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-http

The 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 theming

  • references/widget-templates.md — reusable HTML scaffolds for picker / confirm / progress / display

  • references/apps-sdk-messages.md — the App class API: widget ↔ host ↔ server messaging, lifecycle & supersession

  • references/payload-budgeting.md — host tool-result size caps, prune-then-truncate, heavy assets via callServerTool

  • references/abuse-protection.md — Anthropic egress CIDRs, tiered rate limiting, trust proxy, response caching

  • references/directory-checklist.md — pre-flight for connector-directory submission

Files

references/apps-sdk-messages.md

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.

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. fieldUse
theme"light" / "dark" — toggle a .dark class
styles.variablesHost CSS tokens — pass to applyHostStyleVariables() so colors/fonts match host chrome
displayMode / availableDisplayModesCurrent mode and which requestDisplayMode targets are valid
containerDimensions.{maxHeight,width}Size your render to this instead of hard-coded px
deviceCapabilities.touchSwitch hover-only affordances to tap (pointerdown)
safeAreaInsetsPadding 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.

SymptomCauseFix
Widget is a blank rectangle, nothing rendersCDN 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 runInline event handlers blockedUse addEventListener — never onclick="..." in HTML
eval / new Function errorsScript-src restrictionDon't use them; use JSON.parse for data
fetch() to your API failsCross-origin blockedRoute through app.callServerTool() instead
External CSS doesn't loadstyle-src restrictionInline styles in a <style> tag
Fonts don't loadfont-src restrictionUse system fonts (font: 14px system-ui)
External <img src> brokenCSP img-src + referrer hotlink blockingFetch server-side, inline as data: URL in the tool result payload
window.open() does nothingSandbox lacks allow-popupsUse app.openLink({url})
<a target="_blank"> does nothingSameIntercept click → preventDefault() → app.openLink
Edited HTML doesn't appear in DesktopDesktop caches UI resourcesFully 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.

Requirements and compatibility

Written for: Claude Code, Claude apps

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs an MCP server, Needs Node.js

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Anthropic
Publisher
Anthropic (Official)
Source
anthropics/claude-plugins-official · plugins/mcp-server-dev/skills/build-mcp-app · commit 0412be7
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from plugins/mcp-server-dev/LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Anthropic or Anthropic.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download build-mcp-app.zip