ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MCP App 交互式 Widget 开发完全指南:基于 claude-plugins-official 的 build-mcp-app 技能实战

MCP App 交互式 Widget 开发完全指南:基于 claude-plugins-official 的 build-mcp-app 技能实战 AI 插件开发工具插件系统【免费下载链接】claude-plugins-officialOfficial, Anthropic-managed directory of high quality Claude Code Plugins.项目地址https://gitcode.com/GitHub_Trending/cl/claude-plugins-official点击查看免费下载导读本文以 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 protocolWidget 只是在这之上叠加的第 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.*键挂载位置作用resourceUritool指定该工具结果由宿主渲染哪个ui://资源visibility: [app]tool把仅供 Widget 内部调用的辅助工具例如经callServerTool调用的几何/图片抓取器从 Claude 的工具列表中隐藏prefersBorder: falseresource去掉宿主外层卡片边框移动端更贴合csp.{connectDomains, resourceDomains, baseUriDomains}resource声明允许访问的外部源默认策略是全部阻止block-all。注意 Claude 中frameDomains目前仍受限两个补充要点hostContext.safeAreaInsets: {top, right, bottom, left}单位 px——Widget 必须遵守它为刘海屏缺口和 composer 浮层让出空间详见下文 App 类章节。提交到 connector 目录要求使用 OAuthDCR 或 CIMD或authlessnone两种认证之一静态 bearer token 仅限私有部署且会阻止目录收录。此外还需提供工具annotations和 35 张 PNG 截图完整门槛见 references/directory-checklist.md。三、何时需要 Widget用信号驱动而非为了 UI 而 UISkill 开篇就给出了清醒的提醒大多数工具返回文本或 JSON 就够了Widget 不是必需品。只有命中以下任一信号才值得投入 Widget 开发信号对应 Widget 类型工具需要结构化输入而 Claude 无法可靠推断表单Form用户必须从 Claude 无法排序的列表中挑选文件、联系人、记录选择器 / 表格Picker / table破坏性或计费动作需要显式确认确认对话框Confirm dialog输出是空间性或视觉性的图表、地图、diff、预览展示型 WidgetDisplay widget长时运行任务用户想持续围观进度 / 实时状态Progress / live status若以上信号全不命中就不要加 Widget——纯文本构建更快对用户也更快。四、Widget 还是 Elicitation先走规范原生路线在动手写任何 HTML 之前先检查elicitation引导式输入是否已经覆盖需求。Elicitation 是 MCP 协议规范原生能力服务器在工具调用中途暂停宿主渲染一个原生表单无 iframe、无 HTML用户填写后服务器继续执行。它零 UI 代码、任何合规宿主都支持。需求ElicitationWidget确认是 / 否✅杀鸡用牛刀从短枚举中挑选✅杀鸡用牛刀填写扁平表单姓名、邮箱、日期✅杀鸡用牛刀从大列表 / 可搜索列表中挑选❌无滚动 / 无搜索✅选择前的视觉预览❌✅图表 / 地图 / 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本地 UIWidget 机制完全相同但服务器运行在 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.mdimport { 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;profilemcp-app——这是宿主判断该渲染为交互式 iframe 而不是直接展示源码的唯一依据写错就会退化成纯文本展示。七、Widget 运行时App类的双向消息 APIiframe 内的脚本通过modelcontextprotocol/ext-apps提供的App类与宿主通信。这是一条持久双向连接只要会话存活Widget 就一直在线既能持续接收新的工具结果也能向会话注入用户动作。script typemodule /* 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.openapp.getHostContext()/app.onhostcontextchanged宿主 → Widget主题、宿主 CSS 变量、containerDimensions、displayMode、deviceCapabilitiesapp.requestDisplayMode({mode})Widget → 宿主请求inline/pip/fullscreenapp.downloadFile({name, mimeType, content})Widget → 宿主宿主代管下载content 为 base64new 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?.progressTokenextra.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.tsimport { 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 charsetutf-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 idlist/ul script typemodule /*__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}/divdiv classsub${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 内联进 HTMLwindow.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 / AltF4后重启顶层await抛错较旧的 iframe 上下文把模块主体包进 async IIFEbundle 内联的完整模式modelcontextprotocol/ext-apps在app-with-deps导出处提供了一个自包含的浏览器构建约 300KB是压缩过的 ESM以export{…}结尾。要把它用进内联script typemodule需在构建期把导出语句重写为全局赋值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。外部图片CSPimg-src默认值加 CDN referrer 策略双重拦截服务器端在工具 handler 里fetch后转data:URL 内联建议AbortSignal.timeout(5000)兜底残留 URL 给img加referrerpolicyno-referrer。主题跟随meta namecolor-scheme contentlight 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-ratioobject-fit: contain一切居中。跟随宿主主题。connect()后读app.getHostContext()?.theme用app.onhostcontextchanged做实时更新给html切.dark类、颜色放 CSS 自定义属性配:root.dark {}覆盖块、设置color-scheme。十一、测试与调试四条互补路径1. Claude Desktopmcp-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 / AltF4不是关窗口再重启才能强制冷拉取资源。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 触发ontoolresultapp.get(/widget-preview, (_req, res) { const shim globalThis.ExtApps{applyHostStyleVariables:(){},App:class{ constructor(){this.h{}} ontoolresult;onhostcontextchanged; async connect(){const pnew 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.mdCSP/sandbox 约束、bundle 内联模式、图片处理、宿主主题references/widget-templates.mdpicker / confirm / progress / display 可复用 HTML 骨架references/apps-sdk-messages.mdApp类 APIWidget ↔ 宿主 ↔ 服务器消息、生命周期与 supersessionreferences/payload-budgeting.md宿主工具结果大小上限、先剪列再截行的降级策略、重资源走callServerToolreferences/abuse-protection.mdAnthropic egress CIDR、分级令牌桶限流、trust proxy正确配置、上游响应缓存references/directory-checklist.md提交 connector 目录前的硬性审核清单几个值得提前知道的进阶事实均来自上述参考文件可作为踩坑预警Payload 预算claude.ai 与 Claude Desktop 会把工具结果截断在约150,000 字符Claude Code 约 25k token。超限时宿主会用一段文件指针字符串替换你的 JSONWidget 侧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 用户来自同一小段 IPIPv4160.79.104.0/21、IPv62607:6bc0::/48Desktop / Code 则直连、有独立用户 IP。建议按Anthropic 共享池 每 IP做分级令牌桶如anthropic: 600 容量/100 每秒other: 30/2trust 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-officialOfficial, Anthropic-managed directory of high quality Claude Code Plugins.项目地址https://gitcode.com/GitHub_Trending/cl/claude-plugins-official点击查看免费下载相关推荐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 服务上一篇3分钟掌握Ant Design时间范围选择从入门到实战下一篇Czkawka 如何帮你找回被重复文件占用的磁盘空间创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表