hermes-agent/tests/tools/test_mcp_bridge_single_failure.py
Teknium ee23bffee9 fix(mcp): clear connect-cooldown state on every shutdown path
Builds on trevorgordon981's #50589 (cherry-picked as the previous commit).
The #50394 cooldown reset only ran inside the async _shutdown coroutine,
which is skipped on the empty-_servers fast path — the most common state
when a server failed to connect (failed servers are never recorded in
_servers). It was also skipped when the MCP loop wasn't running.

Clear _server_connect_retry_after/_server_connect_failures on the fast
path and in a final unconditional sweep so a full shutdown/restart always
re-attempts every configured server immediately. Adds regression tests
for both paths.
2026-07-21 12:43:27 -07:00

172 lines
7.2 KiB
Python

"""Regression test for #50394.
A single failing stdio MCP server must not churn the whole MCP bridge.
Root cause: a server that fails to connect is never recorded in
``_servers`` (``start()`` raises before the ``_servers[name] = server``
line in ``_discover_and_register_server``). Without a post-failure
cooldown, every subsequent ``register_mcp_servers`` pass (one per agent
worker session) re-spawns the failing server from scratch -- a restart
storm that destabilises the healthy co-located servers. The fix arms a
per-server exponential backoff so a chronically failing server is retried
on a schedule, isolated from the rest of the bridge.
"""
from unittest.mock import patch
import pytest
import tools.mcp_tool as mcp_mod
@pytest.fixture(autouse=True)
def _reset_mcp_state():
"""Snapshot and restore the module-level MCP state around each test."""
snapshot = (
dict(mcp_mod._servers),
set(mcp_mod._server_connecting),
dict(mcp_mod._server_connect_errors),
dict(mcp_mod._server_connect_retry_after),
dict(mcp_mod._server_connect_failures),
)
mcp_mod._servers.clear()
mcp_mod._server_connecting.clear()
mcp_mod._server_connect_errors.clear()
mcp_mod._server_connect_retry_after.clear()
mcp_mod._server_connect_failures.clear()
try:
yield
finally:
(servers, connecting, errs, retry_after, failures) = snapshot
mcp_mod._servers.clear(); mcp_mod._servers.update(servers)
mcp_mod._server_connecting.clear(); mcp_mod._server_connecting.update(connecting)
mcp_mod._server_connect_errors.clear(); mcp_mod._server_connect_errors.update(errs)
mcp_mod._server_connect_retry_after.clear(); mcp_mod._server_connect_retry_after.update(retry_after)
mcp_mod._server_connect_failures.clear(); mcp_mod._server_connect_failures.update(failures)
class TestConnectCooldownHelpers:
def test_failure_arms_exponential_backoff(self):
now = 1000.0
with patch("tools.mcp_tool.time.monotonic", return_value=now):
mcp_mod._record_connect_failure("bad")
d1 = mcp_mod._server_connect_retry_after["bad"]
mcp_mod._record_connect_failure("bad")
d2 = mcp_mod._server_connect_retry_after["bad"]
assert d1 == now + mcp_mod._CONNECT_RETRY_BASE_BACKOFF_SEC
assert d2 == now + mcp_mod._CONNECT_RETRY_BASE_BACKOFF_SEC * 2
assert mcp_mod._server_connect_failures["bad"] == 2
def test_backoff_is_capped(self):
for _ in range(50):
mcp_mod._record_connect_failure("bad")
deadline = mcp_mod._server_connect_retry_after["bad"]
assert deadline <= mcp_mod.time.monotonic() + mcp_mod._CONNECT_RETRY_MAX_BACKOFF_SEC + 1
def test_cooldown_active_then_clears(self):
now = 5000.0
with patch("tools.mcp_tool.time.monotonic", return_value=now):
mcp_mod._record_connect_failure("bad")
assert mcp_mod._connect_cooldown_active("bad") is True
later = now + mcp_mod._CONNECT_RETRY_MAX_BACKOFF_SEC + 1
with patch("tools.mcp_tool.time.monotonic", return_value=later):
assert mcp_mod._connect_cooldown_active("bad") is False
mcp_mod._clear_connect_failure("bad")
assert "bad" not in mcp_mod._server_connect_retry_after
assert "bad" not in mcp_mod._server_connect_failures
def test_unknown_server_not_in_cooldown(self):
assert mcp_mod._connect_cooldown_active("never-seen") is False
@pytest.mark.skipif(not mcp_mod._MCP_AVAILABLE, reason="mcp SDK not installed")
class TestRegisterMcpServersIsolation:
"""register_mcp_servers must not re-spawn a server still in cooldown."""
def _run_with_mocked_connect(self, attempts):
async def fake_connect(name, config):
attempts.append(name)
if name == "bad":
raise ConnectionError("exec: bad: not found")
server = mcp_mod.MCPServerTask(name)
server._registered_tool_names = []
server._tools = []
return server
return patch("tools.mcp_tool._connect_server", side_effect=fake_connect)
def test_failing_server_skipped_on_second_pass(self):
attempts = []
cfg = {
"good": {"command": "good-cmd"},
"bad": {"command": "bad-cmd"},
}
with self._run_with_mocked_connect(attempts), \
patch("tools.mcp_tool._register_server_tools", return_value=[]), \
patch("tools.mcp_tool._filter_suspicious_mcp_servers", side_effect=lambda x: x):
mcp_mod.register_mcp_servers(cfg)
assert "good" in mcp_mod._servers
assert "bad" not in mcp_mod._servers
assert mcp_mod._connect_cooldown_active("bad") is True
assert "bad" in attempts
attempts.clear()
mcp_mod.register_mcp_servers(cfg)
assert "bad" not in attempts, (
"failing server was re-spawned despite active cooldown -- "
"restart storm not isolated (#50394)"
)
def test_cooldown_expiry_allows_retry(self):
attempts = []
cfg = {"bad": {"command": "bad-cmd"}}
with self._run_with_mocked_connect(attempts), \
patch("tools.mcp_tool._register_server_tools", return_value=[]), \
patch("tools.mcp_tool._filter_suspicious_mcp_servers", side_effect=lambda x: x):
mcp_mod.register_mcp_servers(cfg)
assert mcp_mod._connect_cooldown_active("bad") is True
mcp_mod._server_connect_retry_after["bad"] = mcp_mod.time.monotonic() - 1
attempts.clear()
mcp_mod.register_mcp_servers(cfg)
assert "bad" in attempts, "elapsed cooldown should permit a retry"
class TestShutdownClearsCooldownState:
"""shutdown_mcp_servers must drop cooldown state on EVERY path.
A server that failed to connect is never recorded in ``_servers``, so
the empty-``_servers`` fast path is the most common state in which
stale cooldown entries exist. The original #50394 fix only cleared the
maps inside the async ``_shutdown`` coroutine, which the fast path
(and the loop-not-running path) never executes.
"""
def test_fast_path_clears_cooldown_state(self):
mcp_mod._record_connect_failure("bad")
assert mcp_mod._server_connect_retry_after
assert not mcp_mod._servers # precondition: fast path taken
with patch("tools.mcp_tool._stop_mcp_loop"):
mcp_mod.shutdown_mcp_servers()
assert mcp_mod._server_connect_retry_after == {}
assert mcp_mod._server_connect_failures == {}
def test_loop_not_running_path_clears_cooldown_state(self):
mcp_mod._record_connect_failure("bad")
class _DeadServer:
name = "dead"
async def shutdown(self): # pragma: no cover - never awaited
pass
mcp_mod._servers["dead"] = _DeadServer() # type: ignore[assignment]
# _mcp_loop is None in this test process, so the async _shutdown
# coroutine is never scheduled; only the final sweep can clear.
with patch("tools.mcp_tool._stop_mcp_loop"):
mcp_mod.shutdown_mcp_servers()
assert mcp_mod._server_connect_retry_after == {}
assert mcp_mod._server_connect_failures == {}