MCP connection cache race condition in clearServerCache causes 'Already connected to a transport' crash on session resume

Resolved 💬 1 comment Opened Apr 15, 2026 by jaxxjj Closed May 24, 2026

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

  1. Start a session with at least one MCP server configured
  2. Let the session idle for hours (sandbox TTL expires, session goes cold)
  3. Resume the session — JSONL is restored, CLI subprocess spawns
  4. 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 onclose handler fires synchronously (L1374)
  • onclose calls connectToServer.cache.delete(key) (L1396) — clears the old entry ✓
  • onclose then calls originalOnclose() (L1399) → useManageMCPConnections's reconnect logic
  • Reconnect calls connectToServer(name, config) → cache miss → creates new connection [3]
  • Back in clearServerCache, L1666 connectToServer.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-sdk query() API)
  • OS: Linux (Kubernetes pod)
  • MCP server: custom stdio server
  • Trigger: session resume after hours of inactivity

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗