MCP server connectivity and reconnection issues
NousResearch/hermes-agent · cluster 36264 · mcp
Data as of
Nothing open on GitHub. Fix merged in #59331. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #59331. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #59331. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A cluster of bug reports and fixes around MCP servers appearing connected but failing tool calls, covering stale/parked server entries, session expiry, reconnection resilience, tool registration on revival, and SSE transport handling.
Open issues0
Open PRs0
Fixes on record
-
#59331 fix(mcp): session-expired retry waits for a fresh session; wake parked servers at session startup (salvage #27052)
files:
scripts/release.py,tests/tools/test_mcp_circuit_breaker.py,tests/tools/test_mcp_register_wakes_stale.py,tests/tools/test_mcp_tool_session_expired.py,tools/mcp_tool.pyShow the diff
-
scripts/release.py@@ -1211,6 +1211,7 @@ "[redacted:email]": "BlackishGreen33", "[redacted:email]": "gnodet", # PR #37598 salvage (MCP preflight POST probe fallback) "[redacted:email]": "kaishi00", # PR #55203 salvage (skip_preflight opt-out) + "[redacted:email]": "setclock", # PR #27052 salvage (MCP session-expired retry waits for a distinct fresh session) "[redacted:email]": "sberan", # PR #54494 salvage (--connect-timeout flag on hermes mcp add) "[redacted:email]": "labsobsidian", # PR #56699 salvage (MCP OAuth login connect_timeout floor) "[redacted:email]": "SaulJWu", -
tests/tools/test_mcp_circuit_breaker.py@@ -31,14 +31,49 @@ def _install_stub_server(mcp_tool_module, name: str, call_tool_impl): ``call_tool_impl`` is an async function stored at ``session.call_tool`` (it's what the tool handler invokes). """ + import threading + server = MagicMock() server.name = name session = MagicMock() session.call_tool = call_tool_impl server.session = session - server._reconnect_event = MagicMock() - server._ready = MagicMock() - server._ready.is_set.return_value = True + + ready_flag = threading.Event() + ready_flag.set() + + class _ReadyAdapter: + def is_set(self): + return ready_flag.is_set() + + def clear(self): + ready_flag.clear() + + def set(self): + ready_flag.set() + + class _ReconnectAdapter: + def __init__(self): + self.set_calls = 0 + + def set(self): + self.set_calls += 1 + old_session = server.session + new_session = MagicMock() + if old_session is not None: + new_session.call_tool = old_session.call_tool + elif call_tool_impl is not None: + new_session.call_tool = call_tool_impl + server.session = new_session + ready_flag.set() + + # MagicMock-compat shim: the dead-session half-open test asserts the + # reconnect signal was delivered exactly once. + def assert_called_once(self): + assert self.set_calls == 1, f"set() called {self.set_calls} times" + + server._reconnect_event = _ReconnectAdapter() + server._ready = _ReadyAdapter() mcp_tool_module._servers[name] = server mcp_tool_module._server_error_counts.pop(name, None) @@ -220,7 +255,7 @@ def _fake_monotonic(): # Clean "reconnecting" error, and a reconnect was actually signalled. assert "reconnect" in parsed.get("error", "").lower(), parsed - server._reconnect_event.set.assert_called_once() + server._reconnect_event.assert_called_once() finally: _cleanup(mcp_tool, "srv") -
tests/tools/test_mcp_register_wakes_stale.py@@ -0,0 +1,62 @@ +"""New sessions must wake parked/stale cached MCP servers immediately. + +Regression for #50170: after a keepalive failure parks a server, its tools +are deregistered — so a NEW agent session starting up saw the tools silently +absent and had no way to trigger recovery until the next timed self-probe +(up to _PARKED_RETRY_INTERVAL later). register_mcp_servers now nudges any +cached entry whose session is None via _signal_reconnect. +""" + +import pytest + + [redacted:email]_isolate +def test_register_wakes_stale_cached_server(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + from tools import mcp_tool + + woken: list[str] = [] + + class _Event: + def __init__(self, name): + self._name = name + + def set(self): + woken.append(self._name) + + class _Stale: + session = None + + def __init__(self, name): + self.name = name + self._reconnect_event = _Event(name) + self._registered_tool_names: list[str] = [] + + class _Alive: + session = object() + + def __init__(self, name): + self.name = name + self._reconnect_event = _Event(name) + self._registered_tool_names = [f"{name}__tool"] + + monkeypatch.setattr(mcp_tool, "_MCP_AVAILABLE", True) + stale = _Stale("parked-srv") + alive = _Alive("healthy-srv") + monkeypatch.setitem(mcp_tool._servers, "parked-srv", stale) + monkeypatch.setitem(mcp_tool._servers, "healthy-srv", alive) + + try: + result = mcp_tool.register_mcp_servers({ + "parked-srv": {"url": "http://127.0.0.1:9/mcp"}, + "healthy-srv": {"url": "http://127.0.0.1:9/mcp"}, + }) + # Both cached → no new connections attempted; existing names returned. + assert "healthy-srv__tool" in result + # The parked (session=None) entry got a reconnect nudge; the healthy + # one was left alone. + assert woken == ["parked-srv"] + finally: + mcp_tool._servers.pop("parked-srv", None) + mcp_tool._servers.pop("healthy-srv", None) -
tests/tools/test_mcp_tool_session_expired.py@@ -112,23 +112,47 @@ def _install_stub_server(name: str = "wpcom"): server = MagicMock() server.name = name + + ready_flag = threading.Event() + ready_flag.set() + + class _ReadyAdapter: + def is_set(self): + return ready_flag.is_set() + + def clear(self): + ready_flag.clear() + + def set(self): + ready_flag.set() + + server._ready = _ReadyAdapter() + # _reconnect_event is called via loop.call_soon_threadsafe(…set); use - # a threading-safe substitute. + # a threading-safe substitute. The production reconnect path must not + # treat the old stale session as fresh, so this test double swaps in a + # distinct session object when reconnect is requested. reconnect_flag = threading.Event() class _EventAdapter: def set(self): reconnect_flag.set() + old_session = server.session + new_session = MagicMock() + for method_name in ( + "call_tool", + "list_resources", + "read_resource", + "list_prompts", + "get_prompt", + ): + if hasattr(old_session, method_name): + setattr(new_session, method_name, getattr(old_session, method_name)) + server.session = new_session + ready_flag.set() server._reconnect_event = _EventAdapter() - # Immediately "ready" — simulates a fast reconnect (_ready.is_set() - # is polled by _handle_session_expired_and_retry until the timeout). - ready_flag = threading.Event() - ready_flag.set() - server._ready = MagicMock() - server._ready.is_set = ready_flag.is_set - # session attr must be truthy for the handler's initial check # (``if not server or not server.session``) and for the post- # reconnect readiness probe (``srv.session is not None``). @@ -188,6 +212,74 @@ async def _call_sequence(*a, **kw): mcp_tool._server_error_counts.pop("wpcom", None) +def test_session_expired_retry_waits_for_new_session(monkeypatch, tmp_path): + """Regression for long-lived HTTP/stream MCP sessions. + + If the reconnect helper only checks ``_ready.is_set()`` and + ``session is not None``, it can return immediately while ``session`` still + points at the stale transport. The retry then hits the same dead session + and the circuit breaker eventually reports the server as unreachable. The + handler must wait for a distinct session object before retrying. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + from tools import mcp_tool + from tools.mcp_tool import _make_tool_handler + + mcp_tool._ensure_mcp_loop() + server = MagicMock() + server.name = "hindsight" + ready_flag = threading.Event() + ready_flag.set() + + class _ReadyAdapter: + def is_set(self): + return ready_flag.is_set() + + def clear(self): + ready_flag.clear() + + def set(self): + ready_flag.set() + + old_session = MagicMock() + + async def _old_call(*a, **kw): + raise RuntimeError("Session terminated") + + old_session.call_tool = _old_call + new_session = MagicMock() + + async def _new_call(*a, **kw): + result = MagicMock() + result.isError = False + result.content = [MagicMock(type="text", text="bank ok")] + result.structuredContent = None + return result + + new_session.call_tool = _new_call + server.session = old_session + server._ready = _ReadyAdapter() + + class _ReconnectAdapter: + def set(self): + server.session = new_session + ready_flag.set() + + server._reconnect_event = _ReconnectAdapter() + mcp_tool._servers["hindsight"] = server + mcp_tool._server_error_counts.pop("hindsight", None) + + try: + handler = _make_tool_handler("hindsight", "get_bank", 10.0) + parsed = json.loads(handler({})) + assert parsed.get("result") == "bank ok", parsed + assert mcp_tool._server_error_counts.get("hindsight", 0) == 0 + finally: + mcp_tool._servers.pop("hindsight", None) + mcp_tool._server_error_counts.pop("hindsight", None) + + def test_call_tool_handler_non_session_expired_error_falls_through( monkeypatch, tmp_path ): -
tools/mcp_tool.py@@ -2539,12 +2539,12 @@ async def run(self, config: dict): # permanently kill an otherwise-healthy server. self._reconnect_retries = 0 backoff = 1.0 - # Reset the session reference; _run_http/_run_stdio will - # repopulate it on successful re-entry. + # Reset the session reference and readiness; _run_http/_run_stdio + # will repopulate both on successful re-entry. Leaving + # _ready set here lets handler-side recovery mistake the stale + # pre-reconnect session for a fresh one and retry too early. + self._ready.clear() self.session = None - # Keep _ready set across reconnects so tool handlers can - # still detect a transient in-flight state — it'll be - # re-set after the fresh session initializes. continue except asyncio.CancelledError: # Task was cancelled (shutdown, gateway restart, explicit @@ -2816,6 +2816,89 @@ def _signal_reconnect(server: Any) -> bool: return True +def _wait_for_server_session_ready( + srv: "MCPServerTask", + *, + old_session: Any = None, + timeout: float = 15.0, +) -> bool: + """Wait for an MCP server to expose a usable session. + + Tool handlers run in normal worker threads while the MCP transport lives on + the module's background asyncio loop. During a reconnect there is a short + window where ``srv.session`` is ``None`` (or still points at the stale + session until the lifecycle coroutine has left the transport context). A + handler that blindly retries in that window can burn circuit-breaker strikes + and return ``not connected`` even though the reconnect is already in + progress. + + When ``old_session`` is supplied, require the observed session object to be + different so callers do not mistake the pre-reconnect, stale session for a + fresh one. + """ + # Iteration-bounded rather than deadline-bounded: several tests (and the + # circuit-breaker cooldown logic) monkeypatch time.monotonic to a frozen + # clock, which would make a monotonic-deadline loop spin forever. + poll_interval = 0.25 + iterations = max(1, int(max(float(timeout), 0.0) / poll_interval)) + for i in range(iterations): + session = getattr(srv, "session", None) + ready = getattr(srv, "_ready", None) + is_ready = True + if ready is not None and hasattr(ready, "is_set"): + try: + is_ready = bool(ready.is_set()) + except Exception: + is_ready = True + if session is not None and session is not old_session and is_ready: + return True + if i < iterations - 1: + time.sleep(poll_interval) + return False + + +def _signal_reconnect_and_wait( + server_name: str, + srv: "MCPServerTask", + *, + op_description: str, + timeout: float = 15.0, +) -> bool: + """Ask a live MCP server task to rebuild its transport session. + + The important detail is clearing ``_ready`` on the MCP event loop before + setting ``_reconnect_event``. Older code left ``_ready`` set across + reconnects, so the caller's readiness poll could return immediately and + retry against the same dead HTTP/stream session. That was observed as + repeated ``Session terminated`` / ``not connected`` / circuit-breaker + failures in long-lived gateway sessions even though a fresh CLI process + could connect successfully. + """ + loop = _mcp_loop + if loop is None or not loop.is_running(): + return False + + old_session = getattr(srv, "session", None) + + def _request_reconnect() -> None: + ready = getattr(srv, "_ready", None) + if ready is not None and hasattr(ready, "clear"): + ready.clear() + reconnect_event = getattr(srv, "_reconnect_event", None) + if reconnect_event is not None and hasattr(reconnect_event, "set"): + reconnect_event.set() + + logger.info( + "MCP server '%s': %s requesting transport reconnect", + server_name, op_description, + ) + loop.call_soon_threadsafe(_request_reconnect) + return _wait_for_server_session_ready( + srv, + old_session=old_session, + timeout=timeout, + ) + # --------------------------------------------------------------------------- # Auth-failure detection helpers (Task 6 of MCP OAuth consolidation) # --------------------------------------------------------------------------- @@ -2941,41 +3024,24 @@ async def _recover(): if recovered: with _lock: srv = _servers.get(server_name) + reconnected = False if srv is not None and hasattr(srv, "_reconnect_event"): - loop = _mcp_loop - if loop is not None and loop.is_running(): - loop.call_soon_threadsafe(srv._reconnect_event.set) - - # Wait briefly for the session to come back ready. Bounded - # so that a stuck reconnect falls through to the error - # path rather than hanging the caller. The async helper - # runs on the MCP event loop via _run_on_mcp_loop so it - # does NOT block the event loop during the poll interval. - async def _await_ready() -> bool: - deadline = time.monotonic() + 15 - while time.monotonic() < deadline: - if srv.session is not None and srv._ready.is_set(): - return True - await asyncio.sleep(0.25) - return False - - try: - _run_on_mcp_loop(_await_ready(), timeout=15) - except Exception as exc: - logger.warning( - "MCP OAuth '%s': ready poll failed: %s", - server_name, exc, - ) + reconnected = _signal_reconnect_and_wait( + server_name, + srv, + op_description=f"{op_description} after OAuth recovery", + timeout=15, + ) - # A successful OAuth recovery is independent evidence that the - # server is viable again, so close the circuit breaker here — - # not only on retry success. Without this, a reconnect - # followed by a failing retry would leave the breaker pinned - # above threshold forever (the retry-exception branch below - # bumps the count again). The post-reset retry still goes - # through _bump_server_error on failure, so a genuinely broken - # server will re-trip the breaker as normal. - _reset_server_error(server_name) + # A successful OAuth recovery + transport reconnect is independent + # evidence that the server is viable again, so close the circuit + # breaker here — not only on retry success. Without this, a reconnect + # followed by a failing retry would leave the breaker pinned above + # threshold forever. The post-reset retry still goes through + # _bump_server_error on failure, so a genuinely broken server will + # re-trip the breaker as normal. + if reconnected: + _reset_server_error(server_name) try: result = retry_call() @@ -3104,15 +3170,12 @@ def _handle_session_expired_and_retry( # Trigger the same reconnect mechanism the OAuth recovery path # uses, then wait briefly for the new session to come back ready. - loop.call_soon_threadsafe(srv._reconnect_event.set) - deadline = time.monotonic() + 15 - ready = False - while time.monotonic() < deadline: - if srv.session is not None and srv._ready.is_set(): - ready = True - break - time.sleep(0.25) - if not ready: + if not _signal_reconnect_and_wait( + server_name, + srv, + op_description=op_description, + timeout=15, + ): logger.warning( "MCP server '%s': reconnect did not ready within 15s after " "session-expired error; falling through to error response.", @@ -3561,27 +3624,38 @@ def _handler(args: dict, **kwargs) -> str: }, ensure_ascii=False) if not server.session: - # No live session — the server task is reconnecting, or it has - # exhausted its retry budget and parked (e.g. a dead stdio - # subprocess). Probing here would write into a dead/absent - # transport and re-arm the breaker forever (#16788). Instead, - # ask the (always-present) server task to rebuild the transport - # — which respawns a dead stdio subprocess — and return a clean - # "reconnecting" error so the model backs off without burning - # iterations. The breaker resets once the fresh session - # initializes (_run_stdio/_run_http call _reset_server_error). - _bump_server_error(server_name) - if _signal_reconnect(server): + # No live session. A reconnect may already be completing (the + # transport swaps in a fresh session object asynchronously) — + # wait briefly before treating this as a failure, so a + # transient reconnect window doesn't burn a circuit-breaker + # strike (#26892). + if _wait_for_server_session_ready( + server, timeout=min(5.0, float(tool_timeout or 5.0)), + ): + pass # Fresh session arrived; proceed below. + else: + # Still down — the server task is reconnecting, or it has + # exhausted its retry budget and parked (e.g. a dead stdio + # subprocess). Probing here would write into a dead/absent + # transport and re-arm the breaker forever (#16788). Instead, + # ask the (always-present) server task to rebuild the + # transport — which respawns a dead stdio subprocess — and + # return a clean "reconnecting" error so the model backs off + # without burning iterations. The breaker resets once the + # fresh session initializes (_run_stdio/_run_http call + # _reset_server_error). + _bump_server_error(server_name) + if _signal_reconnect(server): + return json.dumps({ + "error": ( + f"MCP server '{server_name}' transport is down; " + f"reconnect requested. Do NOT retry this tool " + f"immediately — give it a few seconds to come back." + ) + }, ensure_ascii=False) return json.dumps({ - "error": ( - f"MCP server '{server_name}' transport is down; " - f"reconnect requested. Do NOT retry this tool " - f"immediately — give it a few seconds to come back." - ) + "error": f"MCP server '{server_name}' is not connected" }, ensure_ascii=False) - return json.dumps({ - "error": f"MCP server '{server_name}' is not connected" - }, ensure_ascii=False) async def _call(): async with server._rpc_lock: @@ -4529,6 +4603,16 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]: for k, v in servers.items() if k not in _servers and _parse_boolish(v.get("enabled", True), default=True) } + # Cached entries with no live session are parked or mid-reconnect. + # Their tools are deregistered, so nothing else can reach + # _signal_reconnect — without this nudge a new session silently + # waits up to _PARKED_RETRY_INTERVAL for the next self-probe + # (#50170). Wake them now so their tools come back promptly. + stale_cached = [ + _servers[k] + for k in servers + if k in _servers and getattr(_servers[k], "session", None) is None + ] _server_connecting.update(new_servers) for srv_name in new_servers: _server_connect_errors.pop(srv_name, None) @@ -4539,6 +4623,9 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]: else: _parallel_safe_servers.discard(sanitize_mcp_name_component(srv_name)) + for srv in stale_cached: + _signal_reconnect(srv) + if not new_servers: return _existing_tool_names()
-
2 PRs closed without merging
From the close comments: duplicate 1 · other 1
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.25: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- mcp
- Merged PRs
- #59331
- Closed issues
- 3
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 6 Jul 2026