Option
HeimHeim Skill Sonstiges build-mcp-app

Diese Funktion sollte verwendet werden, wenn der Benutzer eine „MCP-App“ erstellen, einem MCP-Server eine „interaktive Benutzeroberfläche“ oder „Widgets“ hinzufügen, „Komponenten im Chat darstellen“, „MCP-UI-Ressourcen“ erstellen, ein Tool entwickeln möchte, das ein „Formular“, einen „Picker“, ein „Dashboard“ oder einen „Bestätigungsdialog“ direkt in der Konversation anzeigt, oder im Zusammenhang mit MCP das „Apps SDK“ erwähnt. Verwenden Sie diese Funktion, NACHDEM die „build-mcp-server“-Fähigkeit das Bereitstellungsmodell festgelegt hat oder wenn der Benutzer bereits weiß, dass er UI-Widgets benötigt.

...Alle erweitern
54
Zeit aktualisiert 4. August 2026

Eine MCP-App erstellen (interaktive UI-Widgets)

Eine MCP-App ist ein Standard-MCP-Server, der zusätzlich UI-Ressourcen bereitstellt – interaktive Komponenten, die direkt in der Chat-Oberfläche gerendert werden. Einmal erstellt, läuft sie in Claude und ChatGPT sowie auf jedem anderen Host, der die App-Oberfläche implementiert.

Die Benutzeroberfläche ist eine zusätzliche Ebene. Im Hintergrund kommen nach wie vor dieselben Tools, Ressourcen und dasselbe Wire-Protokoll zum Einsatz. Wenn Sie noch nie einen einfachen MCP-Server erstellt haben, deckt der build-mcp-server behandelt dieser Skill die Basisschicht. Dieser Skill fügt darüber hinaus Widgets hinzu.

Testen in Claude: Füge den Server als benutzerdefinierten Connector in claude.ai hinzu (über einen Cloudflare-Tunnel für die lokale Entwicklung) – dadurch wird die echte Iframe-Sandbox genutzt und hostContext. Siehe https://claude.com/docs/connectors/building/testing.

Besonderheiten des Claude-Hosts

  • hostContext.safeAreaInsets: {top, right, bottom, left} (px) – diese Werte müssen für Notches und das Composer-Overlay eingehalten werden.
  • Die Einreichung in das Verzeichnis erfordert OAuth oder eine authentifizierungsfreie Verbindung (none) – „static bearer“ ist nur für den privaten Einsatz vorgesehen und verhindert die Aufnahme in die Liste – sowie annotations sowie 3–5 PNG-Screenshots; siehe references/directory-checklist.md.

Wenn ein Widget besser ist als reiner Text

Füge keine Benutzeroberfläche um ihrer selbst willen hinzu – die meisten Tools geben Text oder JSON problemlos zurück. Füge ein Widget hinzu, wenn einer der folgenden Punkte zutrifft:

Wenn keiner der Punkte zutrifft, verzichte auf das Widget. Text lässt sich schneller erstellen und ist für den Nutzer schneller.


Widgets vs. Elicitation – richtig entscheiden

Bevor du ein Widget erstellst, prüfe, ob die Datenabfrage dies abdeckt. Die Datenabfrage ist spezifikationskonform, erfordert keinen UI-Code und funktioniert in jedem kompatiblen Host.

Wenn die Elicitation dies abdeckt, nutze sie. Siehe ../build-mcp-server/references/elicitation.md.


Architektur: zwei Bereitstellungsformen

Remote-MCP-App (am häufigsten)

Gehosteter Streamable-HTTP-Server. Widget-Vorlagen werden als Ressourcen bereitgestellt; Tool-Ergebnisse verweisen auf diese. Der Host ruft die Ressource ab, rendert sie in einer Iframe-Sandbox und vermittelt Nachrichten zwischen dem Widget und Claude.

┌──────────┐  tools/call   ┌────────────┐
│  Claude  │─────────────> │ MCP server │
│   host   │<── result ────│  (remote)  │
│          │  + widget ref │            │
│          │               │            │
│          │ resources/read│            │
│          │─────────────> │  widget    │
│ ┌──────┐ │<── template ──│  HTML/JS   │
│ │iframe│ │               └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘

MCPB-verpackte MCP-App (lokal + UI)

Gleicher Widget-Mechanismus, jedoch läuft der Server lokal innerhalb eines MCPB-Bundles. Verwenden Sie diese Variante, wenn das Widget eine lokale Anwendung steuern muss – z. B. einen Dateiauswähler, der die lokale Festplatte durchsucht, oder einen Dialog, der eine Desktop-App steuert.

Für die MCPB-Paketierungsmechanismen beziehen Sie sich bitte auf die „build-mcpb“-Funktion. Alle folgenden Informationen gelten für beide Formen.


Wie Widgets an Tools angehängt werden

Ein Widget-fähiges Tool verfügt über zwei separate Registrierungen:

  1. Das Tool deklariert eine UI-Ressource über _meta.ui.resourceUri. Sein Handler gibt reinen Text/JSON zurück – NICHT den HTML-Code.
  2. Die Ressource wird separat registriert und stellt den HTML-Code bereit.

Wenn Claude das Tool aufruft, sieht der Host _meta.ui.resourceUri, ruft diese Ressource ab, rendert sie in einem iframe und leitet den Rückgabewert des Tools über das ontoolresult Ereignis in den iframe weiter.

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
    }],
  }),
);

Das URI-Schema ui:// entspricht der Konvention. Der MIME-Typ MUSS RESOURCE_MIME_TYPE ("text/html;profile=mcp-app") – nur so weiß der Host, dass er es als interaktiven Iframe rendern muss und nicht einfach nur den Quellcode anzeigen darf.


Widget-Laufzeitumgebung — die App Klasse

Innerhalb des iframes kommuniziert Ihr Skript mit dem Host über die App Klasse aus @modelcontextprotocol/ext-apps. Dabei handelt es sich um eine dauerhafte bidirektionale Verbindung – das Widget bleibt so lange aktiv, wie die Kommunikation besteht, empfängt neue Tool-Ergebnisse und sendet Benutzeraktionen.

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

Der /*__EXT_APPS_BUNDLE__*/ Platzhalter wird beim Start vom Server durch den Inhalt von @modelcontextprotocol/ext-apps/app-with-deps – siehe references/iframe-sandbox.md , warum dies notwendig ist, und das Rewrite-Snippet. Verwenden Sie nicht import { App } from "https://esm.sh/..."; die CSP-Regeln des iframes blockieren das Abrufen transitiver Abhängigkeiten, und das Widget wird leer gerendert.

sendMessage Dies ist der typische Ablauf: „Der Benutzer hat etwas ausgewählt, teile es Claude mit“. updateModelContext dient für Zustände, die Claude kennen sollte, die aber den Chat nicht überladen dürfen. openLink ist für jede Navigation nach außen erforderlich — window.open und werden durch das Sandbox-Attribut blockiert.

Was Widgets nicht können:

  • Auf das DOM, die Cookies oder den Speicher der Host-Seite zugreifen
  • Netzwerkaufrufe an beliebige Ursprünge tätigen (CSP-beschränkt – Umleitung über callServerTool)
  • Popups öffnen oder direkt navigieren – verwenden Sie app.openLink({url})
  • Fremde Bilder zuverlässig laden – inline als data: URLs serverseitig

Halten Sie Widgets klein und zweckgebunden. Ein Auswahldialog wählt aus. Ein Diagramm zeigt Daten an. Bauen Sie keine komplette Unteranwendung innerhalb des iframe auf – teilen Sie sie in mehrere Tools mit fokussierten Widgets auf.


Grundgerüst: minimales Auswahl-Widget

Installation:

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);

Für rein lokal laufende Widget-Apps (die eine Desktop-App steuern oder lokale Dateien lesen) tauschen Sie den Transport gegen StdioServerTransport und verpacken Sie das Widget über die build-mcpb Skill verpacken.

Widget (widgets/picker.html):

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 = `
${c.name}
${c.email}
`
; li.addEventListener("click", () => { app.sendMessage({ role: "user", content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }], }); }); ul.append(li); } }; await app.connect(); })(); script>

Weitere Informationen references/widget-templates.md für weitere Widget-Formate.


Designhinweise, die dir eine Neuprogrammierung ersparen

Ein Widget pro Tool. Widerstehe dem Drang, ein Mega-Widget zu erstellen, das alles kann. Ein Tool → ein fokussiertes Widget → eine klare Ergebnisform. Claude kann damit viel besser umgehen.

Die Werkzeugbeschreibung muss das Widget erwähnen. Claude sieht nur die Werkzeugbeschreibung, wenn er entscheidet, was er aufrufen soll. „Öffnet einen interaktiven Auswahl-Dialog“ in der Beschreibung ist das, was Claude dazu veranlasst, darauf zurückzugreifen, anstatt eine ID zu erraten.

Widgets sind zur Laufzeit optional. Hosts, die die App-Oberfläche nicht unterstützen, ignorieren _meta.ui und rendern den Textinhalt des Tools wie gewohnt. Da Ihr Tool-Handler bereits aussagekräftigen Text/JSON (die Daten des Widgets) zurückgibt, erfolgt die Herabstufung automatisch – Claude sieht die Daten direkt statt über das Widget.

Warten Sie bei schreibgeschützten Tools nicht auf Widget-Ergebnisse. Ein Widget, das lediglich Daten anzeigt (Diagramm, Vorschau), sollte keine Benutzeraktion erfordern, um abgeschlossen zu werden. Geben Sie das Anzeige-Widget und eine Textzusammenfassung im selben Ergebnis zurück, damit Claude ohne Wartezeit weiterdenken kann.

Teilen Sie das Layout nach der Anzahl der Elemente auf, nicht nach der Anzahl der Tools. Wenn ein Anwendungsfall „ein Ergebnis im Detail anzeigen“ lautet und ein anderer „viele Ergebnisse nebeneinander anzeigen“, erstellen Sie nicht zwei Tools – erstellen Sie ein Tool, das items[]und das Widget das Layout auswählen lässt: items.length === 1 → Detailansicht, > 1 → Karussell. Das hält das Serverschema einfach und lässt Claude die Anzahl auf natürliche Weise bestimmen.

Fügen Sie Claudes Schlussfolgerung in die Nutzdaten ein. Ein kurzes note Feld bei jedem Element (warum Claude es ausgewählt hat), das als Callout auf der Karte dargestellt wird, liefert den Nutzern die Begründung direkt neben der Auswahl. Erwähne dieses Feld in der Tool-Beschreibung, damit Claude es ausfüllt.

Normalisieren Sie die Bildformen serverseitig. Wenn Ihre Datenquelle Bilder mit stark variierenden Seitenverhältnissen zurückgibt, wandeln Sie diese vor dem Abruf für die Inline-Daten-URL in eine vorhersehbare Variante um (z. B. quadratisch begrenzt). Weisen Sie dann dem Bildcontainer des Widgets eine feste aspect-ratio + object-fit: contain , damit alles zentriert angezeigt wird.

Passen Sie sich dem Host-Theme an. app.getHostContext()?.theme (nach connect()) sowie app.onhostcontextchanged für Live-Aktualisierungen. Schalten Sie eine .dark Klasse ein , Farben in benutzerdefinierten CSS-Props mit einem :root.dark {} Überschreibungsblock, legen Sie color-scheme. Deaktivieren Sie mix-blend-mode: multiply im Dunkelmodus – dadurch verschwinden Bilder.


Testen

Claude Desktop – aktuelle Builds erfordern noch die command/args Konfigurationsform (keine native "type": "http"). Mit mcp-remote und erzwinge den http-only Transport erzwingen, damit die SSE-Prüfung die Aushandlung der Widget-Fähigkeiten nicht unterbindet:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
               "--allow-http", "--transport", "http-only"]
    }
  }
}

Desktop speichert UI-Ressourcen intensiv im Cache. Beenden Sie nach der Bearbeitung des Widget-HTMLs die Anwendung vollständig (⌘Q / Alt+F4, nicht das Fenster schließen) und starten Sie sie neu, um ein vollständiges Neuladen der Ressourcen zu erzwingen.

Headless-JSON-RPC-Schleife – schnelle Iteration ohne Klicken durch den 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

Das sleep hält stdin lange genug offen, um alle Antworten zu erfassen. Parsen Sie die jsonl-Ausgabe mit jq oder einem Python-Einzeiler.

Widget-Entwicklungsschleife – Umgehen Sie den ⌘Q-Neustart-Zyklus vollständig, indem Sie den eingebetteten Widget-HTML-Code über eine einfache GET-Route mit einem gefälschten ExtApps Shim, der ontoolresult über einen Abfrageparameter:

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));
});

Öffne http://localhost:3000/widget-preview?payload={"rows":[...]} in einem normalen Browser-Tab und führen Sie die Überprüfung mit den üblichen Entwicklertools durch.

Host-Fallback – Verwende einen Host ohne die App-Oberfläche (oder den MCP Inspector) und stelle sicher, dass der Textinhalt des Tools auch unter diesen Bedingungen einwandfrei dargestellt wird.

CSP-Debugging – Öffnen Sie die eigene DevTools-Konsole des Iframes. CSP-Verstöße sind der Hauptgrund dafür, dass Widgets stillschweigend fehlschlagen (leeres Rechteck, kein Fehler in der Hauptkonsole). Siehe references/iframe-sandbox.md.


Referenzdateien

  • references/iframe-sandbox.md — CSP-/Sandbox-Einschränkungen, das Bundle-Inlining-Muster, Bildverarbeitung, Host-Theming
  • references/widget-templates.md — wiederverwendbare HTML-Gerüste für Auswahlfelder / Bestätigungsfelder / Fortschrittsanzeigen / Anzeigen
  • references/apps-sdk-messages.md — die App Klassen-API: Nachrichtenaustausch zwischen Widget ↔ Host ↔ Server, Lebenszyklus und Ersetzung
  • references/payload-budgeting.md — Größenbeschränkungen für Host-Tool-Ergebnisse, „Prune-then-Truncate“, umfangreiche Assets über callServerTool
  • references/abuse-protection.md — Anthropic-CIDRs für den ausgehenden Datenverkehr, gestaffelte Ratenbegrenzung, trust proxy, Antwort-Caching
  • references/directory-checklist.md — Vorabprüfung für die Einreichung in das Connector-Verzeichnis
Auf GitHub ansehen

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

  • 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:

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.

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.

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

build-mcp-app installieren

Laden Sie die Skill-Dateien herunter und entpacken Sie sie in Ihr Verzeichnis „.claude/skills/“.

ZIP herunterladen

Klonen Sie das Repository und kopieren Sie die Skill-Dateien in Ihr Projekt.

git clone https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app # Copy the skill folder to .claude/skills/ or .codex/skills/

Kopieren Kopieren
Schnelle Einrichtung: Kopiere den Skill-Ordner nach .claude/skills/. Claude erkennt den Skill automatisch und nutzt ihn.

Ähnliche Skills

multica-creating-agents
Zeit aktualisiert 12. August 2026
tilemaps
Zeit aktualisiert 4. August 2026
v4-new-features
Zeit aktualisiert 4. August 2026
pixijs-application
Zeit aktualisiert 4. August 2026
OR