跳到主要内容

协议骨架与握手

本章讲地基:消息怎么传、握手怎么走、消息分几类。读懂这章,后面 View/Host/Server 三章都只是“在这条链路上各干各的”。

1. 传输层:JSON-RPC 骑在 postMessage 上

要解决的小问题: iframe 和它的父窗口(聊天客户端)分属不同上下文,唯一能跨过去的浏览器原语是 window.postMessage。MCP 本身说的是 JSON-RPC 2.0。所以需要一个把 JSON-RPC 消息用 postMessage 收发的传输层。

思路: 实现 MCP SDK 的 Transport 接口,send() 就是 postMessage,收消息就是监听 windowmessage 事件。这就是 PostMessageTransport(src/message-transport.ts:46)。

两个关键细节(都和安全/健壮性有关):

  • 来源校验。 构造时传入 eventSource;收到消息先比 event.source !== this.eventSource,不是预期窗口就丢弃(message-transport.ts:78)。View 传 window.parent,Host 传 iframe.contentWindow
  • 三类消息分流。 解析失败时分三种处理(message-transport.ts:82-103):能解析的正常交给 onmessage;不是 JSON-RPC 的(jsonrpc !== "2.0",比如宿主注入的内部帧)静默忽略以免传输层被搞死;是 JSON-RPC 但畸形的才报真错。
// 示意,核心逻辑来自 message-transport.ts:77-104
this.messageListener = (event) => {
if (event.source !== this.eventSource) return; // 来源不对,丢
const parsed = JSONRPCMessageSchema.safeParse(event.data);
if (parsed.success) this.onmessage?.(parsed.data); // 合法 → 上交
else if (event.data?.jsonrpc !== "2.0") { /* 静默忽略非 JSON-RPC */ }
else this.onerror?.(new Error("Invalid JSON-RPC message ...")); // 畸形 → 报错
};

注意 send() 用的是 postMessage(message, "*")(message-transport.ts:132)——目标 origin 是通配,意味着消息对所有帧可见;真正的安全靠接收端的来源校验,而非发送端的 origin 锁定。这点在第 05 章的沙箱模型里会再展开。

2. 握手:ui/initialize → initialized

要解决的小问题: View 一加载就需要知道宿主环境(主题是深是浅?支持哪些能力?当前是哪个工具调起的我?),Host 也需要知道 View 声明了什么能力。这套“互相通报”就是握手。

它复用了 MCP 的 initialize 模式,但换了方法名前缀 ui/,以便和真正连服务器的标准 MCP initialize 区分开。

View 侧的 connect() 一步步做(app.ts:1943-1993):连传输层 → 发 ui/initialize → 存下 hostCapabilities/hostInfo/hostContext → 发 ui/notifications/initialized → 置 _initializedSent = true → 按需启动自动尺寸上报。失败则 close() 并抛错。

Host 侧应答AppBridge._oninitialize(app-bridge.ts:1461-1490):记下 View 的 appCapabilities/appInfo,做协议版本协商(在 SUPPORTED_PROTOCOL_VERSIONS 里就用请求的,否则回落到 LATEST_PROTOCOL_VERSION),回 hostContext

握手发生在 ui/initialize 而不是标准 initialize——服务器侧那个 initialize 是 Host↔Server 之间的事,与 iframe 无关。

3. 握手的时序是“硬约束”,SDK 专门设了护栏

这是本项目最值得学的一处工程细节:握手没完成就乱发消息,会让某些严格宿主把 iframe 永久隐藏(见代码里引用的 claude-ai-mcp#61/#149)。SDK 用两道护栏来防:

护栏一:出站方法的早调用检查。 AppcallServerTool / sendMessage / updateModelContext 等开头都调 _assertInitialized(method)(app.ts:368-379)。若 _initializedSent 还是 false,默认 console.warn,在 strict: true 下直接抛错。

护栏二:一次性事件的“晚注册”检查。toolinput/toolresult 这类宿主握手后只发一次的通知,如果你在 connect() 完成之后才注册第一个处理器,很可能已经错过了。_assertHandlerTiming(app.ts:414-428)在“某一次性事件的第一个监听器是在握手后注册的”时告警。ONE_SHOT_EVENTS 集合明确列出了哪些算一次性(app.ts:394-395)。

所以文档里反复强调:connect() 之前注册 ontoolresult 等处理器。 这不是风格建议,是与一次性通知的竞态有关。

4. 消息分两类:ui/* 专用 vs 复用标准 MCP

所有 View↔Host 消息归两类。记住这个二分,后面看任何方法都能立刻归位。

第一类:ui/* 专用消息(MCP Apps 自己定义的,见 spec.types.ts):

方向方法干什么
V→H 请求ui/initialize握手
V→H 请求ui/message往对话里发一条用户消息
V→H 请求ui/update-model-context给模型上下文塞状态(不触发回复)
V→H 请求ui/open-link / ui/download-file请宿主开链接 / 下文件
V→H 请求ui/request-display-mode请求切 inline/fullscreen/pip
V→H 通知ui/notifications/size-changed上报 UI 尺寸
H→V 通知ui/notifications/tool-input(-partial)推工具参数(可流式)
H→V 通知ui/notifications/tool-result / tool-cancelled推结果 / 取消
H→V 通知ui/notifications/host-context-changed主题/locale 等变化
H→V 请求ui/resource-teardown拆除前的优雅关闭

第二类:复用的标准 MCP 消息(View 当成 MCP 客户端用,Host 转发给服务器):tools/callresources/readresources/listsampling/createMessagepingnotifications/message(logging)。这些没有 ui/ 前缀,正是它们在沙箱里被透明转发的判据(见第 05 章)。

方法名字符串都有常量,别去抠 Zod schema 内部(spec.types.ts:814-843,如 TOOL_INPUT_METHODINITIALIZE_METHOD)。

5. 能力协商:谁能发什么,握手时就定了

思路: View 不该假设宿主什么都支持(能开链接吗?支持 sampling 吗?)。握手时双方交换能力,之后按能力行事。

  • Host 能力 McpUiHostCapabilities(spec.types.ts:494-532):openLinksdownloadFileserverToolsserverResourcesloggingsampling(含 sampling.tools)、updateModelContextmessagesandbox 等。
  • App 能力 McpUiAppCapabilities(spec.types.ts:538-548):tools(View 自己也能暴露工具给 Host 调)、availableDisplayModes

View 侧检查宿主能力前先调 getHostCapabilities()(app.ts:684);真正调用时还有 assertCapabilityForMethod(app.ts:1142-1152)做硬校验——比如没有 sampling 能力就调 createSamplingMessage,直接抛错而不是发出去石沉大海。

关键细节 / 坑

  • MCP Apps 明确不支持 Tasks。 AppAppBridge 都把 assertTaskCapability / assertTaskHandlerCapability 重写成直接抛 "Tasks are not supported in MCP Apps"(app.ts:1188-1198app-bridge.ts:1434-1444)。
  • Zod 的 JIT 与 CSP 冲突。 View 默认跑在禁 unsafe-eval 的严格 CSP 下,而 Zod 的 JIT 解析器用 new Function(),会在首条消息解析时崩。App 构造函数默认 z.config({ jitless: true }) 规避(app.ts:478-480);宿主 CSP 允许时可用 allowUnsafeEval: true 走更快的 JIT 路径。

下一步:02-view-app-class.md 看 View 怎么在这条链路上干活。