mcpField
B
build-mcp-app

build-mcp-app

github.com/anthropics开发45.1k6.6k 次安装1 天前更新
代码仓库

关于 build-mcp-app

面向开发者的技能,用于构建带交互式 UI 组件的 MCP 服务,适用于支持 apps surface 的宿主。内容涵盖组件注册、iframe 通信、部署方式和测试,并说明何时应采用纯文本或 elicitation。

主要功能

  1. 01注册工具和 UI 资源
  2. 02构建基于 iframe 的交互组件
  3. 03连接组件、宿主与服务端工具
  4. 04选择远程或 MCPB 部署
  5. 05在 Claude 和无头 JSON-RPC 环境中测试

适用场景

  1. 01为大型列表创建可搜索选择器
  2. 02展示图表、地图、差异或预览
  3. 03通过组件收集结构化输入
  4. 04显示长任务的进度

MCP 相关

指导构建向兼容宿主提供工具和 UI 资源的 MCP 服务。组件可与宿主通信,也可调用其他服务端工具。

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/anthropics/claude-plugins-official --skill build-mcp-app

安装后在 Claude Code 中输入/build-mcp-app确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
abuse-protection.md
apps-sdk-messages.md
directory-checklist.md
iframe-sandbox.md
payload-budgeting.md
widget-templates.md

详细介绍

构建 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 宿主特定事项

_meta.ui.* 键位置作用
resourceUri工具宿主为该工具的结果渲染哪个 ui:// 资源。
visibility: ["app"]工具从 Claude 的工具列表中隐藏仅用于组件的辅助工具(例如通过 callServerTool 调用的几何/图像获取工具)。
prefersBorder: false资源去掉宿主的外层卡片边框(移动端)。
csp.{connectDomains, resourceDomains, baseUriDomains}资源声明外部来源;默认全部阻止。frameDomains 目前在 Claude 中受限。
  • hostContext.safeAreaInsets: {top, right, bottom, left}(像素)——为刘海屏和编辑器覆盖层遵循这些值。
  • 目录提交要求 OAuth 或无认证(none)——静态 bearer 仅用于私有部署且会阻止上架——此外还需要工具 annotations 和 3–5 张 PNG 截图;参见 references/directory-checklist.md。

何时组件优于纯文本

不要为了 UI 而添加 UI——大多数工具返回文本或 JSON 就足够了。当满足以下任一条件时添加组件:

信号组件类型
工具需要 Claude 无法可靠推断的结构化输入表单
用户必须从 Claude 无法排序的列表中选择(文件、联系人、记录)选择器 / 表格
破坏性或计费操作需要明确确认确认对话框
输出是空间性或视觉性的(图表、地图、差异、预览)展示组件
用户想观看长期运行的任务进度 / 实时状态

如果都不符合,就跳过组件。文本构建更快,对用户也更快。


组件 vs 引导——正确选择路径

在构建组件之前,检查引导是否能覆盖它。引导是规范原生的,零 UI 代码,在任何合规宿主中都能工作。

需求引导组件
确认是/否✅大材小用
从短枚举中选择✅大材小用
填写扁平表单(姓名、邮箱、日期)✅大材小用
从大型/可搜索列表中选择❌(无滚动/搜索)✅
选择前的视觉预览❌✅
图表 / 地图 / 差异视图❌✅
实时更新的进度❌✅

如果引导能覆盖它,就用引导。参见 ../build-mcp-server/references/elicitation.md。


架构:两种部署形态

远程 MCP 应用(最常见)

托管的 streamable-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 声明一个 UI 资源。它的处理函数返回纯文本/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 内部,你的脚本通过来自 @modelcontextprotocol/ext-apps 的 App 类与宿主通信。这是一条 持久的双向连接 —— 只要对话处于活跃状态,小组件就一直存活,接收新的工具结果并发送用户操作。

<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 会阻止传递依赖的获取,导致小组件渲染为空白。

方法方向用途
app.ontoolresult = fn宿主 → 小组件接收工具的返回值
app.ontoolinput = fn宿主 → 小组件接收工具的输入参数(Claude 传入的内容)
app.sendMessage({...})小组件 → 宿主向对话中注入一条消息
app.updateModelContext({...})小组件 → 宿主静默更新上下文(无可见消息)
app.callServerTool({name, arguments})小组件 → 服务调用你服务器上的另一个工具
app.openLink({url})小组件 → 宿主在新标签页中打开 URL(沙箱会阻止 window.open)
app.getHostContext() / app.onhostcontextchanged宿主 → 小组件主题、宿主 CSS 变量、containerDimensions、displayMode、deviceCapabilities
app.requestDisplayMode({mode})小组件 → 宿主请求 inline / pip / fullscreen
app.downloadFile({name, mimeType, content})小组件 → 宿主由宿主中介的下载(base64 内容)
new App(info, caps, {autoResize: true})—iframe 高度跟随渲染内容

sendMessage 是典型的“用户选了某个东西,告诉 Claude”路径。updateModelContext 用于 Claude 应当知道但不该弄乱聊天的状态。对于任何向外导航,openLink 都是 必需的 —— window.open 和 <a target="_blank"> 会被 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 技能打包。

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

更多小组件形态请参见 references/widget-templates.md。


能帮你省下一次重写的设计要点

每个工具对应一个 widget。 不要忍不住去构建一个包揽一切的大型 widget。一个工具 → 一个专注的 widget → 一种清晰的结果结构。Claude 对这类设计的推理效果好得多。

工具描述必须提到该 widget。 Claude 在决定调用什么时,只能看到工具描述。描述里写上一句“打开一个交互式选择器”,才会让 Claude 去用它,而不是靠猜 ID。

widget 在运行时是可选的。 不支持 apps 界面的宿主会直接忽略 _meta.ui,正常渲染工具的文字内容。由于你的工具处理程序本来就会返回有意义的文本/JSON(即 widget 的数据),降级会自动发生 —— Claude 直接看到数据,而不是通过 widget 看到。

只读工具不要因 widget 结果而阻塞。 一个只用来展示数据的 widget(图表、预览)不应该要求用户操作才能完成。在同一个结果里同时返回展示 widget 和文字摘要,这样 Claude 无需等待就能继续推理。

按条目数量而非工具数量拆分布局。 如果一个用例是“详细展示一条结果”,另一个是“并排展示多条结果”,不要做成两个工具 —— 做成一个接受 items[] 的工具,让 widget 来选择布局:items.length === 1 → 详情视图,> 1 → 轮播视图。这样服务端 schema 保持简单,也让 Claude 自然地决定数量。

把 Claude 的推理放进载荷里。 在每个条目上放一个简短的 note 字段(说明 Claude 为什么选它),并作为卡片上的标注渲染出来,用户就能在选择旁边直接看到理由。在工具描述中提到这个字段,这样 Claude 才会填充它。

在服务端统一图片形状。 如果你的数据源返回的图片宽高比差异很大,就在抓取数据 URL 内联内容之前,先重写为可预测的变体(例如限制为正方形)。然后给 widget 的图片容器设置固定的 aspect-ratio + object-fit: contain,让所有内容都居中显示。

跟随宿主主题。 app.getHostContext()?.theme(在 connect() 之后)加上 app.onhostcontextchanged 实现实时更新。在 <html> 上切换 .dark 类,把颜色保存在 CSS 自定义属性里,并用 :root.dark {} 覆盖块,设置 color-scheme。在深色模式下禁用 mix-blend-mode: multiply —— 它会让图片消失。


测试

Claude Desktop —— 当前版本仍然要求 command/args 形式的配置(不支持原生的 "type": "http")。用 mcp-remote 包一层,并强制使用 http-only 传输,以免 SSE 探测吞掉 widget 能力协商:

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

Desktop 会激进地缓存 UI 资源。编辑 widget HTML 之后,要完全退出(⌘Q / Alt+F4,不是关闭窗口)再重新启动,以强制进行冷启动的资源重新抓取。

无头 JSON-RPC 循环 —— 无需在 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

这个 sleep 让 stdin 保持打开足够长的时间,以收集所有响应。用 jq 或一行 Python 解析 jsonl 输出。

widget 开发循环 —— 完全避开 ⌘Q 重启的循环:用一个普通 GET 路由来提供内联的 widget HTML,并配一个假的 ExtApps 垫片,从查询参数触发 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":[...]},用普通的 devtools 迭代。

宿主回退 —— 使用一个没有 apps 界面的宿主(或 MCP Inspector),确认工具的文字内容能优雅降级。

CSP 调试 —— 打开 iframe 自己的 devtools 控制台。CSP 违规是 widget 静默失败(空白矩形,主控制台无报错)的头号原因。参见 references/iframe-sandbox.md。


参考文件

  • references/iframe-sandbox.md — CSP/sandbox 限制、bundle 内联模式、图片处理、宿主主题定制
  • 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 — 连接器目录提交前的预检

评论

支持 Markdown