我们向 1,144 台 MCP server 调用了一个编造的工具
本文译自 2026-09-24 的英文原文。
(原文写于 2026-09-24;之后,从 2026-09-26 上线的检查套件 0.8.0 起,握手或工具列表被凭据门控挡住时,探测器也已改为不发出这次调用,这一行记为未检查。下文对 0.7.0「因凭据门控以外的原因」才不发调用的描述只适用于当时,详见探测器有时会扣下的那一次调用。)
探测器只发一次 tools/call,点名的是一个编造的工具 __mcpcheckup_probe_nonexistent_tool__;名为 error_taxonomy 的检查看的是 server 怎样拒绝它。返回格式正确的 JSON-RPC 错误,或者 HTTP 401、403,这项检查记为已通过。其他任何答复,这一行都记为观察到风险。
在这周之前,这一行能告诉你的也就这么多。2026-09-20,在 579 台有签名结果的 server 中,有 338 台的这一行被标为风险,而且每一台附的都是同一句话:错误场景没有返回协议形态的 JSON-RPC 错误。这是有签名结果的 server 的 58%,可无论维护者还是我们,都没法从页面上看出这些 server 实际答了什么。
规范怎么说,server 怎么做
MCP 规范的工具章节,从 2025-06-18 到当前的 2026-07-28,每一版都划了同一条线。协议错误(未知工具就属于这一类)是标准的 JSON-RPC 错误,规范给的例子是 {"code": -32602, "message": "Unknown tool: invalid_tool_name"}。工具执行错误,比如一个确实存在的工具在内部调用 API 失败,则放在普通的 result 里报告,标记 isError: true。2025-11-25 这一版把输入校验从前一类挪到了后一类;未知工具仍然留在前一类。
很多 server 对未知工具的做法正好相反:返回一个 result,标记 isError: true,再附一段文字说没找到这个工具。这种做法我们很熟悉,因为我们自己的 MCP server 就是这么做的。2026-09-14,一位用户的 agent 在 mcpcheckup.com/mcp 上调用了一个不存在的工具,得到的是 HTTP 200 和一个 result,文字是「MCP error -32602: Tool … not found」,外面包着 isError: true。我们自己的检查器也一直因为同样的原因把我们自己的 server 记为风险。修复是在 SDK 前面加一个小小的分发器:单个 tools/call 只要名字不在已注册的集合里,就会在 SDK 看到它之前得到一个代码为 -32602 的 JSON-RPC 错误。其余一切,包括参数校验,仍然交给 SDK 处理,在规范要求的地方也仍然以 isError 返回。
我们在检查里改了什么
我们没有改判定。同样的输入,得出的仍是和以前一样的已通过或风险;一个覆盖 9,828 个构造输入的差分测试,加上审核者跑的 450,000 个随机输入,新旧代码没有一处结论不同。改的是风险这一行记录了什么,而且记录的形式是有边界的:
- server 答复的 HTTP 状态码;
- 答复的媒体类型:固定列表里的四种之一,或者「none」「other」;
- 七个类别之一:带
isError标记的 result;没有标记为错误的 result;自称 JSON-RPC、但错误格式不对的消息;合法的 JSON,但不是 JSON-RPC;不是 JSON 的响应体;空响应体;没有任何带内容的 data 事件的事件流; - 只在「错误格式不对」这一类里,记录 JSON-RPC 错误代码,前提是它是一个小到能被精确存储的整数。
server 返回的任何文字都不存储。一个类别、一个状态码、一个取自列表的媒体类型、一个整数。报告页和 MCP 工具为每个类别各给出一句话,所以维护者读到「服务器返回的是带 isError 标记的普通 result,而不是 JSON-RPC error 对象」,就知道该去看处理函数里的哪一行。检查套件从 0.4.0 升到了 0.5.0,有签名的结果会写明是哪个版本得出的。
第一天的数据
新版探测器于 2026-09-23 01:08 UTC 上线,同一天 16:47 UTC 被套件 0.7.0 取代(中间部署过的 0.6.0 没有改动这项检查)。取每台 server 在这段时间里最近的一次 run,error_taxonomy 这一行的记录如下(1,156 台 server 在这段时间里有 run;目录里 1,299 台中另外 143 台没有,因为那天 11:45 到 16:50 UTC 之间探测器一个周期都没跑):
| 结果 | server 数 |
|---|---|
| 已通过(JSON-RPC 错误,或凭据门控) | 743 |
风险:带 isError 标记的 result |
359 |
| 风险:响应体不是 JSON | 14 |
| 风险:合法 JSON,但不是 JSON-RPC | 13 |
| 风险:空响应体 | 8 |
| 风险:result 没有标记为错误 | 7 |
| 风险:JSON-RPC 错误格式不对 | 0 |
| 风险:事件流里没有 data | 0 |
| 未检查(探测器提前停下、被限流,或预算用完) | 12 |
在记为风险的 401 台 server 中,有 359 台答复的是带 isError 标记的 result:十台里有九台。其中 285 台以普通 JSON 答复,74 台以 server-sent event 流答复,形状相同。剩下的十分之一共 42 台,情况各不相同:7 台答复的是没有标记为错误的普通 result;另外 35 台里,大多数看上去在那个路径上根本不是 MCP server:404 页面、响应体为空的重定向、三个 402、两个 451、四个 5xx。
这个分布正是这次改动的意义所在。改动之前,同一句话既用来描述一台唯一的问题只是沿用了 SDK 默认错误形状的 server,也用来描述一台对 tools/call 回一个 HTML 404 的 server。这不是同一种发现,维护者应该知道自己属于哪一种。
套件 0.7.0 把这条线又往前挪了一步:如果它因为凭据门控以外的原因没能读到完整的工具列表,或者列表里就有这个编造的工具名,它就不发出这次编造的调用,因为那样它无法排除 server 真有一个同名工具。到 2026-09-24 01:08 UTC,目录里 1,299 台 server 都有了这个版本下的 run,同一行变成:802 台已通过;401 台答复带 isError 标记的 result(312 台为普通 JSON,89 台为事件流);8 台答复没有标记为错误的 result;3 台答复 402,内容是 JSON 但不是 JSON-RPC;73 台因为工具列表分页或读不出来,没有被发出这次调用;另有 12 台因其他原因未检查。第一张表里的 404 页面和空重定向,现在都在这 73 台里:它们同样没有返回可读的工具列表,所以这次调用不再发出,这一行记录的是我们看不到完整的列表,而不是 server 怎样答复。
我们暂时不做的事
我们不会把 isError 这种情况移出风险一栏。规范写得很清楚,未知工具是协议错误,这一行陈述的是事实;改变什么算风险,是关于这项检查含义的决定,而不是关于证据的决定。它还会改变今后每一个有签名的结果,所以要和对检查的任何其他改动一样走同样的审核。如果哪天我们改了,改动会体现在检查套件的版本号上,不会悄悄进行。
今天我们会这样对维护者说:如果你的 server 在那 359 台里,几乎可以肯定只差一行。如果你的 SDK 允许对未知工具名返回 JSON-RPC 错误,要改的就是那一行;如果不允许,像我们给自己的 server 加的那样,在它前面放一个分发器,也就几行代码,这一行就会记为已通过。
数字从哪里来
338/579 这个基线,来自 2026-09-20 对生产数据库的只读查询:在目录中有签名的行里,统计最近一条 error_taxonomy 断言为风险的数量。上面的表是对同一数据库的一次只读查询:取每个 target 在 2026-09-23 01:08 UTC 到 16:47 UTC 之间开始的最近一次非试用 run,按这次 run 的 error_taxonomy 结果分组;对风险,再按记录的原因、HTTP 状态码和媒体类型分组。0.7.0 的数字是同一个查询,时间窗换成 2026-09-23 01:08 UTC 到 2026-09-24 01:08 UTC。9,828 和 450,000 两个数,分别来自检查套件仓库里的差分测试,以及记录在那个 pull request 里的审核者模糊测试。规范引文出自上面链接的工具章节的两个版本。