Magic Tools
Pitfall NotesBy CooconOctober 3, 202614 views8 min read

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:

  1. ttlMs, cacheScope and resultType are all required in this revision.
  2. Result has [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 install into 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 initialize returns, whether server/discover is implemented, whether tools/list includes resultType / ttlMs / cacheScope, wrong types, unknown extra fields.
  • Model side: a local fake Messages API that always answers STUB_OK. It also acts as HTTPS_PROXY, logging every CONNECT and answering 403. The whole experiment made zero real API calls: the stub logged 97 CONNECT api.anthropic.com:443 attempts, all blocked.
  • Readings: from the first init line of --output-format stream-json --verbose, mcp_servers[].status and whether tools contains mcp__ttlstub__ping; plus that server's lines in --debug-file. Each case got its own HOME, 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:

Claude Code 2.1.280, 2.1.285 and 2.1.288 report the same ttlMs / cacheScope validation error against the same stub 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" } }

With MCP_PROTOCOL_NEGOTIATION=auto, mcp list shows ! Connected · tools fetch failed; with legacy, ✔ Connected 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, auto in settings.json env → modern, fails. So this variable is read from settings.
  • auto in the process environment, legacy in 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

  1. Set MCP_PROTOCOL_NEGOTIATION=legacy (preferably in settings.json env). 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.
  2. Update the server. In the Roblox case, a commenter confirmed Roblox Studio 0.740.19.7400003 fixes it.
  3. 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/discover lists 2026-07-28 in supportedVersions, every tools/list result (and resources/list, prompts/list and the other CacheableResult replies) must include:
    { "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "tools": [ ... ] }
    
    ttlMs: 0 means "immediately stale, the client may refetch every time"; private means "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/discover with -32601 (Case 3). The client falls back to the old handshake cleanly.
  • In the old handshake's initialize, don't hard-code 2026-07-28 (Case 1); you'll get Server'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 -p mode, 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 for MCP 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.

Get field notes like this every Saturday

Subscribe to Dev Breakfast: daily AI coding picks at 8:00, plus a Saturday roundup of this week's hands-on tests with Claude Code / Codex / local models. Written in Chinese.

Related Articles

Clash Verge TUN Mode Breaks All Internet Access: Hysteria2 Traffic Loops Back Into the TUN, and Tailscale Hijacks DNS

Clash Verge Rev 2.5.6 on macOS works fine in system-proxy mode, but as soon as TUN (virtual network adapter) mode is on, not even Baidu loads. Debugging through the mihomo core's API turned up two independent root causes stacked together. First, Hysteria2's outbound UDP isn't bound to the physical interface, so the TUN routes pull it back in and it loops. Second, Tailscale MagicDNS (100.100.100.100) has taken over system DNS, so queries go out through Tailscale's utun where Clash's dns-hijack can't see them, and come back poisoned. The fix is two Merge overrides: route-exclude-address to keep node IPs out of the TUN, and sniffer to recover domains from SNI. Every step comes with the commands and real output.

troubleshootingtailscale+7
pitfallsOct 4, 20266 min
23
Claude Code install errors, reproduced: EACCES, a 600s mirror stall, Node 20 silently getting an old version, a region-block install.sh, and the native installer removing your npm copy

Claude Code install errors, reproduced: EACCES, a 600s mirror stall, Node 20 silently getting an old version, a region-block install.sh, and the native installer removing your npm copy

I reproduced every Claude Code install failure I could on macOS: 15 verbatim errors, each with wall time and exit code. npm -g into /usr/local fails with EACCES, exit 243. A cache dir that is merely 0555 gets blamed on root-owned files, with sudo chown advice. From Beijing, npmmirror took 147s and then >600s, npmjs 11-12s (2 samples each). On Node 20, an unpinned install silently lands on 2.1.197. Fetching claude.ai/install.sh from a blocked region gives curl exit 0 and a 447 KB HTML page. The native installer runs npm uninstall -g on your npm copy without saying so; it removed mine.

claude-codetroubleshooting+5
pitfallsSep 29, 202611 min
150
DeepSeek Harness Test: One Model, Three Harnesses — Claude Code 15/15, Codex CLI 15/15, Bare API 0/15 (and 5 Fake "Done"s)

DeepSeek Harness Test: One Model, Three Harnesses — Claude Code 15/15, Codex CLI 15/15, Bare API 0/15 (and 5 Fake "Done"s)

Same DeepSeek model (deepseek-v4-pro), three harnesses, five tasks (read / write / edit / run a command / multi-step), three rounds each, every side effect checked on disk. Claude Code on DeepSeek's Anthropic endpoint: 15/15, median 4.17s, ¥0.159 per task. Codex CLI 0.157.1 on the Responses endpoint: 15/15, median 15.52s, ¥0.022 per task — one seventh. Bare chat/completions: 0/15, and 5 of those rounds replied DONE or EDITED with nothing on disk. The differences are the harness: DeepSeek partitions its prompt cache by metadata.user_id, so every `claude -p` pays ~15K uncached tokens; Codex has no file tools and does everything through shell; on HTTP 500 Claude Code retries 10 times over ~175s while Codex quits in ~25s, and on 429 Codex doesn't retry; on 120KB of output Claude Code shows the first 2KB, Codex head + tail. And wire_api = "chat" is gone in Codex 0.157.1 — use responses.

claude-codeprompt-caching+6
hands-onSep 28, 202613 min
188
Claude Code "bash denied by auto mode": Why It Blocks, "could not evaluate" and "unavailable for this model" Tested

Claude Code "bash denied by auto mode": Why It Blocks, "could not evaluate" and "unavailable for this model" Tested

In auto mode, a blocked Bash call usually shows one of three messages: denied by auto mode, Auto mode could not evaluate this action, or auto mode unavailable for this model. I ran 40-odd real sessions on Claude Code 2.1.280. denied means the classifier judged the action out of scope, most often [Code from External]: it would run external code you never named. Retrying won't help. Name the source in your prompt, or declare it trusted in autoMode.environment in user-level settings. could not evaluate means the classifier returned no usable verdict. unavailable for this model means the model is older than claude-opus-4-6; under claude -p it is silently downgraded, with only a WARN line in the debug log. In 2.1.280 the verdict is computed server-side and returned with the main response. Behind a relay gateway that only admits Claude Code clients, the local fallback classifier request gets a 503, which is a reliable cause of "temporarily unavailable (server error)".

claude-codepermissions+4
pitfallsSep 27, 202611 min
138

Published by Magic Tools