SuperAPI AI 工具集

build-mcp-app

编程与开发

查看项目

更新于 10-30 16:20

技能介绍

用途

build-mcp-app 用于在已有 MCP 服务器基础上添加交互式 UI 组件(widget)。MCP 应用本质上仍是标准 MCP 服务器,只是额外提供 UI 资源——在聊天界面内联渲染的交互组件。构建一次即可运行在 Claude、ChatGPT 以及其他实现 apps 接口的宿主中。UI 层是附加的,底层仍是工具、资源和同一套通信协议。若尚未构建过普通 MCP 服务器,可先参考 build-mcp-server 技能,本技能在其上叠加 widget。

何时该用 widget

不要为了加 UI 而加 UI——大多数工具返回文本或 JSON 即可。以下情况适合加 widget:

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

若都不符合,跳过 widget。文本构建更快,对用户也更快。

widget 与 Elicitation 的取舍

构建 widget 前先检查 elicitation 是否已覆盖需求。Elicitation 是规范原生能力,零 UI 代码,在任何合规宿主中都能工作。确认是/否、从短枚举中选择、填写扁平表单(姓名、邮箱、日期)都适合 elicitation;从大型/可搜索列表中选择、选择前的视觉预览、图表/地图/diff 视图、实时更新进度则适合 widget。若 elicitation 能覆盖,就用它。

架构:两种部署形态

远程 MCP 应用(最常见):托管式 streamable-HTTP 服务器。widget 模板作为资源提供,工具结果引用它们。宿主获取资源,在 iframe 沙箱中渲染,并在 widget 与 Claude 之间中转消息。

MCPB 打包的 MCP 应用(本地 + UI):widget 机制相同,但服务器运行在本地 MCPB 包内。适用于 widget 需要驱动本地应用的场景,例如浏览真实本地磁盘的文件选择器、控制桌面应用的对话框。MCPB 打包机制参见 build-mcpb 技能。

widget 如何挂接到工具

启用 widget 的工具需要两个独立注册:

  1. 工具通过 _meta.ui.resourceUri 声明 UI 资源,其处理函数返回纯文本/JSON——不是 HTML。
  2. 资源单独注册并提供 HTML。

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

URI 方案 ui:// 是约定。MIME 类型必须是 RESOURCE_MIME_TYPE("text/html;profile=mcp-app")——宿主据此知道要将其渲染为交互式 iframe,而非仅显示源码。

Widget 运行时——App 类

在 iframe 内,脚本通过 @modelcontextprotocol/ext-apps 的 App 类与宿主通信。这是持久双向连接——只要对话活跃,widget 就保持存活,接收新的工具结果并发送用户操作。

主要方法:

  • app.ontoolresult = fn(宿主 → widget):接收工具返回值
  • app.ontoolinput = fn(宿主 → widget):接收工具输入参数
  • app.sendMessage({...})(widget → 宿主):向对话注入消息
  • app.updateModelContext({...})(widget → 宿主):静默更新上下文
  • app.callServerTool({name, arguments})(widget → 服务器):调用服务器上的另一个工具
  • app.openLink({url})(widget → 宿主):在新标签页打开 URL(沙箱阻止 window.open)
  • app.getHostContext() / app.onhostcontextchanged(宿主 → widget):主题、宿主 CSS 变量、容器尺寸、显示模式、设备能力
  • app.requestDisplayMode({mode})(widget → 宿主):请求 inline / pip / fullscreen
  • app.downloadFile({name, mimeType, content})(widget → 宿主):宿主中介的下载(base64 内容)
  • new App(info, caps, {autoResize: true}):iframe 高度跟随渲染内容

sendMessage 是典型的「用户选了某物,告诉 Claude」路径。updateModelContext 用于 Claude 应知道但不应刷屏的状态。任何出站导航都必须用 openLink——window.open 和 <a target="_blank"> 被沙箱属性阻止。

widget 不能做的事:访问宿主页面的 DOM、cookie 或存储;向任意源发起网络请求(受 CSP 限制——通过 callServerTool 路由);打开弹窗或直接导航;可靠加载远程图片(需在服务器端内联为 data: URL)。

保持 widget 小巧且单一用途。选择器就负责选择,图表就负责展示。不要在 iframe 内构建整个子应用——拆分为多个工具和聚焦的 widget。

脚手架:最小选择器 widget

安装:

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

服务器端使用 registerAppTool 注册工具(返回 JSON 数据并声明 resourceUri),用 registerAppResource 注册资源(返回 HTML)。ext-apps 浏览器包必须在构建时内联进 widget HTML——iframe 的 CSP 阻止 CDN 脚本获取,内联是强制的。不要从 https://esm.sh/... 导入 App,否则传递依赖获取被 CSP 阻止,widget 会渲染为空白。

本地专用 widget 应用(驱动桌面应用、读取本地文件)可把传输换成 StdioServerTransport,并通过 build-mcpb 技能打包。

节省返工的设计要点

  • 一个工具一个 widget。抵制构建全能巨型 widget 的冲动。
  • 工具描述必须提到 widget。Claude 决定调用什么时只看到工具描述。
  • widget 在运行时是可选的。不支持 apps 接口的宿主会忽略 _meta.ui,正常渲染工具的文本内容。由于处理函数已返回有意义的文本/JSON,降级是自动的。
  • 只读工具不要阻塞在 widget 结果上。展示型 widget 不应要求用户操作才能完成,应在同一结果中返回展示 widget 和文本摘要。
  • 按条目数量而非工具数量分叉布局。一个工具接受 items[],widget 根据 items.length 选择布局。
  • 把 Claude 的推理放进载荷。每个条目的简短 note 字段渲染为卡片上的标注。
  • 服务器端规范化图片形状。给图片容器固定 aspect-ratio + object-fit: contain。
  • 遵循宿主主题。用 app.getHostContext()?.theme 和 app.onhostcontextchanged,在 <html> 上切换 .dark 类,颜色放在 CSS 自定义属性中。深色模式下禁用 mix-blend-mode: multiply。

Claude 宿主专属配置

  • _meta.ui.resourceUri(工具):宿主为该工具结果渲染哪个 ui:// 资源
  • visibility: ["app"](工具):对 Claude 的工具列表隐藏仅 widget 使用的辅助工具
  • prefersBorder: false(资源):去掉宿主外层卡片边框(移动端)
  • csp.{connectDomains, resourceDomains, baseUriDomains}(资源):声明外部源;默认全部阻止。frameDomains 目前在 Claude 中受限
  • hostContext.safeAreaInsets: {top, right, bottom, left}(px):为刘海和输入框叠加层留出空间

目录提交要求 OAuth 或无认证(none)——静态 bearer 仅限私有部署且会阻止上架——外加工具注解和 3–5 张 PNG 截图。

测试

Claude Desktop:当前构建仍要求 command/args 配置形态(无原生 "type": "http")。用 mcp-remote 包装并强制 http-only 传输,避免 SSE 探测吞掉 widget 能力协商。Desktop 会激进缓存 UI 资源,编辑 widget HTML 后需完全退出(⌘Q / Alt+F4,而非关闭窗口)并重启以强制冷资源重新获取。

无头 JSON-RPC 循环:无需点击 Desktop 即可快速迭代。准备 test.jsonl(每行一条 JSON-RPC 消息,包含 initialize、notifications/initialized、tools/list、tools/call),用 (cat test.jsonl; sleep 10) | npx mcp-remote http://localhost:3000/mcp --allow-http 运行。sleep 保持 stdin 打开以收集所有响应。

在 Claude 中测试:在 claude.ai 中把服务器添加为自定义连接器(本地开发通过 Cloudflare 隧道)——这会走真实的 iframe 沙箱和 hostContext。