跳到主要内容

MCP-UI SDK — 架构与原理

30 秒导读: MCP(Model Context Protocol)让 AI 能调用「工具」,但工具默认只能回纯文本。MCP-UI 让一个工具能附带一段真正的网页:服务端把 HTML 包成 ui:// 资源,客户端在一个沙箱化的 iframe 里把它渲染出来,网页里的按钮还能反过来调工具。本文讲它怎么做到、以及为什么要套两层 iframe。

1. 这是什么(零基础也能懂)

  • 一句话定义: MCP-UI 是一套 SDK,实现了 MCP Apps 标准——让 MCP 工具的返回值里能带一块可交互的 UI,而不只是文字。

  • 解决什么问题 / 给谁用: 假设你写了个 MCP 服务器,有个工具叫 show_chart。在纯 MCP 里,它只能回一段文字描述图表;模型再把文字念给用户。这很弱。MCP-UI 让 show_chart 直接回一个真实的图表网页,宿主(如 Claude、VSCode)把它嵌进对话里;用户点图表上的按钮,还能触发新的工具调用。

  • 它给两类人用:

    角色用哪个包干什么
    工具作者(服务端)@mcp-ui/server(TS)/ mcp_ui_server(Ruby)/ mcp-ui-server(Py)把 HTML 或外部 URL 打包成 ui:// 资源
    宿主作者(客户端)@mcp-ui/client(仅 TS/React)在沙箱 iframe 里渲染那段 UI,并代理它和服务器的通信
  • 用起来什么样: 服务端三步——造资源、注册资源、把工具用 _meta.ui.resourceUri 链到资源:

    // 示意,接近 README 真实用法
    const widgetUI = await createUIResource({
    uri: 'ui://my-server/widget', // 必须 ui:// 开头
    content: { type: 'rawHtml', htmlString: '<h1>Widget</h1>' },
    encoding: 'text',
    });
    registerAppTool(server, 'show_widget', {
    inputSchema: { query: z.string() },
    _meta: { ui: { resourceUri: widgetUI.resource.uri } }, // 工具 → UI 的连线
    }, async ({ query }) => ({ content: [{ type: 'text', text: query }] }));

    客户端一行组件就渲染:

    // 示意
    <AppRenderer client={mcpClient} toolName="show_widget"
    sandbox={{ url: sandboxUrl }}
    toolInput={args} toolResult={result} />
  • 一句话直觉/类比: 把它想成网页里的 <iframe> + 一条 postMessage 对讲机。工具的 UI 住在一个上了锁的小房间(沙箱 iframe)里,只能透过门缝(postMessage)和外面(宿主)喊话;宿主听到「帮我调工具 X」才代它去调真正的 MCP 服务器。

本节不碰底层。记住一件事就够:MCP-UI = 让工具回 UI,UI 又能回调工具,全程隔离在沙箱里。

2. 顶层全景(它大概怎么转)

这个仓库是个多语言 monorepo,但真正的逻辑高度集中。它本身是一层「符合 MCP Apps 标准的薄封装」——真正搬运消息的重活(AppBridgePostMessageTransport、握手协议)在它依赖的外部包 @modelcontextprotocol/ext-apps 里,本仓库只做两件事:打包资源(server)渲染 + 接线(client)

2.1 部件职责

部件干什么在哪
createUIResource把 HTML/URL → ui:// 资源对象sdks/typescript/server/src/index.ts
fetchExternalUrl / validateExternalUrlexternalUrl 模式抓远端 HTML,带 SSRF 防护sdks/typescript/server/src/utils.ts
AppRendererReact 组件:取 HTML、建桥、交给 AppFramesdks/typescript/client/src/components/AppRenderer.tsx
AppFrame建沙箱 iframe、连 AppBridge、推 HTML/输入/结果sdks/typescript/client/src/components/AppFrame.tsx
setupSandboxProxyIframe创建 iframe、等代理页发 ready 信号sdks/typescript/client/src/utils/app-host-utils.ts
代理页 index.html双层 iframe 的外层,用 document.write 注入 HTML 并转发消息sdks/typescript/client/scripts/proxy/index.html
AppBridge / PostMessageTransportMCP 消息在宿主 ↔ 沙箱间的搬运(JSON-RPC)外部依赖 @modelcontextprotocol/ext-apps
Ruby / Python server等价的 create_ui_resource,只打包不渲染sdks/ruby/lib/...sdks/python/.../core.py

2.2 主线走一遍(一次「工具带 UI」的完整往返)

怎么读下图:从上到下是时间顺序;==> 是数据流,文字是这一步在干嘛。中间那道 沙箱边界——左边是可信宿主,右边是不可信的工具 UI。

服务端 宿主(client) 沙箱里的工具 UI
-------- ---------------- ------------------
createUIResource
→ ui://... 资源
→ 工具 _meta.ui.resourceUri

模型调用工具
==> 工具结果 + _meta 指向 ui://

AppRenderer:
① 读 tool._meta 拿 ui:// URI
② resources/read 取回 HTML
③ new AppBridge(client,...)
AppFrame:
④ 建外层代理 iframe ║
⑤ 等代理发 ready ║
⑥ connect(PostMessageTransport)
⑦ sendSandboxResourceReady(html) ==║==> 代理用 document.write
║ 写进内层 iframe → 工具 UI 跑起来
⑧ sendToolInput/Result ==║========> UI 收到参数/结果,渲染

⑨ onCallTool 等回调 <==║<== UI 点按钮: tools/call(JSON-RPC)
⑩ 转发给真正的 MCP client → 服务器

关键点:UI 永远不直接碰 MCP 服务器。它发出的每一个请求(调工具、读资源、开链接)都先撞到宿主注册的 handler(onCallTool/onReadResource/onOpenLink…),由宿主决定放不放行、怎么转发。这就是 MCP-UI 的安全模型核心。

2.3 阅读地图

建议顺序:1 → 2 → 3。只想理解安全模型,直接读 2 的「双层 iframe」节。