高层服务器 API:McpServer
本章讲:你调
server.registerTool(...)之后,SDK 内部到底发生了什么——一个声明式注册项怎么变成能响应tools/list/tools/call的处理器,以及输入输出是怎么被校验的。
1. 它要解决的小问题
协议层(第 2 章)只认 JSON-RPC 方法名:客户端发 tools/list,你得返回工具清单;发 tools/call,你得跑对应函数。手写这些处理器很繁琐——而且 MCP 规范有不少硬性要求(声明了某能力就必须响应它的 list 方法,即使是空结果)。
McpServer 是 Server(底层)之上的便利层:你只声明「有哪些工具/资源/提示词」,它替你装好所有处理器。
2. 思路/直觉:注册表 + 懒加载处理器
核心数据结构是四张「注册表」字典(uri/name → 注册项):
// packages/server/src/server/mcp.ts:72-77 —— 四张注册表
private _registeredResources: { [uri: string]: RegisteredResource } = {};
private _registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate } = {};
private _registeredTools: { [name: string]: RegisteredTool } = {};
private _registeredPrompts: { [name: string]: RegisteredPrompt } = {};
处理器懒加载:第一次注册某类原语时,才把对应的 JSON-RPC 处理器装到底层 Server 上,并用一个 _toolHandlersInitialized 之类的标志位防止重复装。见 setToolRequestHandlers(mcp.ts:161)开头的幂 等守卫。
规范坑(已处理): 如果你在
new McpServer({...}, { capabilities: { tools: {} } })里预先声明了 tools 能力,构造函数会立即装处理器(mcp.ts:125),哪怕你还没注册任何工具。原因:规范要求「声明了能力就必须响应其 list 方法」,否则会回 −32601 Method not found,违反规范。
3. 主线:一次 tools/call 在 McpServer 里的五步
这张图是 tools/call 处理器(mcp.ts:210)的内部流程,从左到右,任一步失败即转错误分支。
请求到达
│
▼
① 查注册表 ──未找到/已禁用──▶ 抛 ProtocolError(InvalidParams)
│ 找到
▼
② validateToolInput ──校验失败──▶ 抛 InvalidParams
│ 通过(得到类型化 args)
▼
③ executeToolHandler ──你的函数跑这里──▶ 结果
│
▼
④ validateToolOutput ──有 outputSchema 但无 structuredContent──▶ 抛 InvalidParams
│ 通过
▼
⑤ projectCallToolResult(交给 wire codec 做纪元投影)──▶ 返回
对应真实代码的骨架(已精简):
// 示意,浓缩自 packages/server/src/server/mcp.ts:210-236
this.server.setRequestHandler('tools/call', async (request, ctx) => {
const tool = this._registeredTools[request.params.name];
if (!tool) throw new ProtocolError(InvalidParams, `Tool ... not found`); // ①
try {
const args = await this.validateToolInput(tool, request.params.arguments, name); // ②
const result = await this.executeToolHandler(tool, args, ctx); // ③
await this.validateToolOutput(tool, result, name); // ④
if (isInputRequiredResult(result)) return result; // 多轮交互结果,原样透传
return this.server.projectCallToolResult(result, tool.outputSchemaJson); // ⑤
} catch (error) {
return this.createToolError(...); // 工具内部错误 → isError:true 的结果,不是协议错误
}
});
一个重要区分: 工具业务失败(catch 分支)被包成 { content:[...], isError: true } 的正常结果(createToolError,mcp.ts:247),而不是 JSON-RPC 协议错误。只有「工具不存在/参数非法」这种协议级问题才抛 ProtocolError。这让 LLM 能看到工具的错误文本并自我纠正。
4. 核心机制
4.1 输入校验:Standard Schema,不绑死 Zod
v2 不再硬依赖 Zod。inputSchema 接受任何实现了 Standard Schema 的库(Zod v4 / Valibot / ArkType)。校验走统一入口 validateStandardSchema:
// packages/server/src/server/mcp.ts:274-280 —— validateToolInput 核心
const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {});
if (!parseResult.success) {
throw new ProtocolError(InvalidParams, `Input validation error: ...: ${parseResult.error}`);
}
return parseResult.data as ...; // 处理器拿到的是「已校验、已转型」的 args
registerTool 还保留一个 @deprecated 的「raw shape」重载:你传 { field: z.string() } 这种裸记录,它用 normalizeRawShapeSchema(mcp.ts:995)自动包成 z.object(...)。这是为兼容 v1 写法。
4.2 输出校验:structuredContent 的「存在性」判断很讲究
如果工具声明了 outputSchema,SDK 会校验 result.structuredContent。关键细节:判断「有没有结构化内容」用的是 === undefined,不是真值判断:
// packages/server/src/server/mcp.ts:307-312 —— 为何不能用 falsy 判断
if (result.structuredContent === undefined) {
throw new ProtocolError(InvalidParams, `... has an output schema but no structured content ...`);
}
原因(注释里写得很清楚):structuredContent 合法地可以是 null、0、false、""——这些都是有效 JSON 值。若用 if (!structuredContent) 就会把合法的 0 当成「缺失」。存在时一律送进 schema 校验。
4.3 tools/list 的 JSON Schema 转换 + 记忆化
tools/list 处理器(mcp.ts:178)把每个工具的 inputSchema(Standard Schema)转成 JSON Schema(standardSchemaToJsonSchema),因为线上传的是 JSON Schema 而非 Zod 对象。
这个转换记忆化在 _toolInputSchemaJson(mcp.ts:84)。为什么?因为 HTTP 入口用「每请求一个工厂」模型(见 createMcpHandler),同一个工具可能被反复转换;缓存让注册时的转换和分发前的 SEP-2243 头校验共用一次成果。
注意 registerTool 里这段转换是 「warn, never throw」(mcp.ts:821-836):转换失败只警告、不抛——这样本地对着「忽略该字段的 stdio 客户端」开发时不会被阻塞。