Claude Code MCP Shows Connected but 0 Tools: "Invalid result for tools/list" (ttlMs / cacheScope), Reproduced and Fixed
The problem
You configure an MCP server, and claude mcp list does not say ✘ Failed to connect. It shows a yellow exclamation mark instead:
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\"" } ]
Inside a session, the server status is connected, yet the model cannot see a single tool from it. Restarting, opening a new session or rebooting does not help.
The matching GitHub issue is anthropics/claude-code#97319 (opened 2026-09-26, still OPEN on 10-03), triggered by Roblox Studio's official MCP bridge. The reporter's diagnosis: the tools/list response carries extra fields from a newer protocol revision (ttlMs, cacheScope), and Claude Code's validation is too strict, so it throws away the whole response. Two workarounds came up in the comments: downgrade to 2.1.280, or set MCP_PROTOCOL_NEGOTIATION=legacy.
This article takes the error apart with a hand-written stub server. Are the fields extra or missing? Why does the same version break for some people and not others? Why does downgrading help? And what should users and server authors each do? Every error message below comes from real runs on my machine on 2026-10-03.
Analysis
Start with the spec. In the MCP 2026-07-28 schema, ListToolsResult extends both PaginatedResult and CacheableResult:
export interface CacheableResult extends Result {
ttlMs: number; // how many ms the client may cache this; 0 = immediately stale
cacheScope: "public" | "private"; // like HTTP Cache-Control public / private
}
export interface Result {
_meta?: ResultMetaObject;
resultType: ResultType; // "complete" | "input_required" | string; required from this revision
[key: string]: unknown; // arbitrary extra fields are allowed
}
Two things matter here:
ttlMs,cacheScopeandresultTypeare all required in this revision.Resulthas[key: string]: unknown, so the spec explicitly allows extra fields.
Now look at the error. "expected": "number" with invalid_type points at ttlMs; invalid_value with ["public","private"] points at cacheScope. If these were unknown extra fields, the validator would have no idea one should be a number and the other one of public/private. The error has this shape only when the client validates against the 2026-07-28 schema and the server did not send those fields. 2.1.288 says it outright: expected number, received undefined.
So the hypothesis to test: the server and Claude Code negotiated 2026-07-28, but the server's tools/list still answers in the old format and omits the new required fields. Two more questions need answers: when does a stdio server end up on 2026-07-28 at all, and why does downgrading to 2.1.280 help?
Setup
- Versions under test: Claude Code 2.1.280 (the "last working version" from the issue comments), 2.1.285 (my daily version) and 2.1.288 (latest, released 10-02). 2.1.280 and 2.1.288 were installed with
npm installinto isolated directories, leaving the global install alone. - MCP server: a Node stdio stub of about 70 lines that logs every JSON-RPC message it receives. Environment variables switch its replies: which protocol version
initializereturns, whetherserver/discoveris implemented, whether tools/list includesresultType/ttlMs/cacheScope, wrong types, unknown extra fields. - Model side: a local fake Messages API that always answers
STUB_OK. It also acts asHTTPS_PROXY, logging every CONNECT and answering 403. The whole experiment made zero real API calls: the stub logged 97CONNECT api.anthropic.com:443attempts, all blocked. - Readings: from the first
initline of--output-format stream-json --verbose,mcp_servers[].statusand whethertoolscontainsmcp__ttlstub__ping; plus that server's lines in--debug-file. Each case got its ownHOME, with the working directory outside the repo.
Experiments
Case 0: by default, stdio does not use the new protocol at all
With no environment variables, on 2.1.285, the first message the stub receives is:
{"method":"initialize","params":{"protocolVersion":"2025-11-25", ...},"jsonrpc":"2.0","id":0}
The sequence is initialize → notifications/initialized → tools/list, the old handshake. The client proposes 2025-11-25, and the tools load fine.
So with the code defaults (remote flag off), a stdio MCP server never touches 2026-07-28. Whether the flag is on for your account isn't visible locally; Case 2 shows where it lives.
Case 1: answering 2026-07-28 in the old handshake fails outright
If the server does not echo the client's version in initialize and replies 2026-07-28 instead:
MCP server "ttlstub": Connection failed after 29ms: Server's protocol version is not supported: 2026-07-28
The status here is failed, not connected. So the "connected but 0 tools" state from the issue does not come from the old handshake.
Case 2: where negotiation gets switched on
Searching the 2.1.285 binary for MCP_PROTOCOL_NEGOTIATION leads to the function that picks the negotiation mode. Cleaned up, the logic is:
// MCP_PROTOCOL_NEGOTIATION only accepts 'legacy' or 'auto'; anything else is warned about and ignored
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 are always legacy
}
Whether stdio uses the new protocol is decided by a remote flag whose default is false, tengu_mcp_protocol_negotiation_stdio. This code is identical in 2.1.280 and 2.1.288, default included. Setting MCP_PROTOCOL_NEGOTIATION=auto forces it on locally, which is how every case below gets into the new protocol.
Case 3: negotiation on, but the server has no server/discover, falls back cleanly
With MCP_PROTOCOL_NEGOTIATION=auto and the stub answering server/discover with -32601 Method not found:
stub received: server/discover → initialize → notifications/initialized → tools/list
"protocolEra":"legacy","negotiatedProtocolVersion":"2025-11-25"
The client probes server/discover first, falls back to the old handshake when that fails, and the tools load. A server that does not claim the new protocol is safe.
Case 4: the server claims 2026-07-28 but answers tools/list in the old format
The stub's server/discover returns supportedVersions: ["2026-07-28"], and tools/list returns only { 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 tools=0
This is the symptom from the issue: connected, 0 tools. The first field it trips on is resultType. tools/list is requested 4 times (the first call plus retries at 250/500/1000 ms), and then never again for the rest of the session.
Case 5: add resultType, and you get the exact message from the issue
The stub now includes resultType: "complete" but still no ttlMs / cacheScope:
Figure: the same stub (sends resultType, omits ttlMs / cacheScope) under MCP_PROTOCOL_NEGOTIATION=auto on three versions. 2.1.280 says "Invalid input", word for word what the issue's log shows; 2.1.285 and 2.1.288 add received undefined. All three end up connected with 0 tools. (The "stub 收到" line means "stub received".)
This also answers why downgrading to 2.1.280 helps: 2.1.280's validation is not any looser. With negotiation on, it fails the same way. Downgrading can only help because the remote flag was off for 2.1.280 on that machine, so it used the old handshake. I cannot observe that directly here (my account's cached config has no such flags), so this part is inference. It does line up with the code and with the Roblox developer forum thread linked in the issue comments, whose URL reads roblox-studio-mcp-works-up-to-claude-code-21280-broken-from-21281: same code, with the remote flag rolled out by version.
Case 6: one field at a time
All on 2.1.285 with MCP_PROTOCOL_NEGOTIATION=auto and discover claiming 2026-07-28:
| tools/list reply | Result | Key part of the error |
|---|---|---|
tools only |
0 tools | missing required resultType |
+ resultType |
0 tools | ttlMs expected number, received undefined; cacheScope invalid_value |
+ resultType + ttlMs: 60000 |
0 tools | only cacheScope invalid_value |
+ resultType + ttlMs: "60000" (string) + cacheScope: "shared" |
0 tools | ttlMs expected number, received string; cacheScope invalid_value |
+ resultType + ttlMs: 60000 + cacheScope: "public" |
works | — |
the row above plus an unknown field x-roblox-meta: {...} |
works | — |
resultType: "complete" + ttlMs: 0 + cacheScope: "private" (2.1.288) |
works | — |
The second-to-last row contradicts the issue title directly: unknown extra fields are not rejected; missing required fields are. Also, when the server/discover reply itself lacks ttlMs, the client still negotiates (none of these stubs sent it in discover). The failure is specific to tools/list.
Case 7: two ways to turn it off on the user side
# One-off: applies to this launch only
MCP_PROTOCOL_NEGOTIATION=legacy claude
# Persistent: put it in ~/.claude/settings.json
{ "env": { "MCP_PROTOCOL_NEGOTIATION": "legacy" } }
Figure: 2.1.288, the same server that claims 2026-07-28 but omits fields, claude mcp list under both negotiation modes.
For settings.json I checked two combinations (2.1.288):
- Nothing in the process environment,
autoin settings.jsonenv→ modern, fails. So this variable is read from settings. autoin the process environment,legacyin settings.json → old handshake, works. So the settings value overrides the process environment.
Writing it into settings.json is therefore reliable, and harder to miss than an export in your shell profile (IDE extensions and the desktop app usually don't start through your shell profile).
Results
In one sentence
Invalid result for tools/list with ttlMs / cacheScope means: this server told Claude Code it speaks MCP 2026-07-28, but its replies don't follow the 2026-07-28 format. Claude Code rejects them as the spec says it should. It is not "overly strict".
Users: get the tools back first, then wait for the server update
- Set
MCP_PROTOCOL_NEGOTIATION=legacy(preferably in settings.jsonenv). It takes effect immediately, needs no downgrade, and survives auto-updates. The cost: every stdio / http server uses the old handshake, so 2026-07-28 features (such as URL-mode elicitation added in 2.1.281) are unavailable for now. - Update the server. In the Roblox case, a commenter confirmed Roblox Studio 0.740.19.7400003 fixes it.
- Don't rely on downgrading: whether it works depends on a remote flag you don't control, and 2.1.280 fails the same way once negotiation is on (Case 5).
You don't need --debug to confirm the diagnosis. On macOS, Claude Code writes every server's connection log here by default:
ls ~/Library/Caches/claude-cli-nodejs/<project-path>/mcp-logs-<server-name>/
# Windows: %LOCALAPPDATA%\claude-cli-nodejs\Cache\<project-path>\mcp-logs-<server-name>\
If the log has both "protocolEra":"modern" and Failed to fetch tools: Invalid result for tools/list, that's almost certainly it.
Server authors: three fields, or don't claim the version yet
- If
server/discoverlists2026-07-28insupportedVersions, every tools/list result (and resources/list, prompts/list and the otherCacheableResultreplies) must include:{ "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "tools": [ ... ] }ttlMs: 0means "immediately stale, the client may refetch every time";privatemeans "never share the cache across authorization contexts". These are the most conservative values, and they work on 2.1.288. - If you haven't implemented the new revision, answer
server/discoverwith-32601(Case 3). The client falls back to the old handshake cleanly. - In the old handshake's
initialize, don't hard-code2026-07-28(Case 1); you'll getServer's protocol version is not supported.
Quick reference
| What you see | Cause | Fix |
|---|---|---|
! Connected · tools fetch failed — Invalid result for tools/list: ... ttlMs ... cacheScope |
server negotiated 2026-07-28 but omits ttlMs / cacheScope |
user: MCP_PROTOCOL_NEGOTIATION=legacy; server: add the fields |
missing required resultType — servers implementing protocol revision 2026-07-28 MUST include it |
same, but resultType is missing |
same |
ttlMs ... received string |
field present with the wrong type | server: send a number |
Connection failed ... Server's protocol version is not supported: 2026-07-28 |
old handshake answered with the new version | server: answer initialize with the version the client proposed |
| same version, a colleague is fine, you have 0 tools | stdio negotiation is controlled by a remote flag rolled out per user | same as the first row |
Gotchas
- Don't let the issue title steer you. "Strict validation of extra fields" sounds natural, since the field names belong to the newer revision and look like additions. But
expected number/values: [public, private]in the zod error shows the client knows exactly what these fields should be. They are missing. The run with an unknown extra field works fine. - "Downgrading fixed it" does not mean the old version lacks the problem. Same logic, same default; the only difference is the remote flag. Any before/after comparison across versions needs a discount, because the version isn't the only variable that changed.
- Retries only happen while connecting. After tools/list fails, it is retried 3 times (250/500/1000 ms), and the session then stays at 0 tools. In 2.1.288's
-pmode, the failing case took about 2.46 s wall time against about 0.36 s for the working one; the difference is essentially those retries. - Mistakes in the experiment itself: on my first pass over
--debug-file, I grepped forMCP server "ttlstub"and missed every error line. This version writes log messages that contain newlines as JSON strings ("MCP server \"ttlstub\": ...", quotes escaped), so a literal grep doesn't match; I switched to JSON-decoding each line before matching. Separately, my first case ran with its cwd inside the repo, so Claude Code loaded the project's.claude/config. I discarded that run and moved every cwd to/tmp.
Related reading
- Claude Code MCP server Failed to connect: CONNECTION_CLOSED, connection timed out after 30000ms and ENOENT, reproduced one by one
- Claude Code MCP: Connect External Tools and Data Sources
- Claude Code "Invalid API key · Fix external API key": Not logged in, Credit balance is too low, API Error 401/429/529 — Exact Messages and Retry Behavior, Tested