option
MaisonMaison Skill Autres build-mcp-app

Cette compétence doit être utilisée lorsque l'utilisateur souhaite créer une « application MCP », ajouter une « interface utilisateur interactive » ou des « widgets » à un serveur MCP, « afficher des composants dans le chat », créer des «ressources d’interface utilisateur MCP », créer un outil affichant un « formulaire », un « sélecteur », un « tableau de bord » ou une « boîte de dialogue de confirmation » directement dans la conversation, ou encore mentionner le « SDK des applications » dans le contexte de MCP. À utiliser APRÈS que la compétence « build-mcp-server » ait défini le modèle de déploiement, ou lorsque l’utilisateur sait déjà qu’il souhaite utiliser des widgets d’interface utilisateur.

...Développer tout
54
Heure mise à jour 4 août 2026

Créer une application MCP (widgets d'interface utilisateur interactifs)

Une application MCP est un serveur MCP standard qui fournit également des ressources d’interface utilisateur — des composants interactifs affichés directement dans la fenêtre de discussion. Développée une seule fois, elle fonctionne sur Claude et ChatGPT, ainsi que sur tout autre hôte implémentant l’interface de l’application.

La couche d’interface utilisateur est additive. En arrière-plan, il s’agit toujours des mêmes outils, ressources et du même protocole de communication. Si vous n’avez jamais créé de serveur MCP standard auparavant, la build-mcp-server skill couvre la couche de base. Ce skill ajoute des widgets par-dessus.

Test dans Claude : ajoutez le serveur en tant que connecteur personnalisé dans claude.ai (via un tunnel Cloudflare pour le développement local) — cela permet de tester le véritable bac à sable iframe et hostContext. Voir https://claude.com/docs/connectors/building/testing.

Spécificités de l’hôte Claude

  • hostContext.safeAreaInsets: {top, right, bottom, left} (px) — respectez ces valeurs pour les encoches et la superposition du compositeur.
  • La soumission au répertoire nécessite OAuth ou l’authentification sans identifiant (none) — l’authentification statique « bearer » est réservée au déploiement privé et empêche l’indexation — ainsi que l’outil annotations et 3 à 5 captures d’écran au format PNG ; voir references/directory-checklist.md.

Quand un widget vaut mieux que du texte brut

N’ajoutez pas d’interface utilisateur juste pour le plaisir — la plupart des outils se contentent de renvoyer du texte ou du JSON. Ajoutez un widget lorsque l’une des conditions suivantes est remplie :

Si aucune de ces conditions ne s’applique, ne créez pas de widget. Le texte est plus rapide à créer et plus rapide pour l’utilisateur.


Widgets vs élaboration des spécifications — choisissez la bonne approche

Avant de créer un widget, vérifiez si l’élicitation permet de le remplacer. L’élicitation est native de la spécification, ne nécessite aucun code d’interface utilisateur et fonctionne dans n’importe quel hôte compatible.

Si l’élicitation couvre ce cas, utilisez-la. Voir ../build-mcp-server/references/elicitation.md.


Architecture : deux modèles de déploiement

Application MCP distante (la plus courante)

Serveur HTTP « streamable » hébergé. Les modèles de widgets sont fournis sous forme de ressources ; les résultats des outils y font référence. L’hôte récupère la ressource, l’affiche dans un bac à sable iframe et achemine les messages entre le widget et Claude.

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

Application MCP packagée dans un MCPB (locale + interface utilisateur)

Même mécanisme de widget, mais le serveur s’exécute localement au sein d’un bundle MCPB. Utilisez cette option lorsque le widget doit piloter une application locale — par exemple, un sélecteur de fichiers parcourant le disque local, ou une boîte de dialogue contrôlant une application de bureau.

Pour les mécanismes d’empaquetage MCPB, reportez-vous à la compétence «build-mcpb». Tout ce qui suit s’applique aux deux types de formes.


Comment les widgets s’associent aux outils

Un outil prenant en charge les widgets comporte deux enregistrements distincts :

  1. L’outil déclare une ressource d’interface utilisateur via _meta.ui.resourceUri. Son gestionnaire renvoie du texte brut ou du JSON — et NON du code HTML.
  2. La ressource est enregistrée séparément et fournit le code HTML.

Lorsque Claude appelle l’outil, l’hôte voit _meta.ui.resourceUri, récupère cette ressource, l’affiche dans une iframe et transmet la valeur de retour de l’outil à l’iframe via l’ ontoolresult .

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

Le schéma URI ui:// est convention. Le type MIME DOIT être RESOURCE_MIME_TYPE ("text/html;profile=mcp-app") — c’est ainsi que l’hôte sait qu’il doit l’afficher sous forme d’iframe interactive, et non pas simplement afficher le code source.


Environnement d’exécution du widget — la App classe

À l’intérieur de l’iframe, votre script communique avec l’hôte via la App classe issue de @modelcontextprotocol/ext-apps. Il s’agit d’une connexion bidirectionnelle persistante : le widget reste actif tant que la communication est en cours, recevant les nouveaux résultats de l’outil et transmettant les actions de l’utilisateur.

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

Le /*__EXT_APPS_BUNDLE__*/ placeholder est remplacé par le serveur au démarrage par le contenu de @modelcontextprotocol/ext-apps/app-with-deps — voir references/iframe-sandbox.md pour comprendre pourquoi cela est nécessaire et consulter l’extrait de code de réécriture. Ne import { App } from "https://esm.sh/..."; la politique de sécurité (CSP) de l’iframe bloque les requêtes de dépendances transitives et le widget s’affiche vide.

sendMessage C'est le chemin classique « l'utilisateur a sélectionné quelque chose, préviens Claude ». updateModelContext est destiné à l’état dont Claude doit être informé sans pour autant encombrer le chat. openLink est obligatoire pour toute navigation sortante — window.open et sont bloqués par l’attribut « sandbox ».

Ce que les widgets ne peuvent pas faire :

  • Accéder au DOM, aux cookies ou au stockage de la page hôte
  • Effectuer des requêtes réseau vers des origines arbitraires (restreintes par le CSP — passer par callServerTool)
  • Ouvrir des fenêtres contextuelles ou naviguer directement — utiliser app.openLink({url})
  • Charger des images distantes de manière fiable — en ligne sous forme d’ data: URL intégrées côté serveur

Veillez à ce que les widgets restent légers et monofonctionnels. Un sélecteur sert à sélectionner. Un graphique sert à afficher. Ne construisez pas une sous-application complète à l’intérieur de l’iframe — divisez-la en plusieurs outils dotés de widgets spécialisés.


Structure de base : widget de sélection minimaliste

Installation :

npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express

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

Pour les applications de widgets exclusivement locales (pilotant une application de bureau, lisant des fichiers locaux), remplacez le transport par StdioServerTransport et compilez via la build-mcpb skill.

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>

Voir references/widget-templates.md pour découvrir d’autres formes de widgets.


Conseils de conception pour vous éviter de tout réécrire

Un widget par outil. Résistez à la tentation de créer un méga-widget qui fait tout. Un outil → un widget dédié → une forme de résultat claire. Claude les interprète bien mieux ainsi.

La description de l’outil doit mentionner le widget. Claude ne voit que la description de l’outil lorsqu’il décide quoi appeler. C’est la mention « Ouvre un sélecteur interactif » dans la description qui incite Claude à choisir ce widget plutôt que de deviner un identifiant.

Les widgets sont facultatifs lors de l’exécution. Les hôtes qui ne prennent pas en charge l’interface des applications ignorent simplement _meta.ui et affichent normalement le contenu textuel de l’outil. Comme votre gestionnaire d’outil renvoie déjà du texte/JSON significatif (les données du widget), la dégradation est automatique : Claude voit les données directement plutôt que via le widget.

Ne bloquez pas le traitement sur les résultats des widgets pour les outils en lecture seule. Un widget qui se contente d’afficher des données (graphique, aperçu) ne devrait pas nécessiter d’action de la part de l’utilisateur pour être considéré comme terminé. Renvoyez le widget d’affichage et un résumé textuel dans le même résultat afin que Claude puisse poursuivre son raisonnement sans attendre.

Divisez la mise en page en fonction du nombre d’éléments, et non du nombre d’outils. Si un cas d’utilisation consiste à « afficher un résultat en détail » et un autre à « afficher plusieurs résultats côte à côte », ne créez pas deux outils — créez un seul outil qui accepte items[], et laissez le widget choisir la mise en page : items.length === 1 → vue détaillée, > 1 → carrousel. Cela permet de conserver un schéma serveur simple et laisse Claude déterminer naturellement le nombre d’éléments.

Intégrez le raisonnement de Claude dans la charge utile. Un court note champ sur chaque élément (expliquant pourquoi Claude l’a sélectionné), affiché sous forme de légende sur la carte, fournit aux utilisateurs le raisonnement à l’appui du choix. Mentionnez ce champ dans la description de l’outil afin que Claude le remplisse.

Normalisez les formats d’image côté serveur. Si votre source de données renvoie des images dont les rapports d’aspect varient considérablement, convertissez-les en un format prévisible (par exemple, des images carrées) avant de récupérer l’URL de données en ligne. Attribuez ensuite au conteneur d’image du widget une largeur fixe aspect-ratio + object-fit: contain afin que tout soit centré.

Respectez le thème de l'hôte. app.getHostContext()?.theme (après connect()) et app.onhostcontextchanged pour les mises à jour en temps réel. Activez ou désactivez une .dark classe sur , conservez les couleurs dans les propriétés personnalisées CSS à l’aide d’un :root.dark {} bloc de redéfinition, définissez color-scheme. Désactivez le mix-blend-mode: multiply en mode sombre — cela fait disparaître les images.


Test

Claude Desktop — les versions actuelles nécessitent encore la command/args configuration de la forme (pas de "type": "http"). Enveloppez-le avec mcp-remote et forcer le http-only le transport afin que la sonde SSE n'interfère pas avec la négociation des capacités des widgets :

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

Desktop met en cache les ressources de l’interface utilisateur de manière intensive. Après avoir modifié le code HTML d’un widget, fermez complètement l’application (⌘Q / Alt+F4, et non pas en fermant la fenêtre) puis relancez-la pour forcer une nouvelle récupération à froid des ressources.

Boucle JSON-RPC sans interface graphique — itération rapide sans passer par le bureau :

# 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

Le sleep maintient stdin ouvert suffisamment longtemps pour collecter toutes les réponses. Analysez la sortie jsonl avec jq ou une ligne de code Python.

Boucle de développement de widgets — évitez complètement le cycle ⌘Q-redémarrage en servant le code HTML du widget intégré via une route GET simple avec un faux ExtApps shim qui déclenche ontoolresult à partir d’un paramètre de requête :

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

Ouvrez http://localhost:3000/widget-preview?payload={"rows":[...]} dans un onglet de navigateur normal et effectuez des itérations avec les outils de développement habituels.

Solution de secours pour l’hôte — utilisez un hôte sans l’interface utilisateur de l’application (ou MCP Inspector) et vérifiez que le contenu textuel de l’outil s’affiche correctement même en cas de dégradation.

Débogage CSP — ouvrez la console des outils de développement propre à l’iframe. Les violations CSP sont la première cause d’échec silencieux des widgets (rectangle vide, aucune erreur dans la console principale). Voir les references/iframe-sandbox.md.


Fichiers de référence

  • references/iframe-sandbox.md — contraintes CSP/sandbox, le modèle d’intégration des bundles, gestion des images, personnalisation de l’hôte
  • references/widget-templates.md — structures HTML réutilisables pour les sélecteurs / confirmations / barres de progression / affichages
  • references/apps-sdk-messages.md — l’ App API de classe : messagerie widget ↔ hôte ↔ serveur, cycle de vie et remplacement
  • references/payload-budgeting.md — limites de taille des résultats des outils de l’hôte, « prune-then-truncate », ressources lourdes via callServerTool
  • references/abuse-protection.md — CIDR de sortie Anthropic, limitation de débit par paliers, trust proxy, mise en cache des réponses
  • references/directory-checklist.md — vérification préalable à la soumission au répertoire des connecteurs
Voir sur GitHub

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

Installer build-mcp-app

Téléchargez et décompressez les fichiers de compétences dans votre répertoire .claude/skills/.

Télécharger le ZIP

Clonez le dépôt et copiez les fichiers de compétence dans votre projet.

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/

Copier Copier
Configuration rapide: Copiez le dossier de la compétence dans .claude/skills/ ; Claude la détectera automatiquement et l'utilisera.

Compétences similaires

multica-creating-agents
Heure mise à jour 12 août 2026
tilemaps
Heure mise à jour 4 août 2026
v4-new-features
Heure mise à jour 4 août 2026
pixijs-application
Heure mise à jour 4 août 2026
OR