跳到主要内容

01 · MCP Server 契约:把一个 widget 声明成工具

这章讲服务器侧。读完你能说清:ChatGPT 凭什么知道「调这个工具会渲染那个 widget」,以及 call_tool 该返回什么。

1. 核心契约:一个 widget = 工具 + resource,用模板 URI 绑定

每个 widget 在协议里有两副面孔:

  • 一个 tool(模型能调用的东西),
  • 一个 resource(那段 HTML 本身)。

两者通过一个 ui:// 开头的模板 URI 绑在一起。工具的 _meta 说「我渲染 ui://widget/pizza-map.html」,resource 的 URI 正是这个串。

Node 里把一个 widget 的全部信息收进一个结构体 PizzazWidget(pizzaz_server_node/src/server.ts:29-37):

// 示意,非源码 —— 一个 widget 的描述
type PizzazWidget = {
id: string; // 工具名,如 "pizza-map"
title: string; // 给人看的标题
templateUri: string; // "ui://widget/pizza-map.html" —— 绑定锚点
invoking: string; // 调用中文案:"Hand-tossing a map"
invoked: string; // 调用完文案:"Served a fresh map"
html: string; // 真正的 widget HTML(从 assets/ 读)
responseText: string; // 给模型读的纯文本结果
};

2. _meta 四件套(整套协议最该记住的东西)

工具和 resource 的 _meta 里塞着四个 openai/ 前缀的键。这是 Apps SDK 在标准 MCP 之上的扩展约定。真实实现 widgetDescriptorMeta(pizzaz_server_node/src/server.ts:77-84):

作用
openai/outputTemplate绑定锚点:这工具渲染哪个模板 URI
openai/toolInvocation/invoking调用进行中给用户看的文案
openai/toolInvocation/invoked调用完成的文案
openai/widgetAccessible是否允许 widget 反向调这个工具

Python 侧逐字镜像,见 pizzaz_server_python/main.py:169-175_tool_meta

3. resource 的关键:MIME 类型 text/html+skybridge

widget 的 HTML 不是普通 text/html,而是 text/html+skybridge(pizzaz_server_node/src/server.ts:184mimeType)。这个自定义 MIME 是给 ChatGPT 的信号:「这段 HTML 要放进 skybridge 沙箱 iframe、并注入 window.openai」(inferred —— 沙箱实现不在本仓)。普通 text/html 不会触发这套渲染。

4. 主线:一次 call_tool 的完整往返

server 要实现的 handler 是标准 MCP 那几个。下面按 ChatGPT 调用顺序看:

list_tools → 我有哪些工具(每个带 outputTemplate)
list_resources → 我有哪些 widget HTML 资源
read_resource(uri) → 把某个模板的 HTML 原文给你
call_tool(name,args)→ 执行,返回 structuredContent + text + _meta

call_tool 的返回结构最关键(pizzaz_server_node/src/server.ts:263-274):

// 示意,非源码 —— call_tool 返回三样东西
return {
content: [{ type: "text", text: widget.responseText }], // ① 给模型读
structuredContent: { pizzaTopping: args.pizzaTopping }, // ② 给 widget 当 props
_meta: widgetInvocationMeta(widget), // ③ 调用文案
};
// 重点看:structuredContent 会变成 widget 里的 window.openai.toolOutput

这三者各喂一个消费者:content[].text模型(它据此续写对话),structuredContentwidget(下一章会看到它如何变成 toolOutput),_metahost UI(显示「调用中/完成」)。

真实的 read_resource 把 widget HTML 原样吐出(pizzaz_server_node/src/server.ts:216-236):按 URI 找到 widget,返回 { uri, mimeType: "text/html+skybridge", text: widget.html, _meta }

5. 关闭审批弹窗的小细节

工具默认会让用户点「同意运行」。这些只读 widget 通过 annotations 关掉弹窗(pizzaz_server_node/src/server.ts:171-176):

// 示意,非源码
annotations: {
destructiveHint: false, // 不是破坏性操作
openWorldHint: false, // 不访问外部世界
readOnlyHint: true, // 只读 —— host 据此免去审批
}

Python 同款在 pizzaz_server_python/main.py:195-199

6. Node 与 Python 的对照

两份 server 是逐字镜像,只是 SDK 不同。记住映射即可:

概念Node(pizzaz_server_node/src/server.ts)Python(pizzaz_server_python/main.py)
列工具ListToolsRequestSchema handler:245@mcp._mcp_server.list_tools():185
读资源ReadResourceRequestSchema handler:216_handle_read_resource:235
调工具CallToolRequestSchema handler:252_call_tool_request:257
入参校验zod(toolInputParser:161)pydantic(PizzaInput:115)
传输SSE 双端点(/mcp+/mcp/messages):288-289streamable HTTP(mcp.streamable_http_app():309)

一个值得注意的差异:Python 用 mcp._mcp_server.request_handlers[...] = fn 直接覆盖 call_tool / read_resource 处理器(pizzaz_server_python/main.py:305-306),绕过 FastMCP 的装饰器,因为这些 handler 要返回原生 types.ServerResult 以便塞自定义 _meta

7. 边界

  • HTML 在启动时就从 assets/ 读进内存(Node readWidgetHtml:43、Python _load_widget_html:39 带 lru_cache)。改了 assets 必须重启 server(README 明确警告 Python 的 lru_cache 会缓存)。
  • read_resource 找不到 URI 时:Node 直接 throw(:222),Python 返回带 _meta.error 的空 contents(:237-243)—— 行为略有不同。

8. 代码地图

主题文件路径符号名
widget 描述结构pizzaz_server_node/src/server.tsPizzazWidgetwidgets
_meta 四件套pizzaz_server_node/src/server.tswidgetDescriptorMetawidgetInvocationMeta
call_tool 返回pizzaz_server_node/src/server.tsCallToolRequestSchema handler
Python 镜像pizzaz_server_python/main.py_tool_meta_call_tool_request_handle_read_resource
HTML 装载pizzaz_server_python/main.py_load_widget_html