跳到主要内容

客户端:渲染与双层沙箱

本章是 MCP-UI 的核心。宿主(如 Claude、VSCode)用 @mcp-ui/client 把工具的 UI 渲染出来。难点不是「显示一段 HTML」——浏览器天生会——而是怎么在显示不可信 HTML 的同时,既隔离它、又让它能安全地回调工具。答案是一套双层 iframe 代理 + postMessage 桥。

1. 两个组件的分工

MCP-UI 客户端只有两个 React 组件,是上下层关系:

组件你给它什么它负责
AppRenderer(高层)client + toolName取 HTML(读 _meta 拿 URI、resources/read)+ AppBridge + 注册回调
AppFrame(低层)现成的 html + 现成的 appBridge建沙箱 iframe + 连桥 + 推 HTML/输入/结果

想自己控制取 HTML 的逻辑,可以绕过 AppRenderer 直接用 AppFrame(AppFrame.tsx:96-100 的注释明说了这个分层意图)。

2. AppRenderer:取 HTML + 接线

AppRenderer(sdks/typescript/client/src/components/AppRenderer.tsx:267)用一串 useEffect 把生命周期串起来。两件主要的事:

2.1 怎么拿到 HTML

Effect 2(AppRenderer.tsx:449-539)的取 HTML 逻辑分两步走,顺序和代码一致——注意「URI 解析」和「读 HTML」各有各的优先级,别混为一条链:

第 0 步:直接给了 html prop? → 用它,setHtml 后立刻返回,跳过一切抓取(449-452)

否则,先解析出 ui:// URI(474-489,toolResourceUri 优先):
toolResourceUri 有? → 直接用它当 URI
否则 client 有? → getToolUiResourceUri() 读 tool._meta 拿 ui:// URI
两者都无 → 报错

再用这个 URI 读 HTML(497-521,client 优先):
client 有? → readToolUiResourceHtml() 走 resources/read
否则 onReadResource 有? → 用回调去读(适合 client 在别的上下文)
两者都无 → 报错

换句话说,三种输入模式(html prop / client / toolResourceUri + onReadResource)的进入门槛在 449-466 校验,但真正取值时:URI 解析先看 toolResourceUri、读 HTML 先看 client,不是一条简单的「① html ② client ③ toolResourceUri」线性优先级。

取 URI 的 getToolUiResourceUri(sdks/typescript/client/src/utils/app-host-utils.ts:95-121)会翻页遍历 listTools 找到目标工具,再用 ext-apps 的 getToolUiResourceUri(tool)_meta 里抽 URI,并强制校验它 ui:// 开头。

读 HTML 的 readToolUiResourceHtml(同文件 123-150)对 resources/read 的结果做严格校验:必须恰好 1 个 content、MIME 必须等于 RESOURCE_MIME_TYPE,text 直接用、blobatob 解码。

2.2 建桥 + 注册回调(安全模型在这儿)

Effect 1(AppRenderer.tsx:339-446)new AppBridge(client, hostInfo, hostCapabilities),然后把宿主传进来的回调挂到桥的各个 handler 上。这一串 handler 就是UI 能向宿主请求的全部能力:

桥上的 handler对应 propUI 想干嘛
oncalltoolonCallTool调一个 MCP 工具
onreadresourceonReadResource读一个资源
onlistresources / onlistresourcetemplatesonListResources列资源
onlistpromptsonListPrompts列 prompt
onmessageonMessage给宿主发消息
onopenlinkonOpenLink请求开一个链接
fallbackRequestHandleronFallbackRequest兜底:实验性/未标准化方法

两个设计点:

  1. 不提供回调 = 拒绝。 没传 onMessage/onOpenLink 时,handler 直接 throw new McpError(ErrorCode.MethodNotFound, ...)(AppRenderer.tsx:369-384)。能力是默认关闭、显式开启
  2. 自动转发 vs 自定义。 如果传了 client 但没传 onCallTool,桥会自动把 tools/call 转发给那个 MCP client(这层自动转发在 ext-apps 的 AppBridge 内,本仓库只在传了自定义回调时才覆盖它,见 AppRenderer.tsx:394-409if (onCallToolRef.current))。传了自定义回调就用你的,可做缓存/过滤/聚合多服务器。

回调全部用 useRef 镜像(AppRenderer.tsx:300-324),这样 prop 变化不会重建整个桥——桥只在 client/hostInfo/hostCapabilities 变时重建(Effect 1 依赖数组第 446 行)。

3. 双层 iframe 代理:为什么是「两层」

这是全仓库最巧的一处,也是安全核心。先说怎么读:外层 iframe 是宿主能控制的「代理页」,内层 iframe 才真正跑工具的 HTML。

宿主页面 (可信)
└─ 外层 iframe = 代理页 scripts/proxy/index.html (宿主控制)
sandbox="allow-scripts allow-same-origin allow-forms"
└─ 内层 iframe id=root, src=about:blank
└─ 工具的 HTML (不可信) ← 用 document.write 写进来

3.1 为什么不直接一层 iframe?

直觉上,一层 <iframe srcdoc="工具HTML"> 就够了。但有两个麻烦:

  • srcdoc + CSP 的 base-uri 冲突:proxy 的注释和测试都点明,新实现改用 document.write 而非 srcdoc,正是为了绕开 base-uri 问题(ProxyScript.test.ts:63-72)。
  • 隔离与转发要分层:外层代理页负责「当中继」——它在宿主和内层之间转发 postMessage;内层只管渲染。把中继逻辑放在一个宿主自己提供的、固定的 sandbox_proxy.html 里,意味着工具 HTML 再怎么恶意,也只能影响内层,碰不到中继逻辑。

3.2 代理页干的三件事

代理页 scripts/proxy/index.html(sdks/typescript/client/scripts/proxy/index.html)很短,rawhtml 模式下做三件事:

  1. 建内层 iframe,id='root'src='about:blank'(第 47-77 行),让浏览器先初始化好 contentDocument
  2. 等父窗口发 HTML:收到 ui-html-content 消息就 doc.open(); doc.write(html); doc.close() 写进内层(renderHtmlInIframe,第 53-65 行)。如果此刻 contentDocument 还没好,就把 HTML 存进 pendingHtml,等内层 load 事件再写(第 67-72、85-88 行)。
  3. 双向转发消息(第 80-99 行):父 → 内层、内层 → 父,但只认 event.source === window.parent=== inner.contentWindow,别的来源一律不转。

建好后发 { type: 'ui-proxy-iframe-ready' } 通知父窗口(第 102 行)。父窗口那边由 setupSandboxProxyIframe 等这个信号(见 3.4)。

3.3 externalUrl 模式的代理

同一个代理页,带 ?url=... 参数时走另一支(index.html:103-124):

  • isValidHttpUrl 只放行 http/https(客户端侧再挡一次)。
  • 内层 iframe 直接 src=target,且单独设 sandbox="allow-same-origin allow-scripts"(注意:不含 allow-forms)。
  • 转发消息时用 urlOrigin 作为 targetOrigin(第 118 行),不是 '*'——因为外站 origin 已知,可以精确指定,更安全。

3.4 客户端侧怎么和代理握手

setupSandboxProxyIframe(app-host-utils.ts:19-89)在宿主侧:

  • 建一个 <iframe sandbox="allow-scripts allow-same-origin allow-forms">(第 31 行),src 指向代理页 URL。
  • 返回一个 onReady Promise,监听代理发的 SANDBOX_PROXY_READY_METHOD 消息才 resolve;10 秒超时(DEFAULT_SANDBOX_TIMEOUT_MS)。
  • 有个 cancelRef:AppFrame 在 React StrictMode 下 effect 被双调时,能同步拿到 cancel() 清掉定时器,避免「卸载后 Promise 还在等」的未处理拒绝——这正是 commit ce5893e 修的 bug(见代码地图)。

4. AppFrame:把上面拼起来

AppFrame(AppFrame.tsx:117)的四个 effect 是渲染流水线:

Effect 1: 建代理 iframe → 等 onReady → 挂 onsizechange/oninitialized
→ appBridge.connect(new PostMessageTransport(iframe.contentWindow, ...))
Effect 2: 桥连上后 → sendSandboxResourceReady({ html, csp }) // HTML 进沙箱
Effect 3: 初始化完成后 → sendToolInput({ arguments }) // 工具入参进 UI
Effect 4: 初始化完成后 → sendToolResult(result) // 工具结果进 UI

几处稳健性细节:

  • 同 URL + 同桥就复用 iframe(AppFrame.tsx:153-159),避免 StrictMode 重渲染时反复重建;但换了 app(不同 appBridge)就重建并清理旧的(第 177-182 行),保证不同工具 UI 之间相互隔离。
  • 每个发送 effect 都加了 currentAppBridgeRef.current === appBridge 这道闸(第 253、275、284 行),防止快速切换 app 时把 HTML/输入发错桥——这是异步竞态的实打实防护。
  • iframe 尺寸由 UI 自己通过 onsizechange 反向驱动(第 200-211 行),宿主据此调 iframe 的 style.width/height

5. CSP 怎么下发

沙箱 CSP 有两条路(AppFrame.tsx:24-3044-64 注释):

  1. URL query 参数 ?csp=<json>:支持的代理服务器据此设 HTTP 头——最稳,改不了。
  2. postMessage 兜底:sendSandboxResourceReady({ html, csp }) 把 csp 一起发进去,给不解析 query 的代理用。

注释明说:meta 标签 / postMessage 注入的 CSP 可能被恶意内容绕过,HTTP 头才是 tamper-proof,所以推荐用会读 ?csp= 的代理服务器(AppFrame.tsx:50-60,引 ext-apps PR #234)。

6. 实验性逃生舱:sendExperimentalRequest

服务端包还导出一个给 UI 自己用的辅助函数 sendExperimentalRequest(sdks/typescript/server/src/index.ts:148-218)。它让沙箱里的 UI 发一个自定义 JSON-RPC 请求给宿主的 onFallbackRequest,走的还是现有 PostMessageTransport

要点:

  • 约定方法名用 x/<namespace>/<action>(如 x/clipboard/write),给实验性方法用;未标准化的标准 MCP 方法(如 sampling/createMessage)用本名。
  • 自带递增 id、30 秒超时、AbortSignal 取消、event.source !== window.parent 过滤(第 173-217 行)。
  • 不在 iframe 里(window.parent === window)直接 reject(第 153 行)。

这是「标准还没覆盖的能力」的临时通道,配合 AppRendereronFallbackRequest 使用。

7. 一个诚实的提醒:README 与代码已脱节

README 还在讲 UIResourceRendereronUIAction<HTMLResourceRenderer><ui-resource-renderer> Web Component,以及 tool/prompt/link/intent/notify 五种 action。但在本 commit(33de6b6)的客户端 src/index.ts 里,这些已经不再导出了——只剩 AppRenderer / AppFrame / 工具函数 / 能力声明。git 历史里有明确的 chore: remove legacy spec(51e8f3a)和 fix: bump version for legacy removal(0a75453)。

那五种 action 类型现在只活在 Ruby/Python 的 server 辅助函数里(如 Python ui_action_result_tool_call 等,core.py:167-257),用于构造 UI 发回的消息体;客户端的旧版 onUIAction 分发器已被 MCP Apps 的 AppBridge handler 体系取代。读代码时以 src/index.ts 的导出为准。