Skip to content

Stale MCP toolset in shared _toolset_cache causes silent session failure on resume #121

Description

@Leoyzen

Bug Description

When an ACP session is resumed after WebSocket reconnection, the agent's run silently fails. The handle_prompt() returns end_turn immediately (by design for turn_complete capability), but the background run fails 5 minutes later with an MCP timeout — no session/update events are ever produced.

Root Cause

MCPManager._toolset_cache on the shared pool-level MCPManager is never invalidated between sessions.

When an agent shares the pool's MCPManager (_mcp_shared = True, which is the default when the agent has no agent-level MCP servers), the _toolset_cache: dict[str, MCPToolset] persists across session boundaries. On session resume:

  1. A new SessionConnectionPool is created with fresh transports (correct)
  2. agent._mcp_snapshot is updated with new McpConfigEntry (correct)
  3. get_agentlet() calls self.mcp.as_capability(snapshot=..., session_pool=...)
  4. as_capability()_make_capability() checks _toolset_cache[client_id] first
  5. Cache hit → returns OLD MCPToolset (which holds the OLD transport → OLD AcpMcpConnection → dead WebSocket)
  6. Agentlet's MCP toolset __aenter__() tries to initialize via the dead connection → 300s timeout → McpError

Why the cache key collides

AcpMCPServerConfig.client_id = f"acp_{self.acp_id}" — deterministic across sessions. The same ACP server always maps to the same _toolset_cache key, so session resume always hits the stale cache entry.

Evidence (from production logs)

09:30:27 — Session resume: new MCP connections mcp_conn_4459a3ed, mcp_conn_1523435e created
09:30:28 — /fta-eval skill command executed (has_instructions=True)
09:30:28 — handle_prompt() returns {"stopReason":"end_turn"} immediately
09:35:28 — MCP timeout (300s): Error sending mcp/message to client connection_id=mcp_conn_57fde2f2 (ORIGINAL WebSocket, from 08:39:42!)
09:35:29 — NativeTurn execution failed: McpError: Internal error

The error uses mcp_conn_57fde2f2 — the OLDEST connection, not the fresh ones created at resume time.

Reproduction Test

tests/mcp_server/test_stale_mcp_connection.py — 5 tests, all passing:

  1. test_session_resume_returns_stale_toolset_from_cache — Main reproduction: creates shared MCPManager, simulates session 1 (cache populated) → session 2 resume (fresh transport ignored, stale toolset returned)
  2. test_acp_client_id_is_deterministic_across_sessions — Documents why cache key collides
  3. test_session_pool_provides_fresh_transport_on_resume — Proves SessionConnectionPool is NOT the source of the bug
  4. test_multiple_acp_servers_all_go_stale_on_resume — All ACP servers go stale, not just one
  5. test_disconnect_all_clears_cache_but_not_called_on_resumedisconnect_all() would fix it, but session resume never calls it

Key Code Path

File Line Role
messagenode.py 130-132 self.mcp = agent_pool.mcp when shared
manager.py 147 _toolset_cache: dict[str, Any] = {}
manager.py 374-394 _make_capability() — checks cache first, creates if missing
manager.py 414-425 Session-scoped configs still go through _make_capability() with caching
session_pool.py 198-219 add_transport() — correctly overwrites old transport in per-session pool
session_manager.py 264-286 get_or_create_session_agent() — creates fresh SessionConnectionPool
session_controller.py 932-947 close_session() — does NOT clear shared _toolset_cache
mcp_server.py 414-416 AcpMCPServerConfig.client_id = f"acp_{self.acp_id}" (deterministic)

Proposed Fix

Don't cache session-scoped toolsets in the shared _toolset_cache.

In MCPManager._make_capability() (manager.py:374-394), add a cache parameter:

def _make_capability(server: BaseMCPServerConfig, transport: Any, *, cache: bool = True) -> MCP:
    client_id = server.client_id
    toolset = self._toolset_cache.get(client_id) if cache else None
    if toolset is None:
        toolset = MCPToolset(client=transport, **_make_kwargs(server))
        if cache:
            self._toolset_cache[client_id] = toolset
    return MCP(url=_derive_url(server), local=toolset, ...)

Then in _process_snapshot(), pass cache=False for session-scoped configs:

# Session-scoped configs (session + skill) — don't cache, transports are per-session
for entry in snap.session_scoped_configs:
    ...
    capabilities.append(_make_capability(server, transport, cache=False))

Why this approach:

  • Minimal change — only affects session-scoped path, global configs still benefit from caching
  • Correct semantics — session-scoped transports are per-session by definition; caching them in a shared manager is a category error
  • No lifecycle hooks needed — doesn't require changes to session close/resume paths
  • disconnect_all() still works for global configs

Secondary Issues (address separately)

  1. ACPSession.close() doesn't clean MCP state (session.py:795+) — Cleans env, signals, sys_prompts but does NOT touch _mcp_manager._connections, agent._session_connection_pool, or agent._mcp_snapshot
  2. session_manager.resume_session() early-return (session_manager.py:244-249) — If session is in _acp_sessions, returns immediately, ignoring new mcp_servers parameter
  3. No WebSocket disconnect cleanup — No hook fires when ACP WebSocket drops
  4. MCP snapshot merge is append-only (session.py:525-535) — Deduplicates by client_id but never prunes old entries

Acceptance Criteria

  • test_session_resume_returns_stale_toolset_from_cache passes with fix (toolset2.client is transport_b, not transport_a)
  • test_multiple_acp_servers_all_go_stale_on_resume passes with fix (all toolsets use session-2 transports)
  • Global (pool-level + agent-level) MCP toolsets still benefit from caching
  • No regressions in test_mcpmanager_caching.py tests
  • Session resume produces session/update events normally (manual ACP test)

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions