{"repo":"NousResearch/hermes-agent","public_id":"5721","display_label":"MCP client reconnect retry limits and resilience","summary":"These issues and patches concern the MCP client permanently abandoning servers after a fixed number of exhausted reconnect attempts. Proposed changes address retry counter resets, background retry loops, and recovery after transient outages.","subsystem":"agent","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":6,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53599},"resolution":{"state":"partially_resolved","by":"pr-59222"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T02:10:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1549090909090909},"fix_edges":[{"issue_number":16788,"pr_number":53599,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":57129,"pr_number":59222,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":57604,"pr_number":59222,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":5721,"title":"OpenViking memory provider fails permanently if server is unreachable at agent startup; never recovers","url":null,"state":"closed"},{"kind":"issue","number":19559,"title":"MCP client doesn't retry connecting to HTTP servers after startup window closes","url":null,"state":"closed"},{"kind":"issue","number":38488,"title":"MCP server permanently gives up after a transient backend outage; never reconnects until gateway restart","url":null,"state":"closed"},{"kind":"issue","number":57129,"title":"MCP client permanently abandons a server after 5 failed reconnects — one transient upstream blip kills its tools until process restart","url":null,"state":"closed"},{"kind":"issue","number":57604,"title":"MCP stdio client gives up permanently after 5 reconnect attempts — no lazy reconnect, retry counter never resets","url":null,"state":"closed"},{"kind":"issue","number":59568,"title":"MCP reconnect: lifetime retries counter never resets on successful reconnect → remote servers permanently give up after 5 total drops","url":null,"state":"closed"},{"kind":"pr","number":38509,"title":"fix(mcp): never permanently give up reconnecting after transient outage (#38488)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":38558,"title":"fix(mcp): background retry loop for transient backend outages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38509","number":38509},{"kind":"pr","value":"#38488","number":38488}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53599,"title":"fix(mcp): never permanently wedge the circuit breaker on a dead transport","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_circuit_breaker.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"tests/tools/test_mcp_circuit_breaker.py","hunks":["@@ -179,6 +179,107 @@ def _fake_monotonic():\n         _cleanup(mcp_tool, \"srv\")\n \n \n+def test_half_open_probe_on_dead_session_requests_reconnect(monkeypatch, tmp_path):\n+    \"\"\"A half-open probe against a server with no live session must request\n+    a transport reconnect and return a clean error — NOT write into a dead\n+    pipe or permanently re-arm the breaker.\n+\n+    This is the #16788 wedge: a dead stdio subprocess leaves ``session=None``\n+    (the run loop parked after exhausting retries). The old handler bumped\n+    the breaker every cooldown forever; the fix signals ``_reconnect_event``\n+    so the parked task revives and rebuilds the transport.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from tools import mcp_tool\n+    from tools.mcp_tool import _make_tool_handler\n+\n+    server = _install_stub_server(mcp_tool, \"srv\", None)\n+    # Simulate a dead/parked transport: no live session.\n+    server.session = None\n+    # Drive _signal_reconnect down its direct .set() path (no live loop).\n+    monkeypatch.setattr(mcp_tool, \"_mcp_loop\", None)\n+\n+    try:\n+        mcp_tool._server_error_counts[\"srv\"] = mcp_tool._CIRCUIT_BREAKER_THRESHOLD\n+        fake_now = [1000.0]\n+\n+        def _fake_monotonic():\n+            return fake_now[0]\n+\n+        monkeypatch.setattr(mcp_tool.time, \"monotonic\", _fake_monotonic)\n+        mcp_tool._server_breaker_opened_at[\"srv\"] = fake_now[0]\n+        cooldown = getattr(mcp_tool, \"_CIRCUIT_BREAKER_COOLDOWN_SEC\", 60.0)\n+\n+        # Advance past cooldown → next call is a half-open probe.\n+        fake_now[0] += cooldown + 1.0\n+\n+        handler = _make_tool_handler(\"srv\", \"tool1\", 10.0)\n+        result = handler({})\n+        parsed = json.loads(result)\n+\n+        # Clean \"reconnecting\" error, and a reconnect was actually signalled.\n+        assert \"reconnect\" in parsed.get(\"error\", \"\").lower(), parsed\n+        server._reconnect_event.set.assert_called_once()\n+    finally:\n+        _cleanup(mcp_tool, \"srv\")\n+\n+\n+def test_half_open_dead_session_recovers_after_reconnect(monkeypatch, tmp_path):\n+    \"\"\"Once the transport comes back (session repopulated + breaker reset by\n+    the run loop), the next call must go straight through — proving the wedge\n+    is escapable, not just deferred.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from tools import mcp_tool\n+    from tools.mcp_tool import _make_tool_handler\n+\n+    async def _call_tool_success(*a, **kw):\n+        result = MagicMock()\n+        result.isError = False\n+        block = MagicMock()\n+        block.text = \"ok\"\n+        result.content = [block]\n+        result.structuredContent = None\n+        return result\n+\n+    server = _install_stub_server(mcp_tool, \"srv\", _call_tool_success)\n+    server.session = None  # transport down at first\n+    monkeypatch.setattr(mcp_tool, \"_mcp_loop\", None)\n+    mcp_tool._ensure_mcp_loop()\n+\n+    try:\n+        mcp_tool._server_error_counts[\"srv\"] = mcp_tool._CIRCUIT_BREAKER_THRESHOLD\n+        fake_now = [1000.0]\n+        monkeypatch.setattr(mcp_tool.time, \"monotonic\", lambda: fake_now[0])\n+        mcp_tool._server_breaker_opened_at[\"srv\"] = fake_now[0]\n+        cooldown = getattr(mcp_tool, \"_CIRCUIT_BREAKER_COOLDOWN_SEC\", 60.0)\n+        fake_now[0] += cooldown + 1.0\n+\n+        handler = _make_tool_handler(\"srv\", \"tool1\", 10.0)\n+\n+        # Probe 1: transport down → reconnect requested, clean error.\n+        parsed = json.loads(handler({}))\n+        assert \"reconnect\" in parsed.get(\"error\", \"\").lower(), parsed\n+\n+        # Simulate the run loop rebuilding the session + resetting the breaker\n+        # (what _run_stdio does on successful re-init).\n+        live = MagicMock()\n+        live.call_tool = _call_tool_success\n+        server.session = live\n+        mcp_tool._reset_server_error(\"srv\")\n+\n+        # Advance past the re-armed cooldown so the next call is a fresh probe.\n+        fake_now[0] += cooldown + 1.0\n+\n+        # Next call goes straight through.\n+        parsed = json.loads(handler({}))\n+        assert parsed.get(\"result\") == \"ok\", parsed\n+    finally:\n+        _cleanup(mcp_tool, \"srv\")\n+\n+\n def test_circuit_breaker_cleared_on_reconnect(monkeypatch, tmp_path):\n     \"\"\"When the auth-recovery path successfully reconnects the server,\n     the breaker should be cleared so subsequent calls aren't gated on a","@@ -250,3 +351,98 @@ def _retry_call():\n         )\n     finally:\n         _cleanup(mcp_tool, \"srv\")\n+\n+\n+def test_run_loop_parks_instead_of_exiting_then_revives(monkeypatch, tmp_path):\n+    \"\"\"The run loop must NOT exit when the reconnect budget is exhausted.\n+\n+    It deregisters tools and parks as a dormant listener; a later\n+    ``_reconnect_event`` revives it and re-enters the transport. This is the\n+    structural fix for #16788 — without a live task, no half-open probe could\n+    ever bring a dead stdio server back.\n+    \"\"\"\n+    import asyncio\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from tools import mcp_tool\n+    from tools.mcp_tool import MCPServerTask\n+\n+    # Shrink the budget and collapse backoff sleeps (but still yield control\n+    # to the loop) so the test runs fast without starving the scheduler.\n+    monkeypatch.setattr(mcp_tool, \"_MAX_RECONNECT_RETRIES\", 2)\n+\n+    _real_sleep = asyncio.sleep\n+\n+    async def _fast_sleep(_delay, *a, **kw):\n+        await _real_sleep(0)\n+\n+    monkeypatch.setattr(mcp_tool.asyncio, \"sleep\", _fast_sleep)\n+\n+    state = {\"transport_calls\": 0, \"deregistered\": 0, \"revived\": False}\n+\n+    async def _scenario():\n+        class _Task(MCPServerTask):\n+            def _is_http(self):\n+                return False\n+\n+            def _deregister_tools(self):\n+                state[\"deregistered\"] += 1\n+                self._registered_tool_names = []\n+\n+            async def _run_stdio(self, config):\n+                state[\"transport_calls\"] += 1\n+                # First connect succeeds (sets _ready) then immediately\n+                # fails, as if the subprocess died — the post-ready failure\n+                # path that counts toward the reconnect budget.\n+                if state[\"transport_calls\"] == 1:\n+                    self.session = object()\n+                    self._ready.set()\n+                    self.session = None\n+                    raise RuntimeError(\"subprocess died\")\n+                # Keep failing until the budget is exhausted and the loop\n+                # parks, UNLESS we've been revived after parking.\n+                if state[\"revived\"]:\n+                    self.session = object()\n+                    self._ready.set()\n+                    await self._wait_for_lifecycle_event()\n+                    return\n+                raise RuntimeError(\"still down\")\n+\n+        task = _Task(\"srv\")\n+        task._registered_tool_names = [\"srv__tool\"]\n+\n+        run_task = asyncio.ensure_future(task.run({\"command\": \"x\"}))\n+\n+        # Wait until the loop has parked (it deregisters tools right before\n+        # blocking on _wait_for_reconnect_or_shutdown).\n+        for _ in range(500):\n+            await _real_sleep(0)\n+            if state[\"deregistered\"] >= 1:\n+                break\n+        # Give the loop one more tick to settle into the park wait.\n+        await _real_sleep(0)\n+        assert not run_task.done(), \"run loop exited instead of parking\"\n+        assert state[\"deregistered\"] >= 1, \"tools not deregistered on park\"\n+\n+        # Revive it: a reconnect signal must wake the parked task.\n+        state[\"revived\"] = True\n+        before = state[\"transport_calls\"]\n+        task._reconnect_event.set()\n+        for _ in range(500):\n+            await _real_sleep(0)\n+            if state[\"transport_calls\"] > before:\n+                break\n+        assert state[\"transport_calls\"] > before, (\n+            \"parked task did not re-enter transport on reconnect signal\"\n+        )\n+\n+        # Clean shutdown.\n+        task._shutdown_event.set()\n+        task._reconnect_event.set()\n+        try:\n+            await asyncio.wait_for(run_task, timeout=2)\n+        except (asyncio.TimeoutError, asyncio.CancelledError, Exception):\n+            run_task.cancel()\n+\n+    asyncio.run(_scenario())"]},{"file":"tools/mcp_tool.py","hunks":["@@ -1759,6 +1759,41 @@ async def _wait_for_lifecycle_event(self) -> str:\n         self._reconnect_event.clear()\n         return \"reconnect\"\n \n+    async def _wait_for_reconnect_or_shutdown(self) -> str:\n+        \"\"\"Block until a reconnect or shutdown is requested while parked.\n+\n+        Used by :meth:`run` after the reconnect budget is exhausted. The\n+        task stays alive (so ``_reconnect_event`` always has a listener) but\n+        does no work until something explicitly asks it to come back —\n+        the circuit-breaker half-open probe, OAuth recovery, or a manual\n+        ``/mcp`` refresh.\n+\n+        Returns:\n+            ``\"shutdown\"`` if the server should exit the run loop entirely,\n+            ``\"reconnect\"`` if it should rebuild the transport. The reconnect\n+            event is cleared before returning so the next park cycle starts\n+            from a fresh signal. Shutdown takes precedence.\n+        \"\"\"\n+        shutdown_task = asyncio.ensure_future(self._shutdown_event.wait())\n+        reconnect_task = asyncio.ensure_future(self._reconnect_event.wait())\n+        try:\n+            await asyncio.wait(\n+                {shutdown_task, reconnect_task},\n+                return_when=asyncio.FIRST_COMPLETED,\n+            )\n+        finally:\n+            for t in (shutdown_task, reconnect_task):\n+                if not t.done():\n+                    t.cancel()\n+                    try:\n+                        await t\n+                    except (asyncio.CancelledError, Exception):\n+                        pass\n+        if self._shutdown_event.is_set():\n+            return \"shutdown\"\n+        self._reconnect_event.clear()\n+        return \"reconnect\"\n+\n     async def _run_stdio(self, config: dict):\n         \"\"\"Run the server using stdio transport.\"\"\"\n         if not _MCP_AVAILABLE:","@@ -1857,6 +1892,10 @@ async def _run_stdio(self, config: dict):\n                     self.session = session\n                     await self._discover_tools()\n                     self._ready.set()\n+                    # Session is live again: clear any breaker state from a\n+                    # prior outage so the first call after recovery isn't\n+                    # gated on a stale consecutive-failure count (#16788).\n+                    _reset_server_error(self.name)\n                     # stdio transport does not use OAuth, but we still honor\n                     # _reconnect_event (e.g. future manual /mcp refresh) for\n                     # consistency with _run_http.","@@ -2088,6 +2127,10 @@ def _mcp_http_client_factory(\n                     self.session = session\n                     await self._discover_tools()\n                     self._ready.set()\n+                    # Session is live again: clear any breaker state from a\n+                    # prior outage so the first call after recovery isn't\n+                    # gated on a stale consecutive-failure count (#16788).\n+                    _reset_server_error(self.name)\n                     reason = await self._wait_for_lifecycle_event()\n                     if reason == \"reconnect\":\n                         logger.info(","@@ -2137,6 +2180,10 @@ async def _strip_auth_on_cross_origin_redirect(response):\n                         self.session = session\n                         await self._discover_tools()\n                         self._ready.set()\n+                        # Session is live again: clear any breaker state from\n+                        # a prior outage so the first call after recovery\n+                        # isn't gated on a stale failure count (#16788).\n+                        _reset_server_error(self.name)\n                         reason = await self._wait_for_lifecycle_event()\n                         if reason == \"reconnect\":\n                             logger.info(","@@ -2160,6 +2207,10 @@ async def _strip_auth_on_cross_origin_redirect(response):\n                     self.session = session\n                     await self._discover_tools()\n                     self._ready.set()\n+                    # Session is live again: clear any breaker state from a\n+                    # prior outage so the first call after recovery isn't\n+                    # gated on a stale consecutive-failure count (#16788).\n+                    _reset_server_error(self.name)\n                     reason = await self._wait_for_lifecycle_event()\n                     if reason == \"reconnect\":\n                         logger.info(","@@ -2298,6 +2349,15 @@ async def run(self, config: dict):\n                     \"manual refresh)\",\n                     self.name,\n                 )\n+                # A clean transport return only happens after a session was\n+                # successfully established and then asked to rebuild (auth\n+                # recovery / manual refresh / breaker-driven reconnect). That\n+                # is proof the server is reachable, so clear the consecutive-\n+                # failure budget — otherwise transient drops accumulated over\n+                # a long-lived session would eventually exhaust it and\n+                # permanently kill an otherwise-healthy server.\n+                retries = 0\n+                backoff = 1.0\n                 # Reset the session reference; _run_http/_run_stdio will\n                 # repopulate it on successful re-entry.\n                 self.session = None","@@ -2374,10 +2434,32 @@ async def run(self, config: dict):\n                 if retries > _MAX_RECONNECT_RETRIES:\n                     logger.warning(\n                         \"MCP server '%s' failed after %d reconnection attempts, \"\n-                        \"giving up: %s\",\n+                        \"parking until a reconnect is requested: %s\",\n                         self.name, _MAX_RECONNECT_RETRIES, exc,\n                     )\n-                    return\n+                    # Do NOT return — exiting the task orphans the server:\n+                    # nothing would ever listen for _reconnect_event again,\n+                    # so a half-open circuit-breaker probe could never revive\n+                    # it and the server would be permanently wedged for the\n+                    # life of the process (#16788). Instead, drop the phantom\n+                    # tools from the registry and park as a dormant listener.\n+                    # A future _reconnect_event.set() — from the breaker's\n+                    # half-open probe, OAuth recovery, or a manual /mcp\n+                    # refresh — wakes us to rebuild the transport (respawning\n+                    # a dead stdio subprocess in the process).\n+                    self._deregister_tools()\n+                    self._reconnect_event.clear()\n+                    parked = await self._wait_for_reconnect_or_shutdown()\n+                    if parked == \"shutdown\":\n+                        return\n+                    logger.info(\n+                        \"MCP server '%s': reconnect requested while parked; \"\n+                        \"rebuilding transport.\",\n+                        self.name,\n+                    )\n+                    retries = 0\n+                    backoff = 1.0\n+                    continue\n \n                 logger.warning(\n                     \"MCP server '%s' connection lost (attempt %d/%d), \"","@@ -2403,8 +2485,6 @@ async def start(self, config: dict):\n \n     async def shutdown(self):\n         \"\"\"Signal the Task to exit and wait for clean resource teardown.\"\"\"\n-        from tools.registry import registry\n-\n         self._shutdown_event.set()\n         # Defensive: if _wait_for_lifecycle_event is blocking, we need ANY\n         # event to unblock it. _shutdown_event alone is sufficient (the","@@ -2430,11 +2510,24 @@ async def shutdown(self):\n                 task.cancel()\n             await asyncio.gather(*self._pending_refresh_tasks, return_exceptions=True)\n             self._pending_refresh_tasks.clear()\n+        self._deregister_tools()\n+        self.session = None\n+\n+    def _deregister_tools(self) -> None:\n+        \"\"\"Drop this server's tools from the global registry (idempotent).\n+\n+        Pulls the server's tool schemas out of the registry so the agent\n+        stops advertising them to the model. Called on shutdown AND when the\n+        reconnect budget is exhausted, so a dead server never leaves phantom\n+        tool definitions bloating the prompt cache and producing \"not\n+        connected\" errors on every turn.\n+        \"\"\"\n+        from tools.registry import registry\n+\n         for tool_name in list(getattr(self, \"_registered_tool_names\", [])):\n             registry.deregister(tool_name)\n             _forget_mcp_tool_server(tool_name)\n         self._registered_tool_names = []\n-        self.session = None\n \n \n # ---------------------------------------------------------------------------","@@ -2491,6 +2584,31 @@ def _reset_server_error(server_name: str) -> None:\n     _server_error_counts[server_name] = 0\n     _server_breaker_opened_at.pop(server_name, None)\n \n+\n+def _signal_reconnect(server: Any) -> bool:\n+    \"\"\"Ask a server task to rebuild its transport, thread-safely.\n+\n+    The tool handlers run on caller threads, while the server task and its\n+    ``_reconnect_event`` live on the background MCP loop. Setting an\n+    asyncio.Event from another thread must go through\n+    ``loop.call_soon_threadsafe``; only fall back to a direct ``.set()``\n+    when the loop isn't running (e.g. unit tests that drive the handler\n+    synchronously).\n+\n+    Returns True if a reconnect signal was delivered, False if the server\n+    has no reconnect machinery (nothing to revive).\n+    \"\"\"\n+    event = getattr(server, \"_reconnect_event\", None)\n+    if event is None:\n+        return False\n+    loop = _mcp_loop\n+    if loop is not None and loop.is_running():\n+        loop.call_soon_threadsafe(event.set)\n+    else:\n+        event.set()\n+    return True\n+\n+\n # ---------------------------------------------------------------------------\n # Auth-failure detection helpers (Task 6 of MCP OAuth consolidation)\n # ---------------------------------------------------------------------------","@@ -3176,12 +3294,35 @@ def _handler(args: dict, **kwargs) -> str:\n \n         with _lock:\n             server = _servers.get(server_name)\n-        if not server or not server.session:\n+        if not server:\n             _bump_server_error(server_name)\n             return json.dumps({\n                 \"error\": f\"MCP server '{server_name}' is not connected\"\n             }, ensure_ascii=False)\n \n+        if not server.session:\n+            # No live session — the server task is reconnecting, or it has\n+            # exhausted its retry budget and parked (e.g. a dead stdio\n+            # subprocess). Probing here would write into a dead/absent\n+            # transport and re-arm the breaker forever (#16788). Instead,\n+            # ask the (always-present) server task to rebuild the transport\n+            # — which respawns a dead stdio subprocess — and return a clean\n+            # \"reconnecting\" error so the model backs off without burning\n+            # iterations. The breaker resets once the fresh session\n+            # initializes (_run_stdio/_run_http call _reset_server_error).\n+            _bump_server_error(server_name)\n+            if _signal_reconnect(server):\n+                return json.dumps({\n+                    \"error\": (\n+                        f\"MCP server '{server_name}' transport is down; \"\n+                        f\"reconnect requested. Do NOT retry this tool \"\n+                        f\"immediately — give it a few seconds to come back.\"\n+                    )\n+                }, ensure_ascii=False)\n+            return json.dumps({\n+                \"error\": f\"MCP server '{server_name}' is not connected\"\n+            }, ensure_ascii=False)\n+\n         async def _call():\n             async with server._rpc_lock:\n                 # Snapshot the agent's context so an elicitation callback\n"]}]},{"kind":"pr","number":57615,"title":"fix(mcp): reset reconnect retry counter after successful session establishment","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6d359e068","number":0},{"kind":"pr","value":"#59222","number":59222}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59222,"title":"fix(mcp): reconnect resilience — reset budget per healthy session, self-probe parked servers, re-register tools on revival (salvage #57615, #54139, #57477)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_mcp_circuit_breaker.py","tests/tools/test_mcp_parked_self_probe.py","tests/tools/test_mcp_reconnect_retry_reset.py","tests/tools/test_mcp_stability.py","tests/tools/test_mcp_tool.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -1198,6 +1198,7 @@\n     \"[redacted:email]\": \"LeonSGP43\",\n     \"[redacted:email]\": \"Lubrsy706\",\n     \"[redacted:email]\": \"spniyant\",\n+    \"[redacted:email]\": \"nicha16\",  # PR #54139 partial salvage (re-register tools after park revival)\n     \"[redacted:email]\": \"olafthiele\",\n     \"[redacted:email]\": \"sprmn24\",\n     \"[redacted:email]\": \"olafthiele\","]},{"file":"tests/tools/test_mcp_circuit_breaker.py","hunks":["@@ -446,3 +446,84 @@ async def _run_stdio(self, config):\n             run_task.cancel()\n \n     asyncio.run(_scenario())\n+\n+\n+def test_initial_connect_budget_parks_instead_of_exiting_then_revives(monkeypatch, tmp_path):\n+    \"\"\"Initial connection failures must park, not permanently exit the task.\n+\n+    Regression for #57129's remaining live case: a slow HTTP/SSE server or\n+    late-starting stdio server could exhaust the initial-connect budget before\n+    it ever registered tools. The run loop returned, leaving no task alive to\n+    hear a later manual /mcp refresh.\n+    \"\"\"\n+    import asyncio\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from tools import mcp_tool\n+    from tools.mcp_tool import MCPServerTask\n+\n+    monkeypatch.setattr(mcp_tool, \"_MAX_INITIAL_CONNECT_RETRIES\", 2)\n+\n+    _real_sleep = asyncio.sleep\n+\n+    async def _fast_sleep(_delay, *a, **kw):\n+        await _real_sleep(0)\n+\n+    monkeypatch.setattr(mcp_tool.asyncio, \"sleep\", _fast_sleep)\n+\n+    state = {\"transport_calls\": 0, \"deregistered\": 0, \"revived\": False}\n+\n+    async def _scenario():\n+        class _Task(MCPServerTask):\n+            def _is_http(self):\n+                return False\n+\n+            def _deregister_tools(self):\n+                state[\"deregistered\"] += 1\n+                self._registered_tool_names = []\n+\n+            async def _run_stdio(self, config):\n+                state[\"transport_calls\"] += 1\n+                if not state[\"revived\"]:\n+                    raise RuntimeError(\"server still booting\")\n+                self.session = object()\n+                self._ready.set()\n+                await self._wait_for_lifecycle_event()\n+                return\n+\n+        task = _Task(\"srv\")\n+        run_task = asyncio.ensure_future(task.run({\"command\": \"x\"}))\n+\n+        for _ in range(500):\n+            await _real_sleep(0)\n+            if state[\"deregistered\"] >= 1:\n+                break\n+\n+        await _real_sleep(0)\n+        assert state[\"transport_calls\"] == 3\n+        assert state[\"deregistered\"] >= 1\n+        assert task._ready.is_set()\n+        assert task._error is not None\n+        assert not run_task.done(), \"initial failure exited instead of parking\"\n+\n+        state[\"revived\"] = True\n+        before = state[\"transport_calls\"]\n+        task._reconnect_event.set()\n+        for _ in range(500):\n+            await _real_sleep(0)\n+            if state[\"transport_calls\"] > before and task.session is not None:\n+                break\n+\n+        assert state[\"transport_calls\"] > before\n+        assert task.session is not None\n+        assert task._error is None\n+\n+        task._shutdown_event.set()\n+        task._reconnect_event.set()\n+        try:\n+            await asyncio.wait_for(run_task, timeout=2)\n+        except (asyncio.TimeoutError, asyncio.CancelledError, Exception):\n+            run_task.cancel()\n+\n+    asyncio.run(_scenario())"]},{"file":"tests/tools/test_mcp_parked_self_probe.py","hunks":["@@ -0,0 +1,108 @@\n+\"\"\"Tests for the parked-server self-probe revival path (#57129).\n+\n+Parking deregisters a server's tools, so no tool call can reach the\n+circuit-breaker half-open probe or ``_signal_reconnect`` — the only\n+things that set ``_reconnect_event``. The parked wait must therefore be\n+timed: the run task wakes on ``_PARKED_RETRY_INTERVAL`` and attempts one\n+revival probe on its own.\n+\"\"\"\n+\n+import asyncio\n+\n+import pytest\n+\n+\n[redacted:email]_isolate\n+def test_parked_server_self_probes_and_revives(monkeypatch, tmp_path):\n+    \"\"\"A parked server must revive on its own once the backend recovers,\n+    without any explicit _reconnect_event.set().\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from tools import mcp_tool\n+    from tools.mcp_tool import MCPServerTask\n+\n+    monkeypatch.setattr(mcp_tool, \"_MAX_RECONNECT_RETRIES\", 1)\n+    # Keep the self-probe cadence tiny so the test is fast.\n+    monkeypatch.setattr(mcp_tool, \"_PARKED_RETRY_INTERVAL\", 0.05)\n+\n+    _real_sleep = asyncio.sleep\n+\n+    async def _fast_sleep(_delay, *a, **kw):\n+        await _real_sleep(0)\n+\n+    monkeypatch.setattr(mcp_tool.asyncio, \"sleep\", _fast_sleep)\n+\n+    state = {\n+        \"transport_calls\": 0,\n+        \"deregistered\": 0,\n+        \"backend_up\": False,\n+        \"revived_registration\": 0,\n+    }\n+\n+    async def _scenario():\n+        class _Task(MCPServerTask):\n+            def _is_http(self):\n+                return False\n+\n+            def _deregister_tools(self):\n+                state[\"deregistered\"] += 1\n+                self._registered_tool_names = []\n+\n+            def _register_discovered_tools_if_needed(self):\n+                if self._ready.is_set() and not self._registered_tool_names:\n+                    state[\"revived_registration\"] += 1\n+                    self._registered_tool_names = [\"srv__tool\"]\n+\n+            async def _run_stdio(self, config):\n+                state[\"transport_calls\"] += 1\n+                if state[\"transport_calls\"] == 1:\n+                    # First connect succeeds (sets _ready), then dies.\n+                    self.session = object()\n+                    self._ready.set()\n+                    self.session = None\n+                    raise RuntimeError(\"backend outage begins\")\n+                if not state[\"backend_up\"]:\n+                    raise RuntimeError(\"backend still down\")\n+                # Backend recovered: establish a session and park in the\n+                # lifecycle wait like the real transport does.\n+                self.session = object()\n+                self._register_discovered_tools_if_needed()\n+                await self._wait_for_lifecycle_event()\n+\n+        task = _Task(\"srv\")\n+        task._registered_tool_names = [\"srv__tool\"]\n+\n+        run_task = asyncio.ensure_future(task.run({\"command\": \"x\"}))\n+\n+        # Let it exhaust the budget (1 retry) and park.\n+        for _ in range(2000):\n+            await _real_sleep(0)\n+            if state[\"deregistered\"] >= 1:\n+                break\n+        assert state[\"deregistered\"] >= 1, \"server never parked\"\n+        assert not run_task.done(), \"run task exited instead of parking\"\n+\n+        # The backend comes back. NOTHING sets _reconnect_event — revival\n+        # must come from the timed self-probe alone.\n+        state[\"backend_up\"] = True\n+        for _ in range(200):\n+            await _real_sleep(0.01)\n+            if task.session is not None:\n+                break\n+\n+        assert task.session is not None, (\n+            \"parked server never self-probed back to life \"\n+            f\"(transport_calls={state['transport_calls']})\"\n+        )\n+        assert state[\"revived_registration\"] >= 1, (\n+            \"revived server did not re-register its tools\"\n+        )\n+\n+        task._shutdown_event.set()\n+        task._reconnect_event.set()\n+        try:\n+            await asyncio.wait_for(run_task, timeout=2)\n+        except (asyncio.TimeoutError, asyncio.CancelledError, Exception):\n+            run_task.cancel()\n+\n+    asyncio.run(_scenario())"]},{"file":"tests/tools/test_mcp_reconnect_retry_reset.py","hunks":["@@ -0,0 +1,196 @@\n+\"\"\"Tests for MCP reconnect retry counter reset (#57604).\n+\n+Verifies that the reconnect retry counter resets after each successful\n+reconnection, so transient blips do not accumulate toward permanent parking.\n+\"\"\"\n+\n+import asyncio\n+\n+import pytest\n+\n+\n[redacted:email]_isolate\n+def test_reconnect_counter_resets_after_successful_session(monkeypatch, tmp_path):\n+    \"\"\"Transient disconnections must not accumulate toward permanent parking.\n+\n+    Before the fix, ``retries`` was a local variable in ``run()`` that only\n+    reset on clean transport return (line 2367) or park-wake (line 2468).\n+    Each exception from ``_run_stdio`` incremented it without reset, so 5\n+    transient blips over a long-uptime gateway would permanently park the\n+    server.\n+\n+    After the fix, ``_reconnect_retries`` is an instance variable that resets\n+    to 0 whenever a session is successfully established (``_reset_server_error``\n+    call sites in ``_run_stdio`` / ``_run_http``).\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from tools import mcp_tool\n+    from tools.mcp_tool import MCPServerTask\n+\n+    # Shrink budget so the test can exhaust it quickly if the counter\n+    # does NOT reset (the bug scenario).\n+    monkeypatch.setattr(mcp_tool, \"_MAX_RECONNECT_RETRIES\", 3)\n+\n+    _real_sleep = asyncio.sleep\n+\n+    async def _fast_sleep(_delay, *a, **kw):\n+        await _real_sleep(0)\n+\n+    monkeypatch.setattr(mcp_tool.asyncio, \"sleep\", _fast_sleep)\n+\n+    state = {\n+        \"transport_calls\": 0,\n+        \"parked\": False,\n+        \"max_retries_seen\": 0,\n+    }\n+\n+    async def _scenario():\n+        class _Task(MCPServerTask):\n+            def _is_http(self):\n+                return False\n+\n+            def _deregister_tools(self):\n+                state[\"parked\"] = True\n+                self._registered_tool_names = []\n+\n+            async def _run_stdio(self, config):\n+                state[\"transport_calls\"] += 1\n+                call = state[\"transport_calls\"]\n+\n+                if call == 1:\n+                    # First connect: succeed (sets _ready), then fail.\n+                    self.session = object()\n+                    self._ready.set()\n+                    self.session = None\n+                    raise RuntimeError(\"blip 1\")\n+\n+                # Subsequent calls: succeed (session established, which\n+                # triggers _reset_server_error and should reset retries),\n+                # then immediately fail again — simulating a new transient\n+                # blip.  If retries accumulate, call 4 would exceed the\n+                # budget of 3 and park.  If retries reset correctly,\n+                # this loop can continue indefinitely.\n+                if call <= 8:\n+                    self.session = object()\n+                    # _run_stdio calls _reset_server_error and sets\n+                    # _reconnect_retries = 0 after session establishment.\n+                    # We simulate that by calling the real method.\n+                    mcp_tool._reset_server_error(self.name)\n+                    self._reconnect_retries = 0\n+                    self.session = None\n+                    raise RuntimeError(f\"blip {call}\")\n+\n+                # If we reach here without parking, the fix works.\n+                self.session = object()\n+                mcp_tool._reset_server_error(self.name)\n+                self._reconnect_retries = 0\n+                await self._wait_for_lifecycle_event()\n+                return\n+\n+        task = _Task(\"srv\")\n+        task._registered_tool_names = [\"srv__tool\"]\n+\n+        run_task = asyncio.ensure_future(task.run({\"command\": \"x\"}))\n+\n+        # Let the scenario run.\n+        for _ in range(2000):\n+            await _real_sleep(0)\n+            if state[\"transport_calls\"] >= 8 or state[\"parked\"]:\n+                break\n+\n+        # The fix: the server should NOT have parked despite 8 transient\n+        # disconnections, because each successful reconnection reset the\n+        # retry counter.\n+        assert not state[\"parked\"], (\n+            f\"server parked after {state['transport_calls']} transport calls \"\n+            f\"— retry counter accumulated instead of resetting\"\n+        )\n+        assert state[\"transport_calls\"] >= 8, (\n+            f\"only {state['transport_calls']} transport calls reached \"\n+            f\"(expected >= 8)\"\n+        )\n+\n+        # Verify the counter is an instance variable, not a local.\n+        assert hasattr(task, \"_reconnect_retries\"), (\n+            \"_reconnect_retries should be an instance variable\"\n+        )\n+\n+        # Clean shutdown.\n+        task._shutdown_event.set()\n+        task._reconnect_event.set()\n+        try:\n+            await asyncio.wait_for(run_task, timeout=2)\n+        except (asyncio.TimeoutError, asyncio.CancelledError, Exception):\n+            run_task.cancel()\n+\n+    asyncio.run(_scenario())\n+\n+\n[redacted:email]_isolate\n+def test_reconnect_counter_still_parks_on_consecutive_failures(monkeypatch, tmp_path):\n+    \"\"\"The server must still park when failures are genuinely consecutive\n+    (no successful reconnection in between).\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from tools import mcp_tool\n+    from tools.mcp_tool import MCPServerTask\n+\n+    monkeypatch.setattr(mcp_tool, \"_MAX_RECONNECT_RETRIES\", 2)\n+\n+    _real_sleep = asyncio.sleep\n+\n+    async def _fast_sleep(_delay, *a, **kw):\n+        await _real_sleep(0)\n+\n+    monkeypatch.setattr(mcp_tool.asyncio, \"sleep\", _fast_sleep)\n+\n+    state = {\"transport_calls\": 0, \"parked\": False}\n+\n+    async def _scenario():\n+        class _Task(MCPServerTask):\n+            def _is_http(self):\n+                return False\n+\n+            def _deregister_tools(self):\n+                state[\"parked\"] = True\n+                self._registered_tool_names = []\n+\n+            async def _run_stdio(self, config):\n+                state[\"transport_calls\"] += 1\n+                call = state[\"transport_calls\"]\n+\n+                if call == 1:\n+                    self.session = object()\n+                    self._ready.set()\n+                    self.session = None\n+                    raise RuntimeError(\"first failure\")\n+\n+                # All subsequent calls fail WITHOUT establishing a session\n+                # (no _reset_server_error, no retry reset). This simulates\n+                # genuinely consecutive failures.\n+                raise RuntimeError(f\"failure {call}\")\n+\n+        task = _Task(\"srv\")\n+        task._registered_tool_names = [\"srv__tool\"]\n+\n+        run_task = asyncio.ensure_future(task.run({\"command\": \"x\"}))\n+\n+        for _ in range(500):\n+            await _real_sleep(0)\n+            if state[\"parked\"] or run_task.done():\n+                break\n+\n+        assert state[\"parked\"], (\n+            \"server should park on consecutive failures without successful reconnect\"\n+        )\n+\n+        task._shutdown_event.set()\n+        task._reconnect_event.set()\n+        try:\n+            await asyncio.wait_for(run_task, timeout=2)\n+        except (asyncio.TimeoutError, asyncio.CancelledError, Exception):\n+            run_task.cancel()\n+\n+    asyncio.run(_scenario())"]},{"file":"tests/tools/test_mcp_stability.py","hunks":["@@ -560,7 +560,7 @@ async def fake_run_stdio(self_inner, config):\n         asyncio.get_event_loop().run_until_complete(_run())\n \n     def test_initial_connect_gives_up_after_max_retries(self):\n-        \"\"\"Server gives up after _MAX_INITIAL_CONNECT_RETRIES failures.\"\"\"\n+        \"\"\"Server parks (does not exit) after _MAX_INITIAL_CONNECT_RETRIES failures.\"\"\"\n         from tools.mcp_tool import MCPServerTask, _MAX_INITIAL_CONNECT_RETRIES\n \n         call_count = 0","@@ -583,8 +583,13 @@ async def fake_run_stdio(self_inner, config):\n                 assert \"DNS resolution failed\" in str(server._error)\n                 # 1 initial + N retries = _MAX_INITIAL_CONNECT_RETRIES + 1 total attempts\n                 assert call_count == _MAX_INITIAL_CONNECT_RETRIES + 1\n+                # The task parks for later revival instead of exiting.\n+                await asyncio.sleep(0)\n+                assert not task.done(), \"run task should park, not exit\"\n \n-                await task\n+                server._shutdown_event.set()\n+                server._reconnect_event.set()\n+                await asyncio.wait_for(task, timeout=5)\n \n         asyncio.get_event_loop().run_until_complete(_run())\n "]},{"file":"tests/tools/test_mcp_tool.py","hunks":["@@ -1876,12 +1876,19 @@ async def _test():\n \n             with patch.object(MCPServerTask, \"_run_stdio\", patched_run_stdio), \\\n                  patch(\"asyncio.sleep\", new_callable=AsyncMock):\n-                await server.run({\"command\": \"test\"})\n+                task = asyncio.ensure_future(server.run({\"command\": \"test\"}))\n+                await server._ready.wait()\n \n-            # Now retries up to _MAX_INITIAL_CONNECT_RETRIES before giving up\n-            assert run_count == _MAX_INITIAL_CONNECT_RETRIES + 1\n-            assert server._error is not None\n-            assert \"cannot connect\" in str(server._error)\n+                # Now retries up to _MAX_INITIAL_CONNECT_RETRIES, then PARKS\n+                # (keeps the task alive for later revival) instead of exiting.\n+                assert run_count == _MAX_INITIAL_CONNECT_RETRIES + 1\n+                assert server._error is not None\n+                assert \"cannot connect\" in str(server._error)\n+                assert not task.done(), \"run task should park, not exit\"\n+\n+                server._shutdown_event.set()\n+                server._reconnect_event.set()\n+                await asyncio.wait_for(task, timeout=5)\n \n         asyncio.run(_test())\n "]},{"file":"tools/mcp_tool.py","hunks":["@@ -321,6 +321,11 @@ def _check_logging_callback_support() -> bool:\n _MAX_RECONNECT_RETRIES = 5\n _MAX_INITIAL_CONNECT_RETRIES = 3 # retries for the very first connection attempt\n _MAX_BACKOFF_SECONDS = 60\n+# While parked (reconnect budget exhausted, tools deregistered) the run task\n+# wakes on this cadence and attempts one revival probe. Without it a parked\n+# server is unrevivable: its tools are out of the registry, so no tool call\n+# can ever reach the circuit-breaker half-open probe or _signal_reconnect.\n+_PARKED_RETRY_INTERVAL = 300     # seconds between parked self-probes\n \n # Keepalive cadence for HTTP/SSE sessions. The MCP spec lets a server expire\n # idle sessions on any TTL it chooses (Streamable HTTP \"Session Management\"),","@@ -1475,6 +1480,7 @@ class MCPServerTask:\n         \"_rpc_lock\", \"_pending_refresh_tasks\",\n         \"_pending_call_context\",\n         \"initialize_result\", \"_ping_unsupported\",\n+        \"_reconnect_retries\",\n     )\n \n     def __init__(self, name: str):","@@ -1496,6 +1502,7 @@ def __init__(self, name: str):\n         self._sampling: Optional[SamplingHandler] = None\n         self._elicitation: Optional[ElicitationHandler] = None\n         self._registered_tool_names: list[str] = []\n+        self._reconnect_retries: int = 0\n         self._auth_type: str = \"\"\n         self._refresh_lock = asyncio.Lock()\n         # MCP stdio sessions are a single JSON-RPC stream. Some servers emit","@@ -1837,20 +1844,27 @@ async def _wait_for_lifecycle_event(self) -> str:\n         self._reconnect_event.clear()\n         return \"reconnect\"\n \n-    async def _wait_for_reconnect_or_shutdown(self) -> str:\n+    async def _wait_for_reconnect_or_shutdown(\n+        self, timeout: Optional[float] = None\n+    ) -> str:\n         \"\"\"Block until a reconnect or shutdown is requested while parked.\n \n         Used by :meth:`run` after the reconnect budget is exhausted. The\n         task stays alive (so ``_reconnect_event`` always has a listener) but\n         does no work until something explicitly asks it to come back —\n-        the circuit-breaker half-open probe, OAuth recovery, or a manual\n-        ``/mcp`` refresh.\n+        OAuth recovery, a manual ``/mcp`` refresh — or, when ``timeout`` is\n+        given, until the timeout elapses (a periodic self-probe). The timed\n+        wake matters because parking deregisters this server's tools, so\n+        no tool call can ever reach the circuit-breaker's half-open probe\n+        or ``_signal_reconnect`` — without a self-probe a parked server\n+        would be unrevivable short of a full reload.\n \n         Returns:\n             ``\"shutdown\"`` if the server should exit the run loop entirely,\n-            ``\"reconnect\"`` if it should rebuild the transport. The reconnect\n-            event is cleared before returning so the next park cycle starts\n-            from a fresh signal. Shutdown takes precedence.\n+            ``\"reconnect\"`` if it should rebuild the transport (explicit\n+            request or self-probe timeout). The reconnect event is cleared\n+            before returning so the next park cycle starts from a fresh\n+            signal. Shutdown takes precedence.\n         \"\"\"\n         shutdown_task = asyncio.ensure_future(self._shutdown_event.wait())\n         reconnect_task = asyncio.ensure_future(self._reconnect_event.wait())","@@ -1858,6 +1872,7 @@ async def _wait_for_reconnect_or_shutdown(self) -> str:\n             await asyncio.wait(\n                 {shutdown_task, reconnect_task},\n                 return_when=asyncio.FIRST_COMPLETED,\n+                timeout=timeout,\n             )\n         finally:\n             for t in (shutdown_task, reconnect_task):","@@ -1984,6 +1999,10 @@ async def _run_stdio(self, config: dict):\n                     # prior outage so the first call after recovery isn't\n                     # gated on a stale consecutive-failure count (#16788).\n                     _reset_server_error(self.name)\n+                    # This session is live: reset the reconnect retry counter\n+                    # so transient prior failures do not accumulate toward\n+                    # permanent parking (#57604).\n+                    self._reconnect_retries = 0\n                     # stdio transport does not use OAuth, but we still honor\n                     # _reconnect_event (e.g. future manual /mcp refresh) for\n                     # consistency with _run_http.","@@ -2221,6 +2240,7 @@ def _mcp_http_client_factory(\n                     # prior outage so the first call after recovery isn't\n                     # gated on a stale consecutive-failure count (#16788).\n                     _reset_server_error(self.name)\n+                    self._reconnect_retries = 0\n                     reason = await self._wait_for_lifecycle_event()\n                     if reason == \"reconnect\":\n                         logger.info(","@@ -2274,6 +2294,7 @@ async def _strip_auth_on_cross_origin_redirect(response):\n                         # a prior outage so the first call after recovery\n                         # isn't gated on a stale failure count (#16788).\n                         _reset_server_error(self.name)\n+                        self._reconnect_retries = 0\n                         reason = await self._wait_for_lifecycle_event()\n                         if reason == \"reconnect\":\n                             logger.info(","@@ -2301,6 +2322,7 @@ async def _strip_auth_on_cross_origin_redirect(response):\n                     # prior outage so the first call after recovery isn't\n                     # gated on a stale consecutive-failure count (#16788).\n                     _reset_server_error(self.name)\n+                    self._reconnect_retries = 0\n                     reason = await self._wait_for_lifecycle_event()\n                     if reason == \"reconnect\":\n                         logger.info(","@@ -2331,6 +2353,7 @@ async def _discover_tools(self):\n                 self.name,\n             )\n             self._tools = []\n+            self._register_discovered_tools_if_needed()\n             return\n         async with self._rpc_lock:\n             tools_result = await self.session.list_tools()","@@ -2339,6 +2362,23 @@ async def _discover_tools(self):\n             if hasattr(tools_result, \"tools\")\n             else []\n         )\n+        self._register_discovered_tools_if_needed()\n+\n+    def _register_discovered_tools_if_needed(self) -> None:\n+        \"\"\"Re-register tools after a post-ready reconnect if needed.\n+\n+        Initial registration is performed by ``_discover_and_register_server``\n+        after ``start()`` completes. During a later reconnect, however,\n+        ``_ready`` remains set; if outage handling previously deregistered\n+        stale tools (parking calls ``_deregister_tools``), a successful\n+        revival must publish the freshly discovered tools again — otherwise\n+        the transport comes back alive with zero registered tools.\n+        \"\"\"\n+        if not self._ready.is_set() or self._registered_tool_names:\n+            return\n+        self._registered_tool_names = _register_server_tools(\n+            self.name, self, self._config\n+        )\n \n     async def run(self, config: dict):\n         \"\"\"Long-lived coroutine: connect, discover tools, wait, disconnect.","@@ -2417,7 +2457,7 @@ async def run(self, config: dict):\n                     self._ready.set()\n                     return\n \n-        retries = 0\n+        self._reconnect_retries = 0\n         initial_retries = 0\n         backoff = 1.0\n ","@@ -2446,7 +2486,7 @@ async def run(self, config: dict):\n                 # failure budget — otherwise transient drops accumulated over\n                 # a long-lived session would eventually exhaust it and\n                 # permanently kill an otherwise-healthy server.\n-                retries = 0\n+                self._reconnect_retries = 0\n                 backoff = 1.0\n                 # Reset the session reference; _run_http/_run_stdio will\n                 # repopulate it on successful re-entry.","@@ -2489,12 +2529,30 @@ async def run(self, config: dict):\n                     if initial_retries > _MAX_INITIAL_CONNECT_RETRIES:\n                         logger.warning(\n                             \"MCP server '%s' failed initial connection after \"\n-                            \"%d attempts, giving up: %s\",\n+                            \"%d attempts, parking until a reconnect is requested: %s\",\n                             self.name, _MAX_INITIAL_CONNECT_RETRIES, exc,\n                         )\n                         self._error = exc\n                         self._ready.set()\n-                        return\n+                        self._deregister_tools()\n+                        self._reconnect_event.clear()\n+                        parked = await self._wait_for_reconnect_or_shutdown(\n+                            timeout=_PARKED_RETRY_INTERVAL\n+                        )\n+                        if parked == \"shutdown\":\n+                            return\n+                        logger.info(\n+                            \"MCP server '%s': attempting revival after initial \"\n+                            \"connection failures (self-probe or explicit \"\n+                            \"reconnect request); rebuilding transport.\",\n+                            self.name,\n+                        )\n+                        initial_retries = 0\n+                        self._reconnect_retries = 0\n+                        backoff = 1.0\n+                        self._error = None\n+                        self._ready.clear()\n+                        continue\n \n                     logger.warning(\n                         \"MCP server '%s' initial connection failed \"","@@ -2520,41 +2578,49 @@ async def run(self, config: dict):\n                     )\n                     return\n \n-                retries += 1\n-                if retries > _MAX_RECONNECT_RETRIES:\n+                self._reconnect_retries += 1\n+                if self._reconnect_retries > _MAX_RECONNECT_RETRIES:\n                     logger.warning(\n                         \"MCP server '%s' failed after %d reconnection attempts, \"\n-                        \"parking until a reconnect is requested: %s\",\n-                        self.name, _MAX_RECONNECT_RETRIES, exc,\n+                        \"parking; will self-probe every %ds until it recovers: %s\",\n+                        self.name, _MAX_RECONNECT_RETRIES,\n+                        _PARKED_RETRY_INTERVAL, exc,\n                     )\n                     # Do NOT return — exiting the task orphans the server:\n-                    # nothing would ever listen for _reconnect_event again,\n-                    # so a half-open circuit-breaker probe could never revive\n-                    # it and the server would be permanently wedged for the\n+                    # nothing would ever listen for _reconnect_event again\n+                    # and the server would be permanently wedged for the\n                     # life of the process (#16788). Instead, drop the phantom\n-                    # tools from the registry and park as a dormant listener.\n-                    # A future _reconnect_event.set() — from the breaker's\n-                    # half-open probe, OAuth recovery, or a manual /mcp\n-                    # refresh — wakes us to rebuild the transport (respawning\n-                    # a dead stdio subprocess in the process).\n+                    # tools from the registry and park. Because parking\n+                    # deregisters the tools, no tool call can reach the\n+                    # circuit-breaker half-open probe or _signal_reconnect —\n+                    # so the park is a TIMED wait: every _PARKED_RETRY_INTERVAL\n+                    # we wake and attempt one reconnect ourselves (#57129).\n+                    # An explicit _reconnect_event.set() (OAuth recovery,\n+                    # manual /mcp refresh) still wakes us immediately.\n                     self._deregister_tools()\n                     self._reconnect_event.clear()\n-                    parked = await self._wait_for_reconnect_or_shutdown()\n+                    parked = await self._wait_for_reconnect_or_shutdown(\n+                        timeout=_PARKED_RETRY_INTERVAL\n+                    )\n                     if parked == \"shutdown\":\n                         return\n                     logger.info(\n-                        \"MCP server '%s': reconnect requested while parked; \"\n+                        \"MCP server '%s': attempting revival from parked state \"\n+                        \"(self-probe or explicit reconnect request); \"\n                         \"rebuilding transport.\",\n                         self.name,\n                     )\n-                    retries = 0\n+                    # One probe attempt per wake: budget of 1 so a still-dead\n+                    # server parks again for another interval instead of\n+                    # burning 5 rapid retries each cycle.\n+                    self._reconnect_retries = _MAX_RECONNECT_RETRIES\n                     backoff = 1.0\n                     continue\n \n                 logger.warning(\n                     \"MCP server '%s' connection lost (attempt %d/%d), \"\n                     \"reconnecting in %.0fs: %s\",\n-                    self.name, retries, _MAX_RECONNECT_RETRIES,\n+                    self.name, self._reconnect_retries, _MAX_RECONNECT_RETRIES,\n                     backoff, exc,\n                 )\n                 await asyncio.sleep(backoff)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}