Claude Code MCP 显示 Connected 却 0 个工具:Invalid result for tools/list(ttlMs / cacheScope)实测与修法
问题背景
MCP server 配好之后,claude mcp list 不是 ✘ Failed to connect,而是一个黄色感叹号:
roblox-like: /tmp/mcp-ttl/roblox-like-server - ! Connected · tools fetch failed — Invalid result for tools/list: [ { "expected": "number", "code": "invalid_type", "path": [ "ttlMs" ], "message": "Invalid input: expected number, received undefined" }, { "code": "invalid_value", "values": [ "public", "private" ], "path": [ "cacheScope" ], "message": "Invalid option: expected one of \"public\"|\"private\"" } ]
会话里的表现是:server 状态显示 connected,但模型一个该 server 的工具都看不到。重启、新开会话、重启电脑都没用。
GitHub 上对应的是 anthropics/claude-code#97319(2026-09-26 提交,到 10-03 仍是 OPEN),触发者是 Roblox Studio 官方的 MCP bridge。报告人的判断是「tools/list 响应里带了新版协议的额外字段 ttlMs / cacheScope,Claude Code 的校验太严,把整个响应拒掉了」,评论区给出的两个绕过办法是「降级到 2.1.280」和「设 MCP_PROTOCOL_NEGOTIATION=legacy」。
这篇用自写的 stub server 把这条报错拆开:到底是字段多了还是少了,为什么同一个版本有人中招有人没事,降级为什么能好,以及用户侧和 server 侧各该怎么修。所有报错原文都来自 2026-10-03 在本机的真实运行。
问题分析
先看规范。MCP 的 2026-07-28 版 schema 里,ListToolsResult 同时继承了 PaginatedResult 和 CacheableResult:
export interface CacheableResult extends Result {
ttlMs: number; // 客户端可缓存多少毫秒,0 表示立即过期
cacheScope: "public" | "private"; // 类似 HTTP Cache-Control 的 public / private
}
export interface Result {
_meta?: ResultMetaObject;
resultType: ResultType; // "complete" | "input_required" | string,本版起必填
[key: string]: unknown; // 允许任意额外字段
}
这里有两个关键点:
ttlMs、cacheScope、resultType在这一版里都是必填。Result带[key: string]: unknown,规范明确允许额外字段。
再看报错本身:"expected": "number" + invalid_type 指向 ttlMs,invalid_value + ["public","private"] 指向 cacheScope。字段如果是「多出来的未知字段」,校验器不会知道它该是 number、该在 public/private 里选。只有在按 2026-07-28 的 schema 校验、而 server 没给这两个字段时,才会是这个形状。2.1.288 的措辞更直白:expected number, received undefined。
所以要验证的假设是:server 和 Claude Code 协商到了 2026-07-28,但 server 的 tools/list 仍按旧版协议回包,漏了新版的必填字段。 还要回答两个问题:stdio server 什么时候会协商到 2026-07-28?降级到 2.1.280 为什么能好?
技术方案与选型
- 被测对象:Claude Code 2.1.280(issue 评论里「最后一个能用的版本」)、2.1.285(本机日常版本)、2.1.288(10-02 发布的最新版)。2.1.280 和 2.1.288 用
npm install装在隔离目录,不碰全局安装。 - MCP server:自写 Node stdio stub,约 70 行,逐行记录收到的 JSON-RPC。用环境变量切换各种回包:
initialize回什么协议版本、是否实现server/discover、tools/list 里给不给resultType/ttlMs/cacheScope、给错类型、加未知字段。 - 模型侧:本地假的 Messages API(固定回一句
STUB_OK),同时充当HTTPS_PROXY,记录所有 CONNECT 并回 403。整个实验零真实 API 调用:stub 共记录到 97 次CONNECT api.anthropic.com:443,全部被拦。 - 读数:
--output-format stream-json --verbose第一行init里的mcp_servers[].status和tools里有没有mcp__ttlstub__ping,加上--debug-file里该 server 的日志。每个 case 用独立的HOME,cwd 放在仓库外。
实测过程
Case 0:默认配置下,stdio 根本不走新协议
不设任何环境变量,用 2.1.285 跑。stub 收到的第一条消息:
{"method":"initialize","params":{"protocolVersion":"2025-11-25", ...},"jsonrpc":"2.0","id":0}
序列是 initialize → notifications/initialized → tools/list,走的是老握手。客户端提出的版本是 2025-11-25,工具正常拿到。
也就是说,在代码默认值下(远程开关没打开),stdio MCP server 根本碰不到 2026-07-28。 开关在你的账号上开没开,本地看不到,下面 Case 2 讲它在哪。
Case 1:老握手里回 2026-07-28 会直接失败
如果 server 在 initialize 里不照抄客户端版本,而是硬回 2026-07-28:
MCP server "ttlstub": Connection failed after 29ms: Server's protocol version is not supported: 2026-07-28
这时状态是 failed,不是 connected。所以 issue 里那种「connected + 0 工具」不是从老握手进入的。
Case 2:打开协商的开关在哪
在 2.1.285 的二进制里检索 MCP_PROTOCOL_NEGOTIATION,可以找到决定协商模式的函数。整理后的逻辑如下:
// MCP_PROTOCOL_NEGOTIATION 只接受 'legacy' 或 'auto',其它值警告后忽略
if (env === "legacy") return { mode: "legacy" };
if (env === "auto") return { mode: "auto", ... }; // http / claudeai-proxy / ccr-proxy / stdio
switch (transport) {
case "http": return flag("tengu_mcp_protocol_negotiation_http", true) ? auto : legacy;
case "stdio": return flag("tengu_mcp_protocol_negotiation_stdio", false) ? auto : legacy;
// sse / ws / ide / in-process / sdk-control 一律 legacy
}
stdio 走不走新协议,取决于一个默认值为 false 的远程开关 tengu_mcp_protocol_negotiation_stdio。2.1.280 和 2.1.288 里这段逻辑完全相同,默认值也都是 false。环境变量 MCP_PROTOCOL_NEGOTIATION=auto 可以在本地强制打开它,下面的 case 都靠它进入新协议。
Case 3:打开协商,但 server 不支持 server/discover,会平稳回退
MCP_PROTOCOL_NEGOTIATION=auto,stub 对 server/discover 回 -32601 Method not found:
stub 收到: server/discover → initialize → notifications/initialized → tools/list
"protocolEra":"legacy","negotiatedProtocolVersion":"2025-11-25"
客户端先探测 server/discover,失败后回到老握手,工具正常。不声称支持新协议的 server 是安全的。
Case 4:server 声称支持 2026-07-28,tools/list 却按老格式回
stub 在 server/discover 里回 supportedVersions: ["2026-07-28"],tools/list 只回 { tools: [...] }:
"protocolEra":"modern","negotiatedProtocolVersion":"2026-07-28"
tools/list failed (Invalid result for tools/list: missing required resultType — servers implementing protocol revision 2026-07-28 MUST include it (the absent-means-complete bridge applies only to earlier-revision servers)); retrying in 250ms
...
[ERROR] Failed to fetch tools: Invalid result for tools/list: missing required resultType — ...
init: status=connected mcp 工具数=0
这就是 issue 里的现象:连接成功,工具为 0。第一个被拦下的字段是 resultType。tools/list 一共请求了 4 次(首次加 250/500/1000ms 三次重试),之后整个会话不再尝试。
Case 5:补上 resultType,就是 issue 里那句原文
stub 补上 resultType: "complete",仍然不给 ttlMs / cacheScope:
图:同一个 stub(回 resultType,不回 ttlMs / cacheScope),MCP_PROTOCOL_NEGOTIATION=auto 下三个版本的读数。2.1.280 的措辞是 "Invalid input",和 issue 里贴的日志一字不差;2.1.285 / 2.1.288 多了 received undefined。三个版本都是 connected + 0 个工具。
这一组还能顺带回答「降级到 2.1.280 为什么能好」:2.1.280 的校验一点也不宽松,打开协商后它照样失败。降级之所以有用,只能是因为那台机器上 2.1.280 的远程开关是关的,于是走了老握手。这一点我在本机无法直接观察(本机账号的配置缓存里没有这类开关),属于推断。但它和代码逻辑、issue 评论里贴的 Roblox 开发者论坛帖子(链接标题是 roblox-studio-mcp-works-up-to-claude-code-21280-broken-from-21281)都对得上:同一段代码,远程开关按版本放量。
Case 6:逐个字段排查
都在 2.1.285 + MCP_PROTOCOL_NEGOTIATION=auto + discover 声称 2026-07-28 的条件下:
| tools/list 回包 | 结果 | 报错关键片段 |
|---|---|---|
只有 tools |
0 工具 | missing required resultType |
+ resultType |
0 工具 | ttlMs expected number, received undefined;cacheScope invalid_value |
+ resultType + ttlMs: 60000 |
0 工具 | 只剩 cacheScope invalid_value |
+ resultType + ttlMs: "60000"(字符串)+ cacheScope: "shared" |
0 工具 | ttlMs expected number, received string;cacheScope invalid_value |
+ resultType + ttlMs: 60000 + cacheScope: "public" |
正常 | — |
上一行再加一个未知字段 x-roblox-meta: {...} |
正常 | — |
resultType: "complete" + ttlMs: 0 + cacheScope: "private"(2.1.288) |
正常 | — |
倒数第二行直接否定了 issue 标题里的说法:额外的未知字段不会被拒,缺必填字段才会。 另外,server/discover 响应自身缺 ttlMs 时,客户端没有拒绝协商(这几组 stub 的 discover 都没给),问题只出在 tools/list 上。
Case 7:用户侧的两种关法
# 临时:只对这一次启动生效
MCP_PROTOCOL_NEGOTIATION=legacy claude
# 持久:写进 ~/.claude/settings.json
{ "env": { "MCP_PROTOCOL_NEGOTIATION": "legacy" } }
图:2.1.288,同一个「声称 2026-07-28 却漏字段」的 server,两种协商模式下 claude mcp list 的输出。
关于 settings.json,我做了两组验证(2.1.288):
- 进程不设变量、settings.json 的
env写auto→ 进入 modern,失败。说明 settings 里的这个变量会被读取。 - 进程设
auto、settings.json 写legacy→ 走老握手,正常。说明 settings 里的值覆盖了进程环境变量。
所以持久化写 settings.json 是可靠的,比在 shell 配置里 export 更不容易漏(IDE 插件、桌面端通常不经过你的 shell 配置启动)。
实践效果
一句话结论
Invalid result for tools/list + ttlMs / cacheScope 的意思是:这个 server 告诉 Claude Code 它支持 MCP 2026-07-28,但回包没按 2026-07-28 的格式来。Claude Code 按规范拒收,并不是「过度严格」。
用户侧:先恢复工具,再等 server 更新
- 设
MCP_PROTOCOL_NEGOTIATION=legacy(推荐写进 settings.json 的env)。立即生效,不用降级,也不怕自动更新把版本拉回去。代价是所有 stdio / http server 都走老握手,暂时用不上 2026-07-28 的新能力(比如 2.1.281 加入的 URL 模式 elicitation)。 - 升级 server。Roblox 这个案例里,评论区确认 Roblox Studio 0.740.19.7400003 已修复。
- 不建议靠降级:降级是否有效取决于远程开关,不受你控制;而且 2.1.280 打开协商后同样失败(Case 5)。
确认是不是这个问题,不用开 --debug。macOS 上 Claude Code 默认就会把每个 server 的连接日志写到这里:
ls ~/Library/Caches/claude-cli-nodejs/<项目路径>/mcp-logs-<server名>/
# Windows:%LOCALAPPDATA%\claude-cli-nodejs\Cache\<项目路径>\mcp-logs-<server名>\
日志里同时出现 "protocolEra":"modern" 和 Failed to fetch tools: Invalid result for tools/list,基本就能确诊。
server 作者:三个字段,或者先别声称支持
- 在
server/discover的supportedVersions里写了2026-07-28,那么 tools/list(以及 resources/list、prompts/list 等CacheableResult系列)每个结果都要带上:{ "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "tools": [ ... ] }ttlMs: 0表示「立即过期,客户端每次可重取」,private表示「不跨授权上下文共享缓存」。这是最保守的取值,2.1.288 实测可用。 - 还没实现新版协议,就让
server/discover回-32601(Case 3)。客户端会平稳回退到老握手。 - 老握手的
initialize里不要硬回2026-07-28(Case 1),会直接Server's protocol version is not supported。
速查表
| 看到的现象 | 原因 | 处理 |
|---|---|---|
! Connected · tools fetch failed — Invalid result for tools/list: ... ttlMs ... cacheScope |
server 协商到 2026-07-28,却漏了 ttlMs / cacheScope |
用户:MCP_PROTOCOL_NEGOTIATION=legacy;server:补字段 |
missing required resultType — servers implementing protocol revision 2026-07-28 MUST include it |
同上,漏的是 resultType |
同上 |
ttlMs ... received string |
字段给了,类型错(字符串) | server 改成 number |
Connection failed ... Server's protocol version is not supported: 2026-07-28 |
老握手里硬回新版本号 | server 在 initialize 里回客户端提出的版本 |
| 同一个版本,同事好好的,你的 0 工具 | stdio 协商由远程开关控制,各人放量不同 | 同第一行 |
踩坑
- 别被 issue 标题带偏。 「strict validation of extra fields」这个说法很自然,因为字段名在新版协议里,看起来像是多出来的。但 zod 报错里的
expected number/values: [public, private]说明客户端知道这些字段的定义,问题在于缺失。加未知字段那组实测是正常的。 - 「降级就好了」不等于老版本没有这个问题。 同一段逻辑、同一个默认值,差别只在远程开关。拿「换版本前后」做对照的结论都要打折扣,变量不止版本这一个。
- 重试只发生在连接建立时。 tools/list 失败后重试 3 次(250/500/1000ms),之后整个会话保持 0 工具。在 2.1.288 的
-p里,失败 case 的墙钟约 2.46s,正常 case 约 0.36s,多出来的基本就是这几次重试。 - 实验本身的坑:第一轮读
--debug-file时,用 grep 过滤MCP server "ttlstub",结果把报错行全漏了。这个版本把带换行的日志消息写成了 JSON 字符串("MCP server \"ttlstub\": ...",引号被转义),按原文 grep 匹配不上。后来改成先 JSON 解码再匹配。另外,第一个 case 的 cwd 放在了仓库里,Claude Code 加载了项目的.claude/配置,那一组作废重跑,cwd 移到/tmp。
相关阅读
- Claude Code MCP server Failed to connect 怎么排查:CONNECTION_CLOSED、connection timed out after 30000ms、ENOENT 逐条实测
- Claude Code MCP:连接外部工具和数据源
- MCP Server 把 CPU 吃满 100%:一次 Cloudflare MCP 失控进程的排查与止血
- Claude Code 报错 Invalid API key · Fix external API key 怎么排查:Not logged in、Credit balance is too low、API Error 401/429/529 原文与重试实测