Race between initialize control_request and MCP refresh causes tools to drop from first request
What breaks
When the CLI is driven by the Agent SDK with sdkMcpServers configured, the first messages.create sometimes ships without the SDK MCP tools. The tools[] array then differs between Turn 1 and Turn 2 of the same conversation. Since tools[] is the first byte segment of Anthropic's prompt cache prefix, the cache misses on Turn 2.
In our runs this happens on roughly 50% of CLI spawns. Cache hit rate dropped from ~98% to ~82% on a workload where every turn should be a full prefix read.
Versions
@anthropic-ai/claude-code: reproduced on 2.1.77 and 2.1.110@anthropic-ai/claude-agent-sdk-typescript: 0.2.108- Transport: stdin to CLI subprocess
Cause
The SDK writes two stdin payloads back-to-back with no await between them:
{ control_request: "initialize", sdkMcpServers: [...] }- The first user message
Inside the CLI (minified symbols from cli.js):
dHAgenerator callsH6()unawaited at the topH6reads the MCP server mapOat entry- If
H6runs before the CLI drains theinitializemessage,Ois still{} H6skips the MCP refresh branch- The first
messages.createis sent with no SDK MCP tools - Later the
initializemessage arrives, fillsO, and a secondH6runs via theh6handler, but the wire request has already been sent
SDK CLI
--- ---
spawn CLI ---> boot, dHA starts
H6() fires (unawaited), O={}, tools = N
write initialize to stdin ---> buffered
write user message to stdin ---> buffered
CLI drains stdin. Two orderings:
A) initialize first -> O populated ->
h6 handler fires H6 -> tools = N+M
request ships with N+M tools, cache OK
B) user message first -> dHA continues
with tools = N
request ships with N tools, cache breaks
Proposed fix
Add an explicit gate inside dHA:
// At the top of dHA
let __initDone = false;
let __initResolve;
const __initPromise = new Promise(r => __initResolve = r);
// H6 awaits the gate before proceeding
async function H6() {
if (!__initDone) {
await __initPromise;
}
// existing H6 body follows
}
// initialize control_request handler resolves the gate
// after O is populated
else if (u6.request.subtype === "initialize") {
if (u6.request.sdkMcpServers && u6.request.sdkMcpServers.length > 0) {
// existing code that fills O
}
__initDone = true;
__initResolve();
// existing ack follows
}
Three edits. Promise resolves once per CLI run. H6 awaits it once; after that, __initDone=true short-circuits the await. No polling, no added latency on the passing path.
With this gate in place we got 10/10 passes on back-to-back runs where the old code was failing about half the time.
Reproduction
Any client that spawns the CLI via the Agent SDK with sdkMcpServers configured and issues two turns in the same conversation will hit Turn 2 cache misses on a large fraction of CLI spawns.
Related issues
- #44869 (prompt cache not utilized, same class of symptom)
- #44045 (a similar MCP timing hypothesis, later withdrawn by the author)
- #41778, #35899 (MCP tools missing on first turn in remote triggers)
- #42442, #49019 (stdio MCP servers: Connected but tools never register)
Notes
The same root mechanism also exists in the SDK: it sends both stdin messages without awaiting the initialize ack. A one-line SDK-side fix (await A.initialization in Rv inside assistant.mjs) is filed separately at anthropics/claude-agent-sdk-typescript#286. In our runs that fix alone passed 9/13 while the CLI gate passed 10/10, so there may be a second race we have not traced. Either way, the CLI gate above fixes what we can see.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗