跳到主要内容

Server 侧:注册助手

本章讲写 MCP 服务器的人怎么给工具配 UI。核心文件 src/server/index.ts

1. 两步绑定:工具 + 资源,靠 URI 串起来

MCP Apps 在服务器侧的全部魔法就一句话:一个工具声明它要用哪份 UI 资源,该资源单独注册并返回 HTML。 二者靠同一个 ui:// URI 关联。

// 示意,改写自 examples/quickstart/server.ts
const resourceUri = "ui://get-time/mcp-app.html";

registerAppTool(server, "get-time", {
description: "Returns the current server time.",
_meta: { ui: { resourceUri } }, // ① 工具指向 UI
}, async () => ({ content: [{ type: "text", text: new Date().toISOString() }] }));

registerAppResource(server, resourceUri, resourceUri, {}, async () => ({
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }], // ② 提供 HTML
}));

registerAppTool(server/index.ts:217)和 registerAppResource(server/index.ts:389)都是 SDK 原生 server.registerTool / registerResource 的薄封装,各自只多做一点事。

2. registerAppTool 多做的事:新旧元数据互填

要解决的小问题: UI 资源 URI 历史上有两种放法——新格式 _meta.ui.resourceUri(推荐),旧格式 _meta["ui/resourceUri"](扁平 key,已废弃但老宿主还认)。

registerAppTool 自动双向补齐:你只写新格式,它顺手补一份旧格式;只写旧格式,它补一份新格式(server/index.ts:241-252)。这样新老宿主都能识别。

Host 侧的对应读取在 getToolUiResourceUri(app-bridge.ts:125-141):先读新格式 _meta.ui.resourceUri,缺了再回落旧格式;读到的 URI 必须以 ui:// 开头,否则抛错。

3. registerAppResource 多做的事:默认 MIME

它把 MIME 默认成 RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"(app.ts:158server/index.ts:400-401)。这个特殊 MIME 是宿主识别“这是 MCP App UI 而非普通 HTML 资源”的标志,也是客户端能力协商里 mimeTypes 要包含的值(见下文 §6)。

4. 工具可见性:给谁看、给谁调

_meta.ui.visibility(类型 McpUiToolVisibility[],spec.types.ts:764-784)控制工具对谁可见。默认 ["model", "app"]:

visibility含义典型场景
["model", "app"](默认)模型能看能调,App 也能调普通工具
["model"]只给模型看/调,App 不直接调展示型工具(如 "show-cart",server/index.ts:173-191)
["app"]对模型隐藏,只让 App 调App 内部操作(如 "update-quantity",server/index.ts:194-213)

Host 侧用 isToolVisibilityModelOnly / isToolVisibilityAppOnly(app-bridge.ts:149-169)判定。["app"] 这类对模型隐藏的工具,意义是:让 UI 能调一些不该污染模型工具列表的细粒度操作。

5. 资源元数据:CSP / 权限 / 边框 / 专用域名

registerAppResource 的内容项可带 _meta.ui(类型 McpUiResourceMeta,spec.types.ts:694-731),声明这份 UI 的安全与渲染需求:

  • csp(McpUiResourceCsp,spec.types.ts:605-655):声明 UI 要访问哪些域。connectDomains(fetch/WS)、resourceDomains(脚本/样式/图片/字体)、frameDomains(嵌套 iframe)、baseUriDomains默认全部为空 = 什么外部都不让连(安全默认)。
  • permissions:camera/microphone/geolocation/clipboardWrite,映射到 iframe 的 allow 属性。
  • domain:给 View 一个稳定专用 origin(OAuth 回调、CORS 白名单、API key allowlist 用),格式由各宿主自定(如 {hash}.claudemcpcontent.com)。
  • prefersBorder:是否要宿主给可见边框/背景。

重要约束(写在类型里):csp/permissions 属于资源、不属于工具McpUiToolMeta 里把它们标成 csp?: never(spec.types.ts:786-795),宿主从 resources/read 的内容项(以 resources/list 为回落)读取并忽略工具上的这两项。

_meta.uiresources/list 上是“连接期可审的静态默认”,而 resources/read 的内容项若也带 _meta.ui,内容项优先(server/index.ts:114-137)。

6. 能力协商:服务器怎么知道客户端支持 MCP Apps

客户端(宿主)通过 MCP 初始化里的 extensions 字段广告支持,key 是 EXTENSION_ID = "io.modelcontextprotocol/ui"(server/index.ts:413)。服务器用 getUiCapability(clientCapabilities)(server/index.ts:458)取出 McpUiClientCapabilities,检查 mimeTypes 是否含 "text/html;profile=mcp-app",从而决定注册带 UI 的工具还是纯文本回落工具(server/index.ts:429-456 的示例正是这个分叉)。

// 示意,改写自 server/index.ts:429-456
server.server.oninitialized = () => {
const uiCap = getUiCapability(server.server.getClientCapabilities());
if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
registerAppTool(server, "weather", { /* 带 ui 元数据 */ }, weatherHandler);
} else {
server.registerTool("weather", { /* 纯文本 */ }, textWeatherHandler); // 优雅回落
}
};

关键细节 / 坑

  • schema 库不绑 Zod。 registerAppTool 的 inputSchema 类型已放宽到 StandardSchemaWithJSON,但注释明说:SDK 1.x 运行时仍调 Zod 内部,非 Zod 的 schema 现在会失败,等依赖升到 SDK v2 才真正生效(server/index.ts:228-236)。当前所有调用方用 Zod,无影响。
  • 资源 URI 必须 ui:// 前缀,否则 Host 解析时抛错(app-bridge.ts:135-139)。

下一步:05-security-and-sandbox.md 看这一切是怎么被安全地关进沙箱的。