返回 AICAP-180
B6 · Day 53真 MCP server (2026-07-28 spec)

Zod 工具 schema vs 现有 JsonSchema 子集

Day 52 我们让 server 的 transport 通了(curl 打通 initialize),但那个 server 还没有任何业务工具。今天给它挂上第一个真工具,并解决一个工程现实问题:工具的输入怎么声明。仓库的 toolRegistry.ts 里是手写的 JsonSchema 子集 + 自写 validate();官方 SDK 走的是 Zod——你写一个 Zod shape,SDK

阶段: B6 · 真 MCP server (2026-07-28 spec)(Day 51-60) 标签: #mcp #zod #json-schema #single-source-of-truth

今日导引(由浅入深)

Day 52 我们让 server 的 transport 通了(curl 打通 initialize),但那个 server 还没有任何业务工具。今天给它挂上第一个真工具,并解决一个工程现实问题:工具的输入怎么声明。仓库的 toolRegistry.ts 里是手写的 JsonSchema 子集 + 自写 validate();官方 SDK 走的是 Zod——你写一个 Zod shape,SDK 自动派生 inputSchema(JSON Schema)给 tools/list 暴露,并在 tools/call 时做运行时校验。今天的事就是把手写 JsonSchema 翻成 Zod,体会「单一真相源」相比「手写双份 schema」的优越。今日最小可判定产出:1 个 Zod 工具注册成功、server 进程启动无报错(git commit)。

0. 协议背景速记

  • 承上:Day 52 transport 通了但 server 没业务工具;今天挂第一个工具。
  • 抉择:工具输入用什么声明?仓库是手写 JsonSchema 子集 + 自写 validate();官方 SDK 走 Zod。
  • 判定点:1 个 Zod 工具注册成功、server 启动无报错(git commit)。

1. 机理精读

server.tool(name, zodShape, handler) 的三件事:官方 SDK 的注册 API 直接吃 Zod schema,一次声明同时完成三件原本要手动协调的事:

  1. 对外暴露:从 Zod shape 自动派生 JSON Schema,作为 tools/list 返回里那条工具的 inputSchema——LLM 客户端就是靠它知道该传什么参数。
  2. 运行时校验tools/call 进来时用同一个 Zod schema 做 parse,非法参数直接挡在 handler 之外。
  3. 静态类型推断:handler 的 args 参数被 Zod 推断出精确的 TypeScript 类型,写 handler 时 IDE 有补全、编译期能查错。

一次声明覆盖「对外契约 + 运行时门禁 + 编译期类型」三层,是 Zod 在 MCP 场景的最大价值——三层永远同源,不会各说各话。

和手写 JsonSchema 子集等价在哪、强在哪toolRegistry.ts 里的 validate(schema, value)(line 112)也做类型/enum/min-max/required 校验——功能上等价于 Zod 的一个子集。但手写版有两个结构性弱点:

  • (a) 类型推断弱McpToolHandler 的签名是 (args: Record<string, unknown>) => unknown(line 55),handler 里拿到的是 unknown,得手动断言,容易和声明漂移。
  • (b) 错误信息粗validate() 返回的是自拼字符串数组(如 $.caseId: required property missing,line 136),Zod 的报错路径/类型信息更结构化、更可读。

核心权衡:单一真相源 vs 双份 schema

  • 最容易踩的坑是「Zod 写一份给运行时、JsonSchema 再手写一份给 tools/list」。
  • 两份会随迭代漂移——某天 description 改了一处忘了改另一处,LLM 看到的契约和实际校验的契约就不一致了,这是隐蔽的 bug 源。
  • Zod 派生 JSON Schema 让 Zod 成为唯一真相源inputSchema 永远从它生成,杜绝漂移。这正是 seed SOTA 段强调的「以 Zod 为单一真相源」。

与相邻概念的边界

  • 今天只换「输入声明的语言」(手写 JsonSchema → Zod),不动业务逻辑
  • 被选中的工具其 handler 内部仍可委托给仓库已有的真实实现——比如 typology 评估的真实函数是 src/aml/typology.ts 里的 assessCase(line 321)/ assessCaseV2(line 598),MCP 工具只是它们的「网络封套」。
  • Day 56 才真正把 AML 工具集成批迁上网;今天只迁 1 个打样。

2. 推导 / 手算 / 代码走读

对照基线(Read src/agent/mcp/toolRegistry.ts,真实已存在):

  • JsonSchema 接口(line 28):手写的 JSON-Schema 子集——type + properties + required + items + enum + minimum/maximum + minLength。这就是要被 Zod 取代的「输入声明语言」。
  • validate(schema, value, path)(line 112):递归校验,返回错误字符串数组(空 = 通过)。对象分支在 line 133 检查 required 缺失与逐属性递归——这正是 Zod z.object({...}) 自动做的事。
  • register(spec, handler)(line 151)要求 spec.inputSchema.type === 'object'(line 158)——和 MCP 约定一致:工具输入顶层必须是 object。Zod 侧对应 z.object({...})

手写 JsonSchema → Zod 翻译示例(以 seed 给的 assessTypology 工具名为例,其 handler 委托真实的 assessCase):

手写版(JsonSchema 子集风格):

inputSchema: {
  type: 'object',
  properties: { caseId: { type: 'string', minLength: 1 } },
  required: ['caseId'],
}

翻成 Zod shape:

const shape = { caseId: z.string().min(1) }
server.tool('assessTypology', shape, async (args) => {
  // args.caseId 此处已被 Zod 推断为 string,且运行时已校验
  // handler 委托仓库真实实现(typology.ts 的 assessCase / assessCaseV2)
  return { content: [{ type: 'text', text: /* 评估结果序列化 */ '' }] }
})

逐步说明:

  1. z.string().min(1) 同时编码了「类型=string」「minLength=1」两条约束——等价于手写版的 {type:'string', minLength:1},但一行写完。
  2. { caseId: ... } 这个 shape 被 SDK 包成 z.object,自动满足「顶层 object」约定(对应 toolRegistry.ts line 158 的检查)。
  3. SDK 从该 shape 派生出 tools/list 里的 inputSchema——不需要再手写一份 JsonSchema
  4. handler 的 args.caseId 已是 string 类型,不再是 unknown(对比手写版的 McpToolHandler 签名 line 55)。

注:seed 用 assessTypology 作 MCP 工具名是举例;仓库里真实的底层函数名是 assessCase / assessCaseV2src/aml/typology.ts),MCP 工具名与内部函数名不必同名。

Zod ↔ JsonSchema 子集对照表(手写版的每个特性都能在 Zod 找到对应):

手写 JsonSchema(toolRegistry.tsZod 等价
{type:'string'}z.string()
{type:'string', minLength:1}z.string().min(1)
{type:'integer', minimum:0}z.number().int().min(0)
{type:'string', enum:[...]}z.enum([...])
required:['caseId']字段非 .optional() 即必填
顶层 type:'object'shape 被 SDK 包成 z.object

可见 Zod 把「类型 + 约束 + 必填」编码进一条链式表达式,比手写嵌套对象更紧凑,且类型推断免费附送。

3. 今日实战

  1. toolRegistry.ts 里一个工具的输入形状(seed 举例 assessTypology,输入约束以 caseId 为主)。
  2. 把它手写的 JsonSchema 翻成 Zod shape:z.object({ caseId: z.string(), ... })
  3. server.tool('assessTypology', shape, async (args) => {...}) 注册到 Day 52 起的 server 上;handler 内部委托仓库已有实现(typology 评估走 src/aml/typology.ts)。
  4. 保留 toolRegistry.ts 里的手写 validate() 实现作参照基线,不删。
  5. git commit:1 个 Zod 工具注册成功、server 启动无报错。

4. 今日实测 / 产出

  • 待建——git commit:1 个 Zod 工具注册成功、server 进程启动无报错。
  • 可本地纯跑通(无需 key)。
  • 现有 src/agent/mcp/toolRegistry.tsvalidate()真实已存在的对照基线。

5. 常见误区 / 陷阱

  • 手写双份 schema(Zod + JsonSchema)造成漂移——必须以 Zod 为单一真相源,inputSchema 一律从 Zod 派生。
  • 顶层不是 object:MCP 工具输入顶层必须是 object(toolRegistry.ts line 158 也强制),别直接用 z.string() 当整个 inputSchema。
  • handler 里继续把 args 当 unknown 手动断言:用了 Zod 就该享受类型推断,手动断言又把漂移风险请回来了。
  • 以为换了 Zod 就改了业务逻辑:今天只换输入声明语言,handler 仍委托仓库已有实现。

6. 学习资源(每条带 YYYY-MM)

  • Zod v3 官方文档(schema 定义 / 运行时校验,持续维护至 2026)
  • MCP TypeScript SDK — server.tool() / 自动派生 inputSchema 文档(2026-03)
  • Zod v4 release notes —— 升级前查 z.toJSONSchema 行为变化(RC 阶段,2026)
  • src/agent/mcp/toolRegistry.ts(2026-06)——手写 JsonSchema 子集 + validate() 对照基线

7. 一句话回顾

  • 声明语言切换:工具输入从手写 JsonSchema 子集(toolRegistry.tsJsonSchema+validate())切到 Zod。
  • 一次声明三层同源server.tool(name, zodShape, handler) 同时给出 tools/listinputSchematools/call 的运行时校验、handler 的静态类型。
  • 核心纪律:Zod 作单一真相源,杜绝「双份 schema 漂移」。
  • 诚实状态:Zod 工具注册 + git commit 为「待建」,本地可跑通、无需 key;validate() 对照基线真实已存在。

8. 在 B1→B18 能力曲线上的位置

  • 承接 B5(tool engineering):B5 关注「工具描述/参数怎么写才让模型用对」,今天关注「工具输入怎么声明才让契约不漂移」——同一条「工具契约即提示」主线的工程化延伸。
  • B6 内部:Day 52 给了 transport,今天给输入声明(Zod);二者合起来才能在 Day 54/55 把工具真正 list/call 出来。
  • 通向 B7 安全:Zod 的运行时校验是「零信任工具输入」的第一道门——下一批 MCP 安全(工具投毒、参数注入)会把这道门加固成完整的输入边界防护。
  • 一句话:今天把「输入契约」从手写、易漂移,升级为 Zod 单一真相源——为后面工具规模化与安全加固打基础。

SOTA检查 (2026-06 更新)

  • 当前主流:Zod 作为 MCP 工具 schema 的单一真相源、SDK 自动派生 JSON Schema,仍是 SOTA 实践。
  • 是否仍 SOTAZod v3 稳定v4 已在 RC,API 大体兼容但 z.toJSONSchema 行为有变,升级前查 release notes。
  • 过时黑名单(AVOID):手写双份 schema(Zod + JsonSchema)造成漂移——以 Zod 为单一真相源。
  • 下次复查点:Zod v4 GA 时复验 z.toJSONSchema 输出与 MCP inputSchema 的兼容性;07-28 规范定稿后复验 inputSchema 字段要求。

衔接

  • 昨天:Day 52 — Streamable HTTP transport + 官方 TS SDK v1.x(transport 通了,server 还没业务工具)
  • 今天:用 Zod 把第一个工具的输入声明出来并注册,确立「Zod 单一真相源」
  • 明天:Day 54 — tools/list 网络语义(让客户端通过 Inspector 拉到这个 Zod 工具,核对排序稳定)