MCP tool_reference scrub invalidates the prompt cache when servers are still `pending` on a fresh CLI process
Summary
When a CLI process starts and MCP servers are still connecting, the first request is sent with those servers' tools absent from the tools array. The CLI then strips every tool_reference in the conversation history that points at those tools, replacing the tool_result content with [Tool references removed - tools no longer available].
Because prompt caching is a strict prefix match, rewriting an early message invalidates the whole cached body from that point on. We measure the mutation landing at message 6 of 85, i.e. a 65–93% blast radius, and the next request's cache_read collapsing to exactly the cached system/tools head.
On the following turn the servers have finished connecting, the tools come back, and the history is un-scrubbed — so the cache is destroyed and immediately rebuilt for no benefit.
This is the same class of bug as two you have already fixed:
2.1.243— "Fixed whole-prompt-cache invalidation when a language server disconnected or reconnected mid-session"2.1.250— "Fixed a prompt-cache miss ... caused by tool definitions being re-rendered after an OAuth token refresh"
Why this is not a connect-timeout problem
MCP_CONNECTION_NONBLOCKING=1 is set deliberately (servers report pending in init and resolve lazily via ToolSearch). Non-blocking connect is exactly what allows the first query on a fresh process to ship before the servers are ready. Raising MCP_TIMEOUT / MCP_CONNECT_TIMEOUT_MS does not help, because the process does not wait regardless.
Process re-inits are routine, not exceptional — we logged 12 CLI inits in 5 hours for a single conversation, all resuming the same session id.
The code (from the 2.1.251 bundled binary)
let u = (y, v) => t.has(Vd(v)) && !r?.has(Ioe(y, v)); // t = tool names available in THIS request
...
let v = y.content.filter((A) => {
if (!IY(A)) return true;
let R = A.tool_name;
if (!R) return true;
let O = u(y.tool_use_id, R);
if (!O) n(`Filtering out tool_reference for unavailable tool: ${Vd(R)}`, { level: "warn" });
return O;
});
if (v.length === 0)
return { ...y, content: [{ type: "text", text: "[Tool references removed - tools no longer available]" }] };
The availability test is pure set membership against the current request's tool names. There is no distinction between a server that is pending (transiently absent, will return in seconds) and one that has genuinely failed or been removed. The scrub is correct behaviour for the second case and harmful for the first.
Evidence
Captured from Bedrock model-invocation request bodies. Consecutive requests within one conversation, ordered by message count:
msgs= 49 tools=12 mcp=[server-a]
msgs= 51 tools=10 mcp=NONE <== SCRUBBED
msgs= 53 tools=12 mcp=[server-a]
msgs= 17 tools=10 mcp=NONE <== SCRUBBED
msgs= 19 tools=14 mcp=[server-a, server-b]
msgs= 21 tools=14 mcp=[server-a, server-b]
msgs= 23 tools=10 mcp=NONE <== SCRUBBED
msgs= 25 tools=15 mcp=[server-a, server-b]
Across 1,149 large request bodies sampled over 5 hours:
| tools in request | requests | scrubbed | rate |
|---|---|---|---|
| zero MCP tools | 708 | 42 | 5.9% |
| MCP tools present | 441 | 3 | 0.7% |
All 45 scrubbed requests were missing MCP tools they had previously had: 42 carried none at all, and the remaining 3 carried exactly one — a process caught mid-connect, with some servers up and others not.
Independently, on the proxy side, requests in this state show cache_read pinned at exactly the system/tools head size while writing 130k–215k tokens, versus ~3k tokens written when the body caches normally.
Impact
- Cache: a full body rewrite on ~4% of large requests. Roughly 43% of these occur while the cache would otherwise still be alive, so they are pure waste.
- Capability (arguably worse): on those turns the agent runs with no MCP tools at all. It cannot call any MCP-backed integration, with no error surfaced — it simply behaves as though those capabilities do not exist, mid-conversation.
Suggested fix
Do not scrub tool_reference blocks when the owning server is pending rather than failed/removed. The reference is only transiently dangling; leaving it intact for a turn or two costs nothing and preserves the prefix.
Alternatives, in preference order:
- Treat
pendingservers as available for the purposes of the history-scrub check. - Defer the scrub until a server reaches a terminal non-connected state.
- Hold the first query until MCP connection status is terminal for every server referenced in history (costs first-turn latency; less desirable).
Environment
- Observed on CLI 2.1.185 (bundled in
claude-agent-sdk0.2.106, Python), Linux x64 - Still present in 2.1.251 — verified by extracting the function above from the published
@anthropic-ai/claude-code-linux-x64@2.1.251binary; the marker strings (Filtering out tool_reference for unavailable tool,Tool references removed - tools no longer available,Tool references removed - tool search not enabled) appear with identical counts in both versions - Multiple MCP servers configured (stdio + HTTP), tool search / deferred loading enabled
- Requests proxied to Bedrock (
claude-sonnet-4-6), prompt caching enabled with a 1h breakpoint on the system/tools head and 5m on the body
Reproduction sketch
- Configure 2+ MCP servers, at least one slow to enumerate (a large
tools/list). - Run a conversation long enough that the model calls MCP tools, so
tool_referenceblocks land in history. - Force a fresh CLI process to resume that session (kill the holder / route the next turn to a new process).
- Inspect the outgoing request for that first turn: MCP tools are absent from
tools, and historytool_resultblocks have been replaced with[Tool references removed - tools no longer available]. - The next turn has the tools back — and pays a full cache-write for the entire body.