MCP 工具拿到的是字符串不是数字:藏在 $ref 后面的类型

你给自己写了一个 MCP 服务器,schema 是用 pydantic 的模型或 TypeScript 那边的 zod 顺手生成的。工具在本地单独测的时候一切正常。接进 Hermes 之后,越用越觉得别扭:明明是整数的 count,工具里拿到的是 "5";布尔参数传了 true,收下来是 "true";有一个返回结果的过滤器,你在日志里看到的是一个带转义的 JSON 字符串,而不是对象。参数看起来没写错,模型也没胡来,可工具就是按错误的类型收到了值。
问题不在你的服务器,也不在模型,而在两者之间的那一小段路上:Hermes 在把工具调用交给实现之前,会按 schema 做一次参数整理(类型转换、参数键还原)。这套整理很长一段时间里只看 schema 的字面内容,不跟进 $ref 指向的地方——而 pydantic 和 zod 恰好最擅长把真正的类型放到 $ref 后面。PR #137862(2026 年 10 月 13 日合入 main)补上了这一步。
现象:同一个工具,参数有时是数字,有时是字符串
先说清楚这次的整理是干嘛的。每次工具调用被分发前,都会经过一次参数整理(coerce_tool_args),它按工具的 schema 做两件事:
- 类型转换:模型把
5写成字符串"5"、把对象写成 JSON 字符串,这里会按 schema 声明的类型转回去; - 参数键还原:有些 provider 不接受带空格之类的属性名,schema 在送去模型之前会把
first name改成first_name,这里再把模型发回来的键映射回工具真正认识的名字。
在这次修复之前,这两步都只看 schema 对象本身。属性写法是 "count": {"type": "integer"} 时一切正常;一旦写成 "count": {"$ref": "#/$defs/Count"},整理逻辑就看不到 type 了——它不认识引用,于是「没有类型信息」,于是什么都不做。值原样交给工具。
为什么 MCP 服务器特别容易踩到
这不是某个服务器写错了,而是主流 SDK 的默认行为:
- 用 pydantic(Python 侧的 MCP 服务器常见做法)定义模型时,重复出现的子模型会被放到
$defs里,属性上留一个$ref; - 用 zod 转 JSON Schema(TypeScript 侧常见做法)时,同一个 schema 被复用第二次,会用第一个出现位置的路径来引用,比如
#/properties/limit/anyOf/0。
Hermes 的 MCP 适配层只把 definitions 改名成 $defs,引用本身原样保留。于是「用 SDK 好好写 schema」的服务器,反而最容易命中这个问题。如果你在接外部 MCP 服务器(比如官方远程 MCP 目录里的那些),它们的 schema 也是这么生成的,同样适用。
修复前后:值到底差在哪
同一份 schema、同样的入参,修复前后交给工具的东西不一样:
| 模型发来的值 | $ref 背后的类型 |
修复前 | 修复后 |
|---|---|---|---|
"count": "5" |
integer | "5" |
5 |
"verbose": "true"($ref 旁还有 description) |
boolean | "true" |
true |
"filters": "{\"labels\": \"[\\\"a\\\", \\\"b\\\"]\"}" |
含数组的 object | 一个 JSON 字符串 | {"labels": ["a", "b"]} |
"offset": "7"(引用 #/properties/limit/anyOf/0) |
integer | "7" |
7 |
"person": {"first_name": "Ada"} |
object,键原本是 first name |
工具收到 first_name |
工具收到 first name |
最后一行是另一种错法,也最容易让人怀疑人生:工具报「缺少必填参数 first name」,而你的调用里明明有一个长得几乎一样的字段。原因就是 schema 被改名之后,引用里的对象没能一起改回来。
修复怎么做的:一个本地指针解析器
新增的解析器叫 resolve_local_schema_ref,放在 tools/schema_sanitizer.py,参数整理和参数键还原共用同一份实现——这样两处不会再各写一套、各漏一格。它的规则不复杂:
- 只处理文档内部的引用:以
#开头的片段先做百分号解码,再按 RFC 6901 的 JSON Pointer 语义读下来,穿过对象,也允许穿过数组下标(那条anyOf/0的路径就是这么走进去的); - 循环会停:同一条引用链遇到重复就停;递归的
anyOf/oneOf/allOf按分支各自限量,所以一个递归联合类型里其它的备选不会被它吞掉; - 引用旁边的兄弟字段保留:
{"$ref": ..., "description": ...}这种写法,description不会丢;两边冲突的时候以被引用的 schema 为准; - 成本可忽略:只对带
$ref的属性多做几次字典查找。
有边界:哪些引用不会解析
修复很克制,下面这些情况 schema 原样不动,也就是说这类参数仍按老规矩处理:
- 远程引用(指向别的文件或 URL);
- 纯名字锚点,比如
#Address,它不是指针; - 非法的
~转义和指不到东西的悬空指针。
还有一条是修复范围之外的老毛病,跟有没有 $ref 无关:如果某个联合类型没有顶层 type(比如 anyOf: [object, null]),模型把对象写成 JSON 字符串时依然不会被解析。
你能做的那点小事
这个修复 10 月 13 日合入 main,而最近一个打标签的版本 v0.21.7 是 10 月 11 日切出来的,所以它还没有随任何发版出去,会跟下一个版本一起到达。两条路:
- 想让 MCP 服务器立刻按预期收到类型:装最新的
main(hermes update --set-channel main)。这条命令的语义在 v0.21.7 里已经讲清楚了——默认跟随打标签的发版,需要跟随每个提交时自己打开这个通道; - 想留在稳定版:等下次发版即可。在那之前,如果某个 MCP 工具对类型特别敏感,可以在工具描述里写清它期望的类型,或者在服务器侧对入参做一次宽松解析(收到
"5"也能当 5 处理)。
顺手确认自己的环境里有没有这个函数,一条命令就够:
grep -n "resolve_local_schema_ref" ~/.hermes/hermes-agent/tools/schema_sanitizer.py
有输出说明你的代码已经带上这次修复;没有输出就是还没有。你的 MCP 是怎么接进来的(本地命令、远程地址、还是应用型 MCP 服务器)不影响判断,因为整理这一步对所有工具都一样,配置细节可以对照命令参考和MCP 配置里的上下文变量那篇。
小结
一句话版本:schema 里的类型藏在 $ref 后面时,Hermes 以前看不见它,于是参数按模型发来的样子直接进了工具。这次修复补上了本地引用解析,顺带把「被改名过的参数键」在引用内部也映射回来。它不解决远程引用和悬空指针,也不解析没有顶层类型的联合类型——这些都是有意留下的边界,而不是漏掉的部分。