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 以前看不见它,于是参数按模型发来的样子直接进了工具。这次修复补上了本地引用解析,顺带把「被改名过的参数键」在引用内部也映射回来。它不解决远程引用和悬空指针,也不解析没有顶层类型的联合类型——这些都是有意留下的边界,而不是漏掉的部分。