- AI 插件
- 开发工具
- 插件系统
【免费下载链接】claude-plugins-official
Official, Anthropic-managed directory of high quality Claude Code Plugins.
导读
本文以 claude-plugins-official 仓库中mcp-server-dev插件的 build-mcp-app SKILL.md 为绝对主体,系统讲解如何把标准 MCP 服务器升级为「MCP App」——即在聊天界面内直接渲染表单(Form)、选择器(Picker)、确认对话框(Confirm)、图表预览(Display)与实时进度(Progress)等交互式 UI Widget。读完本文,你将掌握 Widget 与工具的双重注册机制、App类双向消息 API、两种部署形态(远程 streamable-HTTP 与 MCPB 本地打包)、iframe 沙箱与 CSP 约束下的 bundle 内联方案,以及一套完整的测试与调试流程,可直接照抄出第一个可运行的 MCP App。
一、MCP App 是什么:标准 MCP Server + 可选的聊天内 UI 层
本文默认你已了解普通 MCP 服务器的基础构建方式。若尚未掌握,请先阅读 build-mcp-server SKILL.md——它负责侦察用例、选定部署模型(远程 HTTP / MCPB / 本地 stdio)与工具设计模式,是 MCP App 的前置入口。整个
mcp-server-dev插件的分工见 插件 README。
MCP App 的定义非常克制:它就是一个标准的 MCP 服务器,只是额外对外提供 UI 资源(UI resources)。这些资源是渲染在聊天界面内联区域的交互式组件,形态包括表单、选择器、仪表盘、确认对话框等。
关键性质有三点:
- UI 层是附加的(additive)。底层仍然是工具(tools)、资源(resources)和同一套 wire protocol,Widget 只是在这之上叠加的"第 2 层"。这意味着不破坏既有 MCP 语义。
- 构建一次,多处运行。同一个服务器既能在 Claude 中运行,也能在 ChatGPT 以及任何实现了 apps surface 的宿主中运行。
- 降级是自动的。不支持 apps surface 的宿主会直接忽略
_meta.ui,照常渲染工具返回的纯文本内容——由于工具 handler 本来就返回有意义的文本/JSON(即 Widget 的数据),降级无需任何额外代码。
在 claude.ai 中测试时,可将服务器作为自定义 connector 添加(本地开发走 Cloudflare 隧道),这能真实演练 iframe 沙箱与hostContext行为;详见官方 connectors 测试文档。若直接以本地 stdio + MCPB 打包运行,则不存在隧道这一环,但沙箱语义完全相同。
二、Claude 宿主下的_meta.ui.*元数据
Widget 与宿主之间的"挂钩"全部通过工具/资源声明里的_meta.ui.*元数据完成。下表来自 SKILL.md 原文,逐行注释了作用位置与效果:
_meta.ui.*键 | 挂载位置 | 作用 |
|---|---|---|
resourceUri | tool | 指定该工具结果由宿主渲染哪个ui://资源 |
visibility: ["app"] | tool | 把仅供 Widget 内部调用的辅助工具(例如经callServerTool调用的几何/图片抓取器)从 Claude 的工具列表中隐藏 |
prefersBorder: false | resource | 去掉宿主外层卡片边框(移动端更贴合) |
csp.{connectDomains, resourceDomains, baseUriDomains} | resource | 声明允许访问的外部源;默认策略是全部阻止(block-all)。注意 Claude 中frameDomains目前仍受限 |
两个补充要点:
hostContext.safeAreaInsets: {top, right, bottom, left}(单位 px)——Widget 必须遵守它,为刘海屏缺口和 composer 浮层让出空间(详见下文 App 类章节)。- 提交到 connector 目录要求使用 OAuth(DCR 或 CIMD)或authless(
none)两种认证之一;静态 bearer token 仅限私有部署,且会阻止目录收录。此外还需提供工具annotations和 3~5 张 PNG 截图,完整门槛见 references/directory-checklist.md。
三、何时需要 Widget:用信号驱动,而非"为了 UI 而 UI"
Skill 开篇就给出了清醒的提醒:大多数工具返回文本或 JSON 就够了,Widget 不是必需品。只有命中以下任一信号,才值得投入 Widget 开发:
| 信号 | 对应 Widget 类型 |
|---|---|
| 工具需要结构化输入,而 Claude 无法可靠推断 | 表单(Form) |
| 用户必须从 Claude 无法排序的列表中挑选(文件、联系人、记录) | 选择器 / 表格(Picker / table) |
| 破坏性或计费动作需要显式确认 | 确认对话框(Confirm dialog) |
| 输出是空间性或视觉性的(图表、地图、diff、预览) | 展示型 Widget(Display widget) |
| 长时运行任务,用户想持续围观 | 进度 / 实时状态(Progress / live status) |
若以上信号全不命中,就不要加 Widget——纯文本构建更快,对用户也更快。
四、Widget 还是 Elicitation:先走规范原生路线
在动手写任何 HTML 之前,先检查elicitation(引导式输入)是否已经覆盖需求。Elicitation 是 MCP 协议规范原生能力:服务器在工具调用中途暂停,宿主渲染一个原生表单(无 iframe、无 HTML),用户填写后服务器继续执行。它零 UI 代码、任何合规宿主都支持。
| 需求 | Elicitation | Widget |
|---|---|---|
| 确认是 / 否 | ✅ | 杀鸡用牛刀 |
| 从短枚举中挑选 | ✅ | 杀鸡用牛刀 |
| 填写扁平表单(姓名、邮箱、日期) | ✅ | 杀鸡用牛刀 |
| 从大列表 / 可搜索列表中挑选 | ❌(无滚动 / 无搜索) | ✅ |
| 选择前的视觉预览 | ❌ | ✅ |
| 图表 / 地图 / diff 视图 | ❌ | ✅ |
| 实时更新的进度 | ❌ | ✅ |
Elicitation 能覆盖就用它,完整用法与能力回退模式(注意CapabilityNotSupported异常与优雅降级)见 build-mcp-server/references/elicitation.md。
五、两种部署形态的架构
远程 MCP App(最常见)
托管在远程的 streamable-HTTP 服务器。Widget 模板以资源形式提供,工具结果引用它们;宿主拉取资源后放入 iframe 沙箱渲染,并在 Widget 与 Claude 之间做消息代理:
┌──────────┐ tools/call ┌────────────┐ │ Claude │─────────────> │ MCP server │ │ host │<── result ────│ (remote) │ │ │ + widget ref │ │ │ │ │ │ │ │ resources/read│ │ │ │─────────────> │ widget │ │ ┌──────┐ │<── template ──│ HTML/JS │ │ │iframe│ │ └────────────┘ │ │widget│ │ │ └──────┘ │ └──────────┘MCPB 打包的 MCP App(本地 + UI)
Widget 机制完全相同,但服务器运行在 MCPB bundle 内部(本地)。适用于 Widget 需要驱动本地应用的场景——例如浏览真实本地磁盘的文件选择器、控制桌面应用的对话框。MCPB 打包机制交给build-mcpb技能处理(见 build-mcpb SKILL.md),其余 Widget 开发内容对两种形态全部适用。
六、Widget 如何挂载到工具:双重注册机制
一个启用了 Widget 的工具包含两次独立注册:
- 工具(tool)通过
_meta.ui.resourceUri声明要展示哪个 UI 资源;它的 handler 只返回普通文本/JSON——不是 HTML。 - 资源(resource)单独注册,负责对外提供 HTML。
当 Claude 调用该工具时,宿主看到_meta.ui.resourceUri,去拉取对应资源,放进 iframe 渲染,并通过ontoolresult事件把工具返回值灌进 iframe。完整示例(来自 SKILL.md):
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 }], }), );两个必须遵守的约定:
ui://只是约定俗成的 URI 方案,宿主靠它识别资源归属。- MIME 类型必须是
RESOURCE_MIME_TYPE,即"text/html;profile=mcp-app"——这是宿主判断"该渲染为交互式 iframe 而不是直接展示源码"的唯一依据,写错就会退化成纯文本展示。
七、Widget 运行时:App类的双向消息 API
iframe 内的脚本通过@modelcontextprotocol/ext-apps提供的App类与宿主通信。这是一条持久双向连接:只要会话存活,Widget 就一直在线,既能持续接收新的工具结果,也能向会话注入用户动作。
<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 会拦截传递依赖的拉取,最终 Widget 渲染成一片空白。
App类完整 API(方向 / 用途,来自 SKILL.md):
| 方法 | 方向 | 用途 |
|---|---|---|
app.ontoolresult = fn | 宿主 → Widget | 接收工具的返回值 |
app.ontoolinput = fn | 宿主 → Widget | 接收工具入参(Claude 传了什么) |
app.sendMessage({...}) | Widget → 宿主 | 向会话注入一条消息 |
app.updateModelContext({...}) | Widget → 宿主 | 静默更新上下文(不产生可见消息) |
app.callServerTool({name, arguments}) | Widget → 服务器 | 调用你服务器上的另一个工具 |
app.openLink({url}) | Widget → 宿主 | 在新标签打开 URL(沙箱禁掉window.open) |
app.getHostContext()/app.onhostcontextchanged | 宿主 → Widget | 主题、宿主 CSS 变量、containerDimensions、displayMode、deviceCapabilities |
app.requestDisplayMode({mode}) | Widget → 宿主 | 请求inline/pip/fullscreen |
app.downloadFile({name, mimeType, content}) | Widget → 宿主 | 宿主代管下载(content 为 base64) |
new App(info, caps, {autoResize: true}) | — | iframe 高度跟随渲染内容自适应 |
用法要点:
sendMessage是典型的"用户选好了,告诉 Claude"路径,必须用role: "user"——Widget 是代表用户发声。updateModelContext用于那些 Claude 该知道、但不该刷屏聊天区(例如"正在查看近 30 天订单")的状态。openLink是所有外跳流量的唯一出口——window.open和<a target="_blank">都被 sandbox 属性拦截,必须e.preventDefault()后改走app.openLink。ontoolresult必须在await app.connect()之前赋值,否则结果可能在连接后立刻到达而丢失;ontoolinput同理,还可用ontoolinputpartial在参数流式输入时展示骨架屏、用ontoolcancelled清理骨架。- 长期运行的任务,服务器侧通过
extra._meta?.progressToken+extra.sendNotification({ method: "notifications/progress", ... })推送进度,Widget 侧在ontoolresult里解析进度字段更新进度条(详见 references/apps-sdk-messages.md)。
Widget 不能做的事(沙箱硬约束):
- 访问宿主页面的 DOM、cookie 或 storage;
- 向任意源发起网络请求(CSP 限制——应路由到
callServerTool); - 打开弹窗或直接导航——必须用
app.openLink({url}); - 可靠加载远程图片——应在服务器端内联为
data:URL。
最后一条设计纪律:Widget 要保持小而专一。选择器就只管选择,图表就只管展示。不要在 iframe 里造一个完整的子应用——拆成多个带独立小 Widget 的工具。
八、最小可运行脚手架:一个联系人选择器
安装依赖
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);本地专用(驱动桌面应用、读本地文件)的 Widget App:把传输层换成
StdioServerTransport,再按build-mcpb技能打包即可。
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>更多 Widget 形态(确认对话框、进度条、展示型图表、横向轮播等)的可复用 HTML 骨架,见 references/widget-templates.md。所有模板刻意不引框架——Widget 足够小,React/Vue 的 hydration 成本通常不值。
九、iframe 沙箱与 CSP:最容易翻车的约束区
Widget 运行在宿主的沙箱<iframe>中,同时受 HTMLsandbox属性与严格 CSP 双重限制。实际问题几乎都表现为"静默空白矩形"——错误只出现在 iframe 自己的 devtools 控制台,宿主主控制台一声不吭。以下是 references/iframe-sandbox.md 记录的"踩坑 → 修复"对照表:
| 症状 | 根因 | 修复 |
|---|---|---|
| Widget 渲染为空白矩形、无报错 | CSPscript-src拦截了 esm.sh 拉取@modelcontextprotocol/sdk传递依赖 | 把ext-apps/app-with-depsbundle 内联进 HTML |
window.open()无反应 | 沙箱缺allow-popups | 改用app.openLink({ url }) |
<a target="_blank">无反应 | 同上 | 点击时e.preventDefault()+app.openLink({ url }) |
外部<img src>破图 | CSPimg-src+ referrer 防盗链 | 服务器端抓取,以data:URL 放进工具结果 payload |
| 服务器重启后 Widget 修改不生效 | 宿主缓存 UI 资源 | 彻底退出宿主(⌘Q / Alt+F4)后重启 |
顶层await抛错 | 较旧的 iframe 上下文 | 把模块主体包进 async IIFE |
bundle 内联的完整模式
@modelcontextprotocol/ext-apps在app-with-deps导出处提供了一个自包含的浏览器构建(约 300KB),是压缩过的 ESM,以export{…}结尾。要把它用进内联<script type="module">,需在构建期把导出语句重写为全局赋值:
import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const bundle = readFileSync( require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8", ).replace(/export\{([^}]+)\};?\s*$/, (_, body) => "globalThis.ExtApps={" + body.split(",").map((pair) => { const [local, exported] = pair.split(" as ").map((s) => s.trim()); return `${exported ?? local}:${local}`; }).join(",") + "};", ); const widgetHtml = readFileSync("./widgets/widget.html", "utf8") .replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);注意.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle)必须用函数形式做替换——String.replace会把字符串替换里的$…序列当特殊占位符解析,而压缩后的 bundle 里满是$字符。bundle 每个服务器启动只内联一次,全部 Widget 模板复用同一份字符串。
其余沙箱细节速查
- 外链:
window.open/<a target="_blank">一律被拦,锚点点击需e.preventDefault()后app.openLink。 - 外部图片:CSP
img-src默认值加 CDN referrer 策略双重拦截,服务器端在工具 handler 里fetch后转data:URL 内联(建议AbortSignal.timeout(5000)兜底);残留 URL 给<img>加referrerpolicy="no-referrer"。 - 主题跟随:
<meta name="color-scheme" content="light dark">+ 透明背景 + 宿主 CSS token。applyHostStyleVariables会把宿主的--color-*/--font-*/--border-radius-*写到:root;用:root.dark {}覆盖块切换深色,注意深色下要禁用mix-blend-mode: multiply(否则图片"消失")。 - 调试入口:Claude Desktop 中 View → Toggle Developer Tools,把 Console 页左上角上下文下拉从 "top" 切到 Widget 的 iframe——CSP 违规、未捕获异常、import 错误全部只出现在那里。
十、避免返工的设计要点
SKILL.md 给出了一组实战沉淀的设计原则,直接决定项目成败:
- 一个工具一个 Widget。抵制造"万能大 Widget"的冲动:一个工具 → 一个聚焦的 Widget → 一个清晰的结果形态。Claude 对这类结构推理得远好。
- 工具描述必须提到 Widget。Claude 决策时只看工具描述——"Opens an interactive picker" 这类措辞才会让它选择调用该工具,而不是去猜一个 ID。
- Widget 运行时可缺省。不支持 apps surface 的宿主会忽略
_meta.ui、正常渲染文本内容。因为 handler 本来就返回有意义的数据文本,降级是自动的。 - 只读工具不要阻塞在 Widget 结果上。纯展示型 Widget(图表、预览)不该要求用户动作才算完成——同一结果里同时返回展示 Widget和文本摘要,Claude 不用等用户就能继续推理。
- 按条目数分叉布局,不按工具数。"单条详情"和"多条并排"是同一个用例:做一个接受
items[]的工具,让 Widget 自行选择布局——items.length === 1显示详情视图,> 1显示轮播。保持服务器 schema 简单,数量交给 Claude 自然决定。 - 把 Claude 的推理放进 payload。每个条目加一个简短的
note字段(Claude 为什么选它),在卡片上渲染成 callout,让推理与选择同屏可见;并在工具描述里提这个字段,Claude 才会填充它。 - 服务器端统一图片形状。数据源图片宽高比参差时,在抓取做
data:URL 之前先改写为可预测变体(如方形约束);Widget 端图片容器用固定aspect-ratio+object-fit: contain,一切居中。 - 跟随宿主主题。
connect()后读app.getHostContext()?.theme,用app.onhostcontextchanged做实时更新:给<html>切.dark类、颜色放 CSS 自定义属性配:root.dark {}覆盖块、设置color-scheme。
十一、测试与调试:四条互补路径
1. Claude Desktop:mcp-remote+ http-only
当前 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,不是关窗口)再重启,才能强制冷拉取资源。
2. Headless 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-httpsleep让 stdin 保持打开足够久以收齐所有响应;用jq或 Python 单行解析 jsonl 输出。
3. Widget 开发循环:GET 路由 +ExtApps假 shim
完全绕开 ⌘Q-重启循环:把内联了 Widget HTML 的页面挂到普通 GET 路由,注入一个假ExtAppsshim,从 query param 触发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 迭代。
4. 宿主流退 + CSP 调试
- 宿主流退:用一个没有 apps surface 的宿主(或 MCP Inspector)确认工具文本内容能优雅降级。
- CSP 调试:打开 iframe 自己的 devtools 控制台。CSP 违规是 Widget 静默失败的头号原因(空白矩形、主控制台无报错),排查入口见 references/iframe-sandbox.md。
十二、Reference 文件体系:进阶内容的入口
SKILL.md 末尾列出的参考资料,每一份都可继续深挖:
| 参考文件 | 主题 |
|---|---|
| references/iframe-sandbox.md | CSP/sandbox 约束、bundle 内联模式、图片处理、宿主主题 |
| references/widget-templates.md | picker / confirm / progress / display 可复用 HTML 骨架 |
| references/apps-sdk-messages.md | App类 API:Widget ↔ 宿主 ↔ 服务器消息、生命周期与 supersession |
| references/payload-budgeting.md | 宿主工具结果大小上限、先剪列再截行的降级策略、重资源走callServerTool |
| references/abuse-protection.md | Anthropic egress CIDR、分级令牌桶限流、trust proxy正确配置、上游响应缓存 |
| references/directory-checklist.md | 提交 connector 目录前的硬性审核清单 |
几个值得提前知道的进阶事实(均来自上述参考文件,可作为踩坑预警):
- Payload 预算:claude.ai 与 Claude Desktop 会把工具结果截断在约150,000 字符,Claude Code 约 25k token。超限时宿主会用一段"文件指针"字符串替换你的 JSON,Widget 侧
JSON.parse直接抛错,且完全看不出是大小问题。应对策略:自限 ~130KB,先整行输出、超限则按渲染规范剪列(注意calculate变换别名下源列只以datum.X出现,误删会让 Widget 得到 NaN)、最后截行并带上{ truncated: N }标注。几何数据、图片字节等"Widget 需要但 Claude 不需要"的重资产,由 Widget 挂载后经callServerTool单独拉取,辅助工具记得标_meta.ui.visibility: ["app"]。 - Authless 滥用防护:无认证的 streamable-HTTP 服务器面向全网开放。claude.ai 流量经 Anthropic egress 代理,所有 Web 用户来自同一小段 IP(IPv4
160.79.104.0/21、IPv62607:6bc0::/48);Desktop / Code 则直连、有独立用户 IP。建议按"Anthropic 共享池 + 每 IP"做分级令牌桶(如anthropic: 600 容量/100 每秒,other: 30/2),trust proxy必须精确等于可信跳数、生产环境永远不要设true(否则客户端可伪造X-Forwarded-For冒充 Anthropic 池)。CIDR 用于分级限流而非硬性封锁,否则会把 Desktop / Code 一起锁死。 - Widget 生命周期:Claude 每次调用带
_meta.ui.resourceUri的工具,宿主都会挂载一个全新的 iframe;旧实例一直留在会话记录里,同一工具的再次调用会在旁边再挂一个。因此没有"提交并关闭"一说,且旧 Widget 的点击可能在新 Widget 渲染后继续sendMessage——用BroadcastChannel广播序号(Date.now() + Math.random()),让旧实例自废(superseded标记 + 半透明 +pointer-events: none),这是 supersession 的标准解法。
结语
MCP App 的核心理念可以浓缩为一句话:UI 是附加层,底层永远是标准 MCP。从_meta.ui.resourceUri的双重注册,到App类的持久双向消息,再到沙箱 CSP 下的 bundle 内联,全部技巧都围绕"在不破坏协议的前提下把交互搬进聊天"。按本文的决策表先判断是否需要 Widget、再按脚手架落地、最后用四条测试路径收尾,你就能产出一个在 Claude 与 ChatGPT 里一致运行的 MCP App。若需要本地驱动桌面/文件系统的版本,直接切StdioServerTransport并按build-mcpb技能打包即可。
- AI 插件
- 开发工具
- 插件系统
【免费下载链接】claude-plugins-official
Official, Anthropic-managed directory of high quality Claude Code Plugins.
相关推荐
Camunda 测试利器:用 `ProcessEngineLoggingRule` 在 JUnit 中捕获与断言流程引擎日志
Camunda 测试利器:用 ProcessEngineLoggingRule 在 JUnit 中捕获与断言流程引擎日志 本指南围绕 Camunda 7 平台
AI 插件开发工具插件系统用 mcp-use 开发 MCP Apps:基于 create-mcp-use-app 的 MCP Apps 模板实战指南
用 mcp use 开发 MCP Apps:基于 create mcp use app 的 MCP Apps 模板实战指南 本篇指南以 create mcp u
后端MCP 服务MCP ClientsAI Agent人工智能LikeC4 MCP 全屏渲染:基于 MCP App 显示模式的交互式架构图增强
LikeC4 MCP 全屏渲染:基于 MCP App 显示模式的交互式架构图增强 导读 本文围绕 LikeC4 仓库中的变更记录 .changeset/mcp
开发工具数据可视化CLI前端MCP 服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考