Skip to content

fix(strands): don't evict an MCP connection with a call in flight - #2259

Open
brianstrauch wants to merge 2 commits into
mainfrom
fix/strands-mcp-idle-evict-inflight
Open

fix(strands): don't evict an MCP connection with a call in flight#2259
brianstrauch wants to merge 2 commits into
mainfrom
fix/strands-mcp-idle-evict-inflight

Conversation

@brianstrauch

Copy link
Copy Markdown
Member

What was wrong

The worker-process MCP connection cache in contrib/strands/src/temporal-mcp-client.ts armed its idle timer inside getConnection — that is, at the start of each call — and nothing extended it while the call ran. So the window measured time since the last call started, not since the last call finished.

A callTool or listTools activity that outlived mcpConnectionIdleTimeout (5 minutes by default, against a 10-minute default startToCloseTimeout) had client.disconnect() run underneath it, rejecting the in-flight request. Each retry was cut off the same way, so an MCP tool slower than the idle window could never succeed. A concurrent call holding the same client also continued against a closed transport.

The fix

Track in-flight uses per connection, matching what the Python SDK already does in temporalio/contrib/strands/_temporal_mcp_client.py:

  • acquireConnection() increments inflight and clears any pending idle timer.
  • releaseConnection(), called from a finally in both activities, re-arms the timer only once inflight is 0 and the record is still the cached one — an already-evicted record must not schedule a timer that would later kill its healthy replacement.
  • _evictConnection() removes the entry from the cache before awaiting disconnect(), so a call arriving during teardown opens a fresh connection instead of joining the dying one.

The two parallel maps (CONNECTIONS + IDLE_TIMERS) collapse into one ConnectionRecord { client, inflight, idleTimer }.

Test plan

New test mcpConnectionIdleTimeout does not evict a connection with a call in flight: a 500ms tool call against a 50ms idle window, asserting no disconnect() lands while the call is running. It fails on main ("idle timer disconnected a connection with a call in flight") and passes with the fix.

Full strands suite passes (16/16), including the existing mcpConnectionIdleTimeout evicts the cached connection while the worker runs test — idle eviction still happens between activities, just not during one.

🤖 Generated with Claude Code

The worker-process MCP connection cache armed its idle timer in
`getConnection`, i.e. at the *start* of each call, and nothing extended it
while the call ran. A `callTool` or `listTools` activity that outlived
`mcpConnectionIdleTimeout` (5 minutes by default) had its transport
disconnected underneath it, failing the call; since each retry was cut off the
same way, an MCP tool slower than the idle window could never succeed.

Track in-flight uses per connection, as the Python SDK already does: acquiring
clears the pending idle timer, and releasing re-arms it only once no calls
remain and the record is still the cached one, so the window measures genuine
idleness. Eviction now also removes the entry from the cache before awaiting
`disconnect()`, so a call arriving during teardown opens a fresh connection
instead of joining the dying one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brianstrauch
brianstrauch requested review from a team as code owners July 24, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant