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,一次声明同时完成三件原本要手动协调的事:
- 对外暴露:从 Zod shape 自动派生 JSON Schema,作为
tools/list返回里那条工具的inputSchema——LLM 客户端就是靠它知道该传什么参数。 - 运行时校验:
tools/call进来时用同一个 Zod schema 做 parse,非法参数直接挡在 handler 之外。 - 静态类型推断: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缺失与逐属性递归——这正是 Zodz.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: /* 评估结果序列化 */ '' }] }
})
逐步说明:
z.string().min(1)同时编码了「类型=string」「minLength=1」两条约束——等价于手写版的{type:'string', minLength:1},但一行写完。{ caseId: ... }这个 shape 被 SDK 包成z.object,自动满足「顶层 object」约定(对应toolRegistry.tsline 158 的检查)。- SDK 从该 shape 派生出
tools/list里的inputSchema——不需要再手写一份 JsonSchema。 - handler 的
args.caseId已是string类型,不再是unknown(对比手写版的McpToolHandler签名 line 55)。
注:seed 用
assessTypology作 MCP 工具名是举例;仓库里真实的底层函数名是assessCase/assessCaseV2(src/aml/typology.ts),MCP 工具名与内部函数名不必同名。
Zod ↔ JsonSchema 子集对照表(手写版的每个特性都能在 Zod 找到对应):
手写 JsonSchema(toolRegistry.ts) | Zod 等价 |
|---|---|
{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. 今日实战
- 选
toolRegistry.ts里一个工具的输入形状(seed 举例assessTypology,输入约束以caseId为主)。 - 把它手写的 JsonSchema 翻成 Zod shape:
z.object({ caseId: z.string(), ... })。 - 用
server.tool('assessTypology', shape, async (args) => {...})注册到 Day 52 起的 server 上;handler 内部委托仓库已有实现(typology 评估走src/aml/typology.ts)。 - 保留
toolRegistry.ts里的手写validate()实现作参照基线,不删。 git commit:1 个 Zod 工具注册成功、server 启动无报错。
4. 今日实测 / 产出
- 待建——git commit:1 个 Zod 工具注册成功、server 进程启动无报错。
- 可本地纯跑通(无需 key)。
- 现有
src/agent/mcp/toolRegistry.ts的validate()是真实已存在的对照基线。
5. 常见误区 / 陷阱
- 手写双份 schema(Zod + JsonSchema)造成漂移——必须以 Zod 为单一真相源,
inputSchema一律从 Zod 派生。 - 顶层不是 object:MCP 工具输入顶层必须是 object(
toolRegistry.tsline 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.ts的JsonSchema+validate())切到 Zod。 - 一次声明三层同源:
server.tool(name, zodShape, handler)同时给出tools/list的inputSchema、tools/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 实践。
- 是否仍 SOTA:Zod v3 稳定;v4 已在 RC,API 大体兼容但
z.toJSONSchema行为有变,升级前查 release notes。 - 过时黑名单(AVOID):手写双份 schema(Zod + JsonSchema)造成漂移——以 Zod 为单一真相源。
- 下次复查点:Zod v4 GA 时复验
z.toJSONSchema输出与 MCPinputSchema的兼容性;07-28 规范定稿后复验 inputSchema 字段要求。
衔接
- 昨天:Day 52 — Streamable HTTP transport + 官方 TS SDK v1.x(transport 通了,server 还没业务工具)
- 今天:用 Zod 把第一个工具的输入声明出来并注册,确立「Zod 单一真相源」
- 明天:Day 54 — tools/list 网络语义(让客户端通过 Inspector 拉到这个 Zod 工具,核对排序稳定)