MCP connection cache race condition in clearServerCache causes 'Already connected to a transport' crash on session resume
Bug description
clearServerCache() in src/services/mcp/client.ts has a race condition where connectToServer.cache.delete(key) executes after the onclose handler has already cleared and repopulated the cache with a fresh connection. This causes the delete to remove the new entry, leaving stale references that crash with "Already connected to a transport" on the next connect() call.
Reproduction
- Start a session with at least one MCP server configured
- Let the session idle for hours (sandbox TTL expires, session goes cold)
- Resume the session — JSONL is restored, CLI subprocess spawns
- CLI detects stale MCP connections → calls
clearServerCache()→ crash
This is intermittent — it depends on the async timing between cleanup(), onclose, and reconnection.
Root cause
In clearServerCache() (client.ts ~L1648):
export async function clearServerCache(name, serverRef) {
const key = getServerCacheKey(name, serverRef)
try {
const wrappedClient = await connectToServer(name, serverRef) // [1] get from cache
if (wrappedClient.type === 'connected') {
await wrappedClient.cleanup() // [2] close transport → triggers onclose
}
} catch {}
connectToServer.cache.delete(key) // [4] PROBLEM: deletes NEW entry from [3]
}
When cleanup() calls client.close():
- The transport's
onclosehandler fires synchronously (L1374) onclosecallsconnectToServer.cache.delete(key)(L1396) — clears the old entry ✓onclosethen callsoriginalOnclose()(L1399) →useManageMCPConnections's reconnect logic- Reconnect calls
connectToServer(name, config)→ cache miss → creates new connection [3] - Back in
clearServerCache, L1666connectToServer.cache.delete(key)runs — deletes the new connection [3]
Now the new connection exists in useManageMCPConnections state but not in the memoize cache. Next call to connectToServer creates yet another connection, but the previous one's transport is still alive → "Already connected to a transport".
Suggested fix
Delete the cache entry before cleanup, and retrieve the cached promise directly instead of calling the memoized function:
export async function clearServerCache(name, serverRef) {
const key = getServerCacheKey(name, serverRef)
const cached = connectToServer.cache.get(key) // grab reference
connectToServer.cache.delete(key) // delete FIRST
fetchToolsForClient.cache.delete(name)
fetchResourcesForClient.cache.delete(name)
fetchCommandsForClient.cache.delete(name)
if (cached) {
try {
const client = await cached
if (client.type === 'connected') await client.cleanup()
} catch {}
}
}
This way, when onclose fires during cleanup(), its cache.delete(key) is a no-op (already deleted). Any reconnection triggered by onclose stores a new entry that won't be clobbered.
Environment
- Claude Code: latest (observed via
@anthropic-ai/claude-agent-sdkquery() API) - OS: Linux (Kubernetes pod)
- MCP server: custom stdio server
- Trigger: session resume after hours of inactivity
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗