Skip to content

fix(tools): stop treating a post-registration reconnect drop as an initial-connect failure - #94671

Closed
chelsealong wants to merge 3 commits into
NousResearch:mainfrom
chelsealong:fix/mcp-reconnect-initial-connect-misclassification
Closed

chelsealong wants to merge 3 commits into
NousResearch:mainfrom
chelsealong:fix/mcp-reconnect-initial-connect-misclassification

Conversation

@chelsealong

Copy link
Copy Markdown

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 failure
from a later reconnect failure using self._ready.is_set(). But
_ready is cleared on every reconnect cycle (run() clears it right
before looping back into _run_stdio/_run_http), not just before the
very first connect. So the sequence is:

  1. First connect succeeds: tools register, _ready.set().
  2. Something (keepalive failure, transport TaskGroup drop, auth refresh,
    etc.) asks for a reconnect. run() clears _ready and re-enters the
    transport.
  3. If that reconnect attempt fails, run() sees _ready.is_set() == False and — incorrectly — treats it as if the server had never
    connected, entering the 3-attempt initial_retries ladder
    (_MAX_INITIAL_CONNECT_RETRIES = 3) instead of the 5-attempt
    _reconnect_retries budget (_MAX_RECONNECT_RETRIES = 5) meant for a
    server that has already proven it can connect.
  4. After only 3-4 such failures, it logs failed initial connection after 3 attempts, parking... and deregisters the tools — exactly the
    symptom 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_connected flag (tools/mcp_tool.py) that is set
once, 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 checks
self._ever_connected instead of self._ready.is_set(), so a server
that has proven it can connect always uses the (larger, more forgiving)
reconnect budget for subsequent drops, regardless of how _ready
happens to be toggling.

Related Issue

Fixes #94654

How to Test

Added TestReconnection::test_reconnect_failures_after_initial_success_use_reconnect_budget
in tests/tools/test_mcp_tool.py. It drives MCPServerTask.run() with a
patched _run_stdio that succeeds once (setting _ready and
_ever_connected, mirroring the real success path) and then fails 4
times in a row with a transient ConnectionError, and asserts the
server is not parked.

  • Confirmed the test fails without the fix (reverting only
    tools/mcp_tool.py, keeping the new test):

    FAILED tests/tools/test_mcp_tool.py::TestReconnection::test_reconnect_failures_after_initial_success_use_reconnect_budget
    AssertionError: server parked after only 4 reconnect failures following a successful initial connection ...
    Captured log call:
    WARNING tools.mcp_tool:mcp_tool.py:4177 MCP server 'test_srv' failed initial connection after 3 attempts, parking until a reconnect is requested (state: connecting → parked): ConnectionError: reconnect attempt failed
    

    That's the exact log line from the issue report.

  • With the fix applied, the full suite passes:

    $ python -m pytest tests/tools/test_mcp_tool.py -q
    98 passed, 2 warnings in 3.65s
    
    $ python -m pytest tests/tools/test_mcp_tool_session_expired.py \
        tests/tools/test_mcp_tool_issue_948.py \
        tests/tools/test_mcp_tool_401_handling.py \
        tests/tools/test_setup_mcp_tool.py \
        tests/tools/test_refresh_agent_mcp_tools.py \
        tests/hermes_cli/test_mcp_tools_config.py -q
    40 passed, 2 warnings in 6.72s
    

    (The two _watch_stdio_children RuntimeWarnings are pre-existing and
    unrelated to this change — present on both runs.)

  • ruff check tools/mcp_tool.py tests/tools/test_mcp_tool.py — all
    checks 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

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run pytest tests/ -q (targeted: the mcp-related test files above) and all tests pass
  • I've added a test for this change
  • I've tested on my platform: Linux (unit tests)
  • Updated docs / config example / CONTRIBUTING / AGENTS.md — N/A, no user-facing behavior or config surface changed
  • Cross-platform impact considered — the change is pure Python state-tracking logic, no OS-specific behavior touched

AI 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 the
regression test, and running the verification above.

…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
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tools Tool registry, model_tools, toolsets tool/mcp MCP client and OAuth labels Aug 25, 2026
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.
@chelsealong

Copy link
Copy Markdown
Author

Fixed the CI failure: 3 pre-existing tests (test_mcp_reconnect_retry_reset.py, test_mcp_parked_self_probe.py, test_mcp_reconnect_log_hygiene.py) fake a successful first connect via self._ready.set() only, which is what the real code did before this fix. Since run() now gates the initial-vs-reconnect branch on the new _ever_connected flag, their simulated reconnect failures were misclassified as never-connected. Set _ever_connected = True alongside _ready.set() in those mocks to mirror the real success sites (same pattern already used in this PR's own new test). Verified locally: all 3 files reproduce the exact CI failure on the pre-fix state and pass with this change; tests/tools/test_mcp_tool.py (98) still passes; ruff clean.

@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference, author can ignore or act on any point.

This PR fixes a subtle but real bug (#94654): the MCP server's run() method gated the initial-connect vs. reconnect retry ladder on _ready.is_set(), but _ready is cleared on every reconnect cycle. A server that had already registered tools could be misclassified as "never connected" when a reconnect attempt failed while _ready was momentarily clear, causing it to re-enter the smaller initial-connect budget (3 retries) instead of the larger reconnect budget (5 retries) and park prematurely. The fix introduces _ever_connected — a latch set once after successful tool discovery and never cleared — and gates the branch on this flag instead. The flag is correctly set at all four success sites (stdio + three HTTP paths) and existing test mocks were updated to match.

A few technical concerns:

  1. _ever_connected is never reset: There's no code path that clears _ever_connected back to False. If there's a legitimate "reset to initial state" scenario (e.g., a config change that swaps the transport from stdio to HTTP, or a full teardown-and-restart), the server would incorrectly use the reconnect budget on what is effectively a first connection to a new backend. Worth confirming that no such reset path exists, or adding a clear in the appropriate teardown/reconfigure method if one does.

  2. Defensive try/except AttributeError in test: The new regression test at test_mcp_tool.py:1447 uses try: self_srv._ever_connected = True; except AttributeError: pass to handle the pre-fix class lacking the slot. While this makes the test robust across versions, the except AttributeError could also mask a genuine slot-name typo or a __slots__ misconfiguration. Consider removing the guard now that the slot is defined, or adding an assertion that the attribute exists before the try block.

  3. No test for the HTTP success paths: The regression test covers the stdio path via patched_run_stdio. The three HTTP factory paths (mcp_tool.py:3717, :3783, :3831) also set _ever_connected, but there's no test verifying that an HTTP-connected server that drops and reconnects uses the reconnect budget. The stdio and HTTP paths share the same run() gate, so coverage is likely sufficient, but an HTTP-specific regression test would provide stronger guarantees given the three separate set sites.

  4. Comment clarity in run(): The updated comment at mcp_tool.py:4130 explains why _ever_connected is used instead of _ready, but doesn't mention that _ever_connected is never cleared. A one-line note about the latch semantics ("set once, never cleared") would help future readers understand why this is safe.

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.
@chelsealong

Copy link
Copy Markdown
Author

Addressed the two concrete nits from the automated review:

  • Removed the try/except AttributeError guard around self_srv._ever_connected = True in test_reconnect_failures_after_initial_success_use_reconnect_budget (tests/tools/test_mcp_tool.py) — the slot is unconditionally defined now, so the guard only risked masking a real typo.
  • Added a one-line note to the run() comment (tools/mcp_tool.py) that _ever_connected is set once and never cleared, per the latch-semantics suggestion.

On point 1 (reset path): _connect_server always constructs a fresh MCPServerTask(name) (tools/mcp_tool.py:5806) for any (re)configuration, so there's no in-place teardown/reconfigure path that reuses an instance across a transport swap — _ever_connected naturally starts False for every new server object. No reset method needed.

On point 3 (HTTP-specific test): leaving as-is per the reviewer's own note that coverage via the shared run() gate is likely sufficient — the four connect-success sites all set the same flag checked by the same branch.

python -m pytest tests/tools/test_mcp_tool.py -q → 98 passed; ruff check tools/mcp_tool.py tests/tools/test_mcp_tool.py → clean.

@teknium1

Copy link
Copy Markdown
Collaborator

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.

@teknium1 teknium1 closed this Aug 26, 2026
Finn763 pushed a commit to Finn763/hermes-agent that referenced this pull request Aug 27, 2026
and7777 pushed a commit to and7777/hermes-agent that referenced this pull request Aug 27, 2026
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
zapabob pushed a commit to zapabob/hermes-agent-windows that referenced this pull request Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(mcp): todoist stdio server registers tools then is parked as failed initial connection in long-lived gateway

4 participants