Repository navigation
fix(tools): stop treating a post-registration reconnect drop as an initial-connect failure - #94671
chelsealong wants to merge 3 commits into
Conversation
…itial-connect failure MCPServerTask.run() used `_ready.is_set()` to tell a genuine first connection attempt from a later reconnect. `_ready` is cleared on every reconnect cycle, so once a server has already registered its tools and then drops (keepalive failure, transient TaskGroup exit, etc.), the next failed reconnect attempt is misclassified as "never connected" and burns the 3-attempt initial-connect ladder instead of the 5-attempt reconnect budget, parking the server much sooner and logging "failed initial connection after 3 attempts" even though tools were already registered. Add a sticky `_ever_connected` flag, set once alongside `_ready.set()` right after a successful `_discover_tools()` call and never cleared, and gate the initial-vs-reconnect branch on it instead. Fixes NousResearch#94654
These pre-existing tests fake a successful first connect by calling only _ready.set(), which is what the real code did before this PR. Now that run() gates the initial-vs-reconnect branch on the new sticky _ever_connected flag instead, their later simulated reconnect failures were misclassified as never-connected and hit the 3-attempt ladder, failing test_reconnect_counter_resets_after_successful_session, test_parked_server_self_probes_and_revives, and test_retry_attempts_log_debug_transitions_warn in CI. Set the flag alongside _ready.set() to mirror the real success sites, same as the new test added in tools/mcp_tool.py's own PR.
|
Fixed the CI failure: 3 pre-existing tests ( |
This PR fixes a subtle but real bug (#94654): the MCP server's A few technical concerns:
The fix is minimal, correctly targets the root cause, and the regression test clearly documents the issue's retry-budget boundary (4 failures > initial budget of 3 but ≤ reconnect budget of 5). |
Drop the try/except AttributeError guard in the new regression test now that the slot is always defined, and note in the run() comment that _ever_connected is set once and never cleared.
|
Addressed the two concrete nits from the automated review:
On point 1 (reset path): On point 3 (HTTP-specific test): leaving as-is per the reviewer's own note that coverage via the shared
|
…o it bites on pre-fix code (#94671 hardening)
…o it bites on pre-fix code (#94671 hardening)
|
Merged via wave-2 Phase C salvage PR #95634 (rebase-merged at 9aa7530) — all 3 of your commits landed with your authorship. Sharp root-cause on the _ever_connected latch; we added one hardening commit on top (your regression test was passing vacuously on main because MCPServerTask's slots swallowed the mock attribute — it bites now). Thanks — tracker #94724. |
…o it bites on pre-fix code (NousResearch#94671 hardening)
…o it bites on pre-fix code (NousResearch#94671 hardening)
…o it bites on pre-fix code (NousResearch#94671 hardening)
…o it bites on pre-fix code (NousResearch#94671 hardening)
What does this PR do?
Fixes a long-lived-gateway MCP bug where a server that already registered
its tools successfully gets reclassified as having "failed initial
connection" and parked forever, even though it connected fine.
MCPServerTask.run()distinguishes a genuine first-connection failurefrom a later reconnect failure using
self._ready.is_set(). But_readyis cleared on every reconnect cycle (run()clears it rightbefore looping back into
_run_stdio/_run_http), not just before thevery first connect. So the sequence is:
_ready.set().etc.) asks for a reconnect.
run()clears_readyand re-enters thetransport.
run()sees_ready.is_set() == Falseand — incorrectly — treats it as if the server had neverconnected, entering the 3-attempt
initial_retriesladder(
_MAX_INITIAL_CONNECT_RETRIES = 3) instead of the 5-attempt_reconnect_retriesbudget (_MAX_RECONNECT_RETRIES = 5) meant for aserver that has already proven it can connect.
failed initial connection after 3 attempts, parking...and deregisters the tools — exactly thesymptom in bug(mcp): todoist stdio server registers tools then is parked as failed initial connection in long-lived gateway #94654, reproduced there with a stdio Todoist MCP server
that registered 51 tools and was parked ~8 seconds later.
Fix
Add a sticky
_ever_connectedflag (tools/mcp_tool.py) that is setonce, right alongside
_ready.set(), immediately after a successful_discover_tools()call in each of the four connect-success sites(stdio, SSE, new Streamable HTTP, legacy Streamable HTTP) — and never
cleared again. The initial-vs-reconnect branch in
run()now checksself._ever_connectedinstead ofself._ready.is_set(), so a serverthat has proven it can connect always uses the (larger, more forgiving)
reconnect budget for subsequent drops, regardless of how
_readyhappens to be toggling.
Related Issue
Fixes #94654
How to Test
Added
TestReconnection::test_reconnect_failures_after_initial_success_use_reconnect_budgetin
tests/tools/test_mcp_tool.py. It drivesMCPServerTask.run()with apatched
_run_stdiothat succeeds once (setting_readyand_ever_connected, mirroring the real success path) and then fails 4times in a row with a transient
ConnectionError, and asserts theserver is not parked.
Confirmed the test fails without the fix (reverting only
tools/mcp_tool.py, keeping the new test):That's the exact log line from the issue report.
With the fix applied, the full suite passes:
(The two
_watch_stdio_childrenRuntimeWarnings are pre-existing andunrelated to this change — present on both runs.)
ruff check tools/mcp_tool.py tests/tools/test_mcp_tool.py— allchecks passed.
What platforms you tested on
Linux (test suite only — no real MCP server was spun up; this is a pure
unit-level fix to the reconnect classification logic).
Checklist
pytest tests/ -q(targeted: the mcp-related test files above) and all tests passAI assistance disclosure
This fix was authored with AI assistance (Claude Code), including
locating the root cause in
tools/mcp_tool.py, writing the fix and theregression test, and running the verification above.