关于 build-mcp-app
面向开发者的技能,用于构建带交互式 UI 组件的 MCP 服务,适用于支持 apps surface 的宿主。内容涵盖组件注册、iframe 通信、部署方式和测试,并说明何时应采用纯文本或 elicitation。
主要功能
- 01注册工具和 UI 资源
- 02构建基于 iframe 的交互组件
- 03连接组件、宿主与服务端工具
- 04选择远程或 MCPB 部署
- 05在 Claude 和无头 JSON-RPC 环境中测试
适用场景
- 01为大型列表创建可搜索选择器
- 02展示图表、地图、差异或预览
- 03通过组件收集结构化输入
- 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.mdapps-sdk-messages.mddirectory-checklist.mdiframe-sandbox.mdpayload-budgeting.mdwidget-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 技能。以下所有内容适用于两种形态。
小组件如何挂接到工具
一个支持小组件的工具有 两次独立的注册:
- 工具通过
_meta.ui.resourceUri声明一个 UI 资源。它的处理函数返回纯文本/JSON —— 而不是 HTML。 - 资源单独注册,并提供 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— 连接器目录提交前的预检