选项
首页首页 Skill 其他 build-mcp-app

当用户希望构建“MCP 应用”、向 MCP 服务器添加“交互式 UI”或“控件”、在聊天中“渲染组件”、构建“MCP UI 资源”,制作可在对话中内联显示“表单”、“选择器”、“仪表盘”或“确认对话框”的工具,或在 MCP 相关场景中提及“应用 SDK”。 请在“build-mcp-server”技能确定部署模型之后使用,或者当用户已明确需要 UI 控件时使用。

...展开全部
54
更新时间 2026-08-04

构建 MCP 应用(交互式 UI 控件)

MCP 应用是一种标准的 MCP 服务器,同时提供 UI 资源——即在聊天界面中内联渲染的交互式组件。只需开发一次,即可在 Claude、ChatGPT 以及任何其他实现了应用界面的宿主环境中运行。

UI 层是附加的。在底层,它仍然由工具、资源和相同的网络协议组成。如果您之前从未构建过普通的 MCP 服务器, build-mcp-server 本技能涵盖了基础层。本技能在此基础上添加了控件。

在 Claude 中测试:在 claude.ai 中将服务器添加为自定义连接器(本地开发时可通过 Cloudflare 隧道)——这将测试真实的 iframe 沙箱, hostContext。参见 https://claude.com/docs/connectors/building/testing。

Claude 主机具体说明

  • hostContext.safeAreaInsets: {top, right, bottom, left} (px) — 请严格遵守这些规格以适配刻度线和编辑器叠加层。
  • 目录提交需要 OAuth 或无认证(none)——静态 Bearer 凭证仅限私有部署且会阻止列表显示——此外还需提供工具 annotations 及3–5张PNG截图;详见 references/directory-checklist.md.

何时应使用小部件而非纯文本

不要为了添加而添加 UI —— 大多数工具仅返回文本或 JSON 即可。当满足以下任一条件时,请添加小部件:

若均不适用,请跳过小部件。文本不仅开发更快,用户使用起来也更快捷。


控件与信息提取——正确选择路径

在构建控件之前,请检查信息提取功能是否已涵盖该需求。信息提取是规范原生的,无需编写任何 UI 代码,可在任何符合规范的主机上运行。

如果信息提取功能已涵盖该需求,请直接使用。参见 ../build-mcp-server/references/elicitation.md.


架构:两种部署模式

远程 MCP 应用(最常见)

托管的可流式传输 HTTP 服务器。小部件模板作为资源提供;工具结果引用这些资源。主机获取资源,在 iframe 沙箱中渲染,并在小部件与 Claude 之间传递消息。

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

MCPB打包的MCP应用(本地 + UI)

采用相同的控件机制,但服务器在 MCPB 包内本地运行。当控件需要驱动本地应用程序时请使用此模式——例如,浏览实际本地磁盘的文件选择器,或控制桌面应用程序的对话框。

关于 MCPB 打包机制,请参考 build-mcpb 技能文档。以下内容均适用于这两种形式。


小部件如何与工具关联

支持小部件的工具具有两个独立的注册项:

  1. 该工具通过 _meta.ui.resourceUri进行声明。其处理程序返回纯文本/JSON——而非 HTML。
  2. 该资源将单独注册并提供 HTML 内容。

当 Claude 调用该工具时,宿主会看到 _meta.ui.resourceUri,获取该资源,在 iframe 中渲染,并通过 ontoolresult 事件将该工具的返回值传递到该iframe中。

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

URI 方案 ui:// 是约定俗成的。MIME类型必须为 RESOURCE_MIME_TYPE ("text/html;profile=mcp-app")——这样主机才知道将其渲染为交互式iframe,而不是仅仅显示源代码。


控件运行时 —— App 类

在 iframe 内部,您的脚本通过 App 类与主机进行通信。 @modelcontextprotocol/ext-apps。这是一条持久的双向连接——只要通信处于活动状态,小部件就会保持运行,既接收新的工具结果,也发送用户操作。

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

该 /*__EXT_APPS_BUNDLE__*/ 占位符会在启动时由服务器替换为 @modelcontextprotocol/ext-apps/app-with-deps ——请参阅 references/iframe-sandbox.md 了解为何需要这样做以及重写代码片段。请勿 import { App } from "https://esm.sh/...";因为 iframe 的 CSP 会阻止传递性依赖项的加载,导致小部件渲染为空白。

sendMessage 是典型的“用户选择了某项内容,通知 Claude”路径。 updateModelContext 用于存储 Claude 应知晓但不应在聊天中显示的状态信息。 openLink 是任何外链导航所必需的 — window.open 而 被 sandbox 属性阻止。

小部件无法执行以下操作:

  • 访问主页面的 DOM、Cookie 或存储
  • 向任意源发起网络请求(受 CSP 限制——需通过 callServerTool)
  • 打开弹出窗口或直接导航——请使用 app.openLink({url})
  • 可靠地加载远程图片——请以内联 data: URL 形式在服务器端加载

保持组件小巧且单一功能。选择器用于选择,图表用于显示。不要在 iframe 内构建整个子应用——将其拆分为多个具有专注功能的组件。


框架:简易选择器组件

安装:

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

服务器端(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);

对于仅限本地运行的小部件应用(驱动桌面应用、读取本地文件),请将传输协议切换为 StdioServerTransport ,并通过 build-mcpb skill 进行打包。

小部件(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>

请参阅 references/widget-templates.md 以获取更多小部件样式。


省去重写麻烦的设计要点

每个工具对应一个控件。请克制构建一个包罗万象的“超级控件”的冲动。一个工具 → 一个专注的控件 → 一个清晰的结果形状。Claude 对此的推理能力要强得多。

工具描述中必须提及该小部件。Claude在决定调用什么时,只能看到工具描述。描述中“打开一个交互式选择器”这样的表述,会让Claude直接调用该工具,而不是去猜测ID。

小部件在运行时是可选的。不支持该应用界面的宿主环境会直接忽略 _meta.ui 并照常渲染该工具的文本内容。由于您的工具处理程序已返回有意义的文本/JSON(即控件的数据),降级处理将自动完成——Claude会直接获取数据,而非通过控件间接获取。

对于只读工具,请勿因小部件结果而阻塞。仅用于显示数据(图表、预览)的小部件不应需要用户操作才能完成。请在同一结果中同时返回显示小部件和文本摘要,以便 Claude 无需等待即可继续推理。

根据项目数量而非工具数量进行布局分支。如果一个用例是“详细显示一个结果”,另一个是“并排显示多个结果”,不要创建两个工具——而应创建一个接受 items[],并让小部件自行选择布局: items.length === 1 → 详细视图, > 1 → 轮播视图。这样既能保持服务器架构的简洁,又能让 Claude 自然地决定项目数量。

将 Claude 的推理过程放入有效载荷中。在每个项目上添加一个简短的 note 字段(说明Claude为何选择该项),以卡片上的说明框形式呈现,可让用户在选择时同步了解推理过程。在工具描述中提及该字段,以便Claude自动填充其内容。

在服务器端规范化图像形状。如果数据源返回的图像宽高比差异极大,请在获取内联数据 URL 之前将其重写为可预测的变体(例如正方形裁剪)。然后为小部件的图像容器设置一个固定的 aspect-ratio + object-fit: contain ,确保所有内容居中显示。

遵循主机主题。 app.getHostContext()?.theme (在 connect())外,还应 app.onhostcontextchanged 以实现实时更新。切换一个 .dark 类 ,将颜色保存在CSS自定义属性中,并使用 :root.dark {} 覆盖块中,将 color-scheme。禁用 mix-blend-mode: multiply 在深色模式下——这会导致图片消失。


测试

Claude Desktop — 当前版本仍需 command/args 配置形状(无原生 "type": "http")。用 mcp-remote 并强制 http-only 传输,以防止 SSE 探测器吞噬小部件功能协商:

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

Desktop 会积极缓存 UI 资源。编辑控件 HTML 后,请完全退出(⌘Q / Alt+F4,而非关闭窗口),然后重新启动以强制进行冷资源重新加载。

无头 JSON-RPC 循环——无需通过桌面点击即可快速迭代:

# 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

该 sleep 会将标准输入(stdin)保持打开状态足够长的时间,以收集所有响应。使用 jq 或 Python 一行代码解析 jsonl 输出。

控件开发循环——通过在普通 GET 路由上提供内联控件 HTML,并使用一个伪 ExtApps shim,该shim会根据 ontoolresult :

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

在 http://localhost:3000/widget-preview?payload={"rows":[...]} 在普通浏览器标签页中打开,并使用常规开发工具进行调试。

主机回退 — 使用不具备应用界面(或 MCP Inspector)的主机,并确认该工具的文本内容能平滑降级。

CSP 调试 — 打开 iframe 自身的开发者工具控制台。CSP 违规是小部件无声失败(显示空白矩形,主控制台无错误提示)的首要原因。参见 references/iframe-sandbox.md.


参考文件

  • references/iframe-sandbox.md ——CSP/沙箱限制、打包内联模式、图像处理、主机主题设置
  • references/widget-templates.md — 用于选择器/确认框/进度条/显示的可复用 HTML 框架
  • references/apps-sdk-messages.md — App 类 API:控件 ↔ 宿主 ↔ 服务器消息传递、生命周期及替换机制
  • references/payload-budgeting.md — 宿主工具结果大小限制、先修剪后截断、通过 callServerTool
  • references/abuse-protection.md — Anthropic 出站 CIDR 范围、分级速率限制、 trust proxy、响应缓存
  • references/directory-checklist.md — 连接器目录提交的预检
在 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

安装 build-mcp-app

下载技能文件并将其解压到 .claude/skills/ 目录中。

下载ZIP

克隆仓库并复制技能文件到您的项目中。

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/

复制 复制
快速设置: 将技能文件夹复制到 .claude/skills/ 目录下,Claude 会自动检测并使用该技能

相关技能

multica-creating-agents
更新时间 2026-08-12
tilemaps
更新时间 2026-08-04
v4-new-features
更新时间 2026-08-04
pixijs-application
更新时间 2026-08-04
OR