跳到主要内容

View 侧:App 类

本章讲写 UI 的人怎么用 App(src/app.ts)。前置:读过 01 章的握手与消息二分。

1. App 是什么:一个“反向 MCP 客户端”

App 继承 ProtocolWithEvents(后者继承 MCP SDK 的 Protocol),定义在 app.ts:345。它扮演的角色很反直觉:View 是 MCP 客户端,Host 是它的“服务器”。View 发请求(tools/call 等),Host 应答或转发。

你几乎不用碰 JSON-RPC。App 把协议封成两类好用的接口:

  • 收(Host→View): DOM 风格事件 —— app.ontoolresult = ...app.addEventListener("toolresult", ...)
  • 发(View→Host): 语义方法 —— app.callServerTool(...)app.sendMessage(...)

2. 收数据:DOM 风格的事件系统

要解决的小问题: 一个通知(比如工具结果)可能有多个监听者;又想保留 el.onclick = fn 那种“一个就够”的简单写法。ProtocolWithEvents(src/events.ts:59)同时提供两条通道,模仿 DOM。

两条通道、固定的派发顺序(events.ts:113-121):

  1. onEventDispatch(子类副作用,比如把 hostcontextchanged 合并进缓存)
  2. 单数 on* 处理器(替换语义,像 el.onclick)
  3. addEventListener 注册的监听器们(按注册顺序,可多个)

View 能监听的事件见 AppEventMap(app.ts:278-284):toolinputtoolinputpartialtoolresulttoolcancelledhostcontextchanged

// 示意,典型 View 写法
const app = new App({ name: "WeatherApp", version: "1.0.0" });

// 推荐:connect() 之前注册,避免错过一次性通知(见 01 章护栏二)
app.ontoolresult = (params) => render(params.content); // 完整结果
app.ontoolinputpartial = (params) => preview(params.arguments); // 流式预览
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);

await app.connect();

on* 设置器现已标 @deprecated,官方推荐用 addEventListener(能组合多监听器、能 removeEventListener 清理,见 app.ts:765 等处的 deprecation 说明)。但 on* 仍可用且文档示例大量使用。

部分输入是“缝合过的 JSON”。 ontoolinputpartial 收到的参数是宿主把未闭合的括号补全后的 healed JSON,可能截断(app.ts:786-795 的文档明确警告)——只用于预览,别拿去做关键操作。

3. 发数据:View→Host 的语义方法

这些方法的共同套路是:开头调 _assertInitialized(握手护栏,见 01 章),然后 this.request(...)this.notification(...) 发出去。

方法干什么源码
callServerTool(params)让 Host 代调服务器工具,拿 CallToolResultapp.ts:1235
readServerResource / listServerResources代读 / 代列服务器资源app.ts:1300 / app.ts:1348
createSamplingMessage(params)借宿主的模型连接做一次补全(需 sampling 能力)app.ts:1429
sendMessage(params)往对话里发一条用户消息(会触发模型回复)app.ts:1494
updateModelContext(params)给模型上下文塞状态,触发回复app.ts:1585
openLink / downloadFile请宿主开外链 / 下文件app.ts:1625 / app.ts:1703
requestDisplayMode(params)请求切换 inline/fullscreen/pipapp.ts:1788
sendLog(params)发日志(不进对话)app.ts:1525

callServerTool 的两个巧思(app.ts:1235-1258):

  • 错误分两种。 传输失败(连接断、宿主拒绝)是 throw;工具执行失败是返回里 isError: true。调用方必须查 result.isError 区分。
  • 默认开 progress 续命。 它默认传 onprogress: () => {} + resetTimeoutOnProgress: true,这样宿主在工具结果到达前插入的长流程(如人审)能靠心跳把请求续命过默认超时。

sendMessage vs updateModelContext 是一对易混点:

  • sendMessage = 往对话发消息,会触发模型回应(role 目前只能是 "user",spec.types.ts:211)。
  • updateModelContext = 把 UI 当前状态作为上下文给模型,不触发回应,且只保留最后一次、由宿主延迟到下次用户消息时才发(spec.types.ts:421-440)。典型组合:先 updateModelContext 塞长文,再 sendMessage 发一句短指令(app.ts:1473-1490 的示例)。

4. 自动尺寸:让宿主知道 iframe 该多大

要解决的小问题: iframe 内容高度会变,宿主需要据此调整容器。App 默认开 autoResize,用 ResizeObserver 自动上报(app.ts:1859-1907 setupSizeChangedNotifications)。

两处实测踩出来的细节(app.ts:1873-1888,值得一看):

  • 高度临时把 html.style.height = "max-content" 量真实内容高——用 max-content 而非 fit-content,因为后者会被 iframe 视口高度夹住、导致内部出现滚动条。
  • 宽度直接用 window.innerWidthfit-content 测量——对响应式 App,设 fit-content 会在 0px 宽触发同步重排,把横向滚动容器的 scrollLeft 永久清零。

上报前还比对上一次尺寸,只有真变了才发,防止样式抖动引发反馈循环。

5. View 也能暴露工具:registerTool

少见但存在的能力:View 自己也能注册工具供 Host(或模型)调用。App.registerTool(app.ts:502)行为对齐 SDK 的 McpServer.registerTool,但泛型基于 StandardSchemaV1 而非死绑 Zod——这意味着 Zod v4、ArkType、Valibot 都能用(校验/序列化逻辑见 src/standard-schema.ts)。

注册工具会自动补上 tools 能力(app.ts:594-596),并按需发 tools/list_changed

6. React 集成:useApp

用 React 的话,useApp(src/react/useApp.tsx:128)把“建 App → 注册处理器 → connect”包成一个钩子。两个刻意的设计(useApp.tsx:79-84139-184):

  • 不随 options 变化重连(依赖数组空),避免重连循环。
  • StrictMode 双挂载防护:若握手期间组件被卸载,关掉那个被抛弃的 App,免得它残留的 message 监听器和第二次挂载的实例抢消息。

关键细节 / 坑

  • connect() 不能重复调。 已连接再 connect 直接抛错(app.ts:1950-1954),要先 close()
  • oncalltool / onlisttools 是给“View 暴露工具”用的,和 callServerTool(代调服务器工具)方向相反,别混。
  • ontoolresult 必须 connect 前注册,否则可能错过那条一次性结果通知(01 章护栏二)。

下一步:03-host-appbridge.md 看链路另一端的 AppBridge