Files
hermes-agent/tests/tools/test_mcp_initial_connect_shutdown.py
T
Brooklyn Nicholson f99d291247 fix(mcp): let a server that 401s at startup come back after re-login
An auth failure on the very first connect returned out of the run loop
instead of parking. That ended the run task, and the task is the only
listener on _reconnect_event — so the server stayed dead for the life of
the process. `hermes mcp login`, a /mcp refresh, and the 300s self-probe
all had nothing left to wake, and the only cure was a full restart.

_classify_mcp_failure already calls 401/403 "permanent" and documents
that run() parks those immediately; the early return above it meant auth
was the one permanent failure that never got there. Park it with the
others and keep the tailored log line, now pointing at `hermes mcp
login <server>`.
2026-08-08 02:21:36 -05:00

287 lines
10 KiB
Python

"""Regression tests for initial MCP failure ownership and teardown."""
import asyncio
import json
import threading
from types import SimpleNamespace
import pytest
def _reset_mcp_state(mcp_tool) -> None:
mcp_tool.shutdown_mcp_servers()
with mcp_tool._lock:
mcp_tool._servers.clear()
mcp_tool._server_connecting.clear()
mcp_tool._server_connect_errors.clear()
def _cleanup_mcp_state(mcp_tool, extra_servers=()) -> None:
with mcp_tool._lock:
loop = mcp_tool._mcp_loop
if loop is not None and loop.is_running():
for server in extra_servers:
task = getattr(server, "_task", None)
if task is not None and not task.done():
mcp_tool._run_on_mcp_loop(server.shutdown, timeout=5)
mcp_tool.shutdown_mcp_servers()
with mcp_tool._lock:
mcp_tool._servers.clear()
mcp_tool._server_connecting.clear()
mcp_tool._server_connect_errors.clear()
def test_initial_connect_failure_is_registry_owned_and_reaped(monkeypatch, tmp_path):
"""Normal discovery must retain the parked task for clean shutdown."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from tools import mcp_tool
_reset_mcp_state(mcp_tool)
created = []
class _FailingServerTask(mcp_tool.MCPServerTask):
def __init__(self, name):
super().__init__(name)
created.append(self)
async def _run_stdio(self, config):
raise ConnectionError("deterministic initial failure")
monkeypatch.setattr(mcp_tool, "MCPServerTask", _FailingServerTask)
monkeypatch.setattr(mcp_tool, "_MCP_AVAILABLE", True)
monkeypatch.setattr(mcp_tool, "_MAX_INITIAL_CONNECT_RETRIES", 0)
monkeypatch.setattr(mcp_tool, "_PARKED_RETRY_INTERVAL", 3600)
real_stop = mcp_tool._stop_mcp_loop
pending_at_stop = []
async def _pending_tasks():
current = asyncio.current_task()
return sorted(
task.get_coro().__qualname__
for task in asyncio.all_tasks()
if task is not current and not task.done()
)
def _observed_stop(*, only_if_idle=False):
pending_at_stop.extend(
mcp_tool._run_on_mcp_loop(_pending_tasks, timeout=5)
)
return real_stop(only_if_idle=only_if_idle)
monkeypatch.setattr(mcp_tool, "_stop_mcp_loop", _observed_stop)
try:
assert mcp_tool.register_mcp_servers({
"initial-failure": {"command": "unused", "connect_timeout": 5}
}) == []
assert len(created) == 1
server = created[0]
with mcp_tool._lock:
assert mcp_tool._servers["initial-failure"] is server
assert "deterministic initial failure" in (
mcp_tool._server_connect_errors["initial-failure"]
)
assert server._task is not None
assert not server._task.done(), "recoverable initial failure was not parked"
mcp_tool.shutdown_mcp_servers()
assert pending_at_stop == [], (
"shutdown left MCP tasks pending at loop stop: "
f"{pending_at_stop!r}"
)
assert server._task.done()
with mcp_tool._lock:
assert mcp_tool._mcp_loop is None
assert mcp_tool._mcp_thread is None
finally:
monkeypatch.setattr(mcp_tool, "_stop_mcp_loop", real_stop)
_cleanup_mcp_state(mcp_tool, created)
def test_initial_connect_failure_revives_same_registered_server(monkeypatch, tmp_path):
"""A cached parked failure must revive through register_mcp_servers()."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from tools import mcp_tool
from tools.registry import ToolRegistry
import tools.registry as registry_module
_reset_mcp_state(mcp_tool)
created = []
backend_up = threading.Event()
revived = threading.Event()
state = {"transport_calls": 0, "tool_calls": 0}
mock_registry = ToolRegistry()
class _Session:
async def call_tool(self, name, arguments):
state["tool_calls"] += 1
return SimpleNamespace(
isError=False,
content=[SimpleNamespace(text=f"revived:{arguments['value']}")],
structuredContent=None,
)
class _RecoveringServerTask(mcp_tool.MCPServerTask):
def __init__(self, name):
super().__init__(name)
created.append(self)
async def _run_stdio(self, config):
assert mcp_tool._connect_server_claim.get() is None
state["transport_calls"] += 1
if not backend_up.is_set():
raise ConnectionError("backend still booting")
self.session = _Session()
self._tools = [SimpleNamespace(
name="ping",
description="Return a deterministic revival result",
inputSchema={
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
)]
# Match the real transports: discovery runs before _ready is set.
self._register_discovered_tools_if_needed()
self._ready.set()
revived.set()
return await self._wait_for_lifecycle_event()
monkeypatch.setattr(mcp_tool, "MCPServerTask", _RecoveringServerTask)
monkeypatch.setattr(mcp_tool, "_MCP_AVAILABLE", True)
monkeypatch.setattr(mcp_tool, "_MAX_INITIAL_CONNECT_RETRIES", 0)
monkeypatch.setattr(mcp_tool, "_PARKED_RETRY_INTERVAL", 3600)
monkeypatch.setattr(registry_module, "registry", mock_registry)
config = {
"recovering": {"command": "unused", "connect_timeout": 5}
}
try:
assert mcp_tool.register_mcp_servers(config) == []
assert len(created) == 1
server = created[0]
with mcp_tool._lock:
assert mcp_tool._servers["recovering"] is server
assert "backend still booting" in (
mcp_tool._server_connect_errors["recovering"]
)
assert not server._task.done()
backend_up.set()
mcp_tool.register_mcp_servers(config)
assert revived.wait(timeout=5), "cached parked server did not revive"
assert len(created) == 1, "revival created a duplicate server task"
with mcp_tool._lock:
assert mcp_tool._servers["recovering"] is server
assert "recovering" not in mcp_tool._server_connect_errors
assert state["transport_calls"] == 2
assert server.session is not None
assert server._error is None
entry = mock_registry.get_entry("mcp__recovering__ping")
assert entry is not None
assert entry.check_fn() is True
assert json.loads(entry.handler({"value": "ok"})) == {
"result": "revived:ok"
}
assert state["tool_calls"] == 1
finally:
_cleanup_mcp_state(mcp_tool, created)
def test_initial_auth_failure_is_retained_and_reaped(monkeypatch, tmp_path):
"""An auth failure must stay parked (revivable) and reap on shutdown.
A 401 used to end the run task outright, which dropped the only listener
on ``_reconnect_event`` — the server could not come back even after the
user re-authenticated. It is now retained like any other parked server,
and must still tear down cleanly.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from tools import mcp_tool
_reset_mcp_state(mcp_tool)
created = []
class _AuthFailingServerTask(mcp_tool.MCPServerTask):
def __init__(self, name):
super().__init__(name)
created.append(self)
async def _run_stdio(self, config):
raise PermissionError("terminal authentication failure")
monkeypatch.setattr(mcp_tool, "MCPServerTask", _AuthFailingServerTask)
monkeypatch.setattr(mcp_tool, "_MCP_AVAILABLE", True)
monkeypatch.setattr(mcp_tool, "_PARKED_RETRY_INTERVAL", 3600)
monkeypatch.setattr(mcp_tool, "_is_auth_error", lambda exc: True)
try:
assert mcp_tool.register_mcp_servers({
"auth-failure": {"command": "unused", "connect_timeout": 5}
}) == []
assert len(created) == 1
server = created[0]
assert not server._task.done(), (
"auth failure ended the run task — the server is unrevivable"
)
with mcp_tool._lock:
assert mcp_tool._servers["auth-failure"] is server
assert "terminal authentication failure" in (
mcp_tool._server_connect_errors["auth-failure"]
)
mcp_tool.shutdown_mcp_servers()
assert server._task.done()
finally:
_cleanup_mcp_state(mcp_tool, created)
def test_standalone_failed_connect_is_reaped_without_global_owner(monkeypatch, tmp_path):
"""Probe-only _connect_server failures must not publish parked servers."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from tools import mcp_tool
_reset_mcp_state(mcp_tool)
created = []
class _ProbeServerTask(mcp_tool.MCPServerTask):
def __init__(self, name):
super().__init__(name)
created.append(self)
async def _run_stdio(self, config):
raise ConnectionError("probe target unavailable")
monkeypatch.setattr(mcp_tool, "MCPServerTask", _ProbeServerTask)
monkeypatch.setattr(mcp_tool, "_MAX_INITIAL_CONNECT_RETRIES", 0)
monkeypatch.setattr(mcp_tool, "_PARKED_RETRY_INTERVAL", 3600)
mcp_tool._ensure_mcp_loop()
try:
with pytest.raises(ConnectionError, match="probe target unavailable"):
mcp_tool._run_on_mcp_loop(
lambda: mcp_tool._connect_server(
"probe-only", {"command": "unused"}
),
timeout=5,
)
assert len(created) == 1
assert created[0]._task.done()
with mcp_tool._lock:
assert "probe-only" not in mcp_tool._servers
assert "probe-only" not in mcp_tool._server_connect_errors
finally:
_cleanup_mcp_state(mcp_tool, created)