Files
hermes-agent/tests/gateway/test_gateway_mcp_oauth_unattended.py
teknium1 ea17e976cd fix(mcp): retry a never-connected server through the existing reconcile seam
Slim follow-up to the cherry-picked #112447 (@Yongcheng123): same outcome —
an MCP server whose FIRST connect failed is retried by the gateway without a
config edit — with less machinery.

- `_mcp_config_reconciler` now calls `reconcile_mcp_servers_with_config()`
  every housekeeping tick after the baseline one, instead of gating on the
  config.yaml stat signature (plus a `pending` retry set). The reconcile is a
  signature-cached config read plus set compares when nothing moved, so the
  gate bought nothing but the bug: drift (a server enabled on disk, absent
  from `_servers`) never changed the signature.
- Dropped `mcp_servers_missing_from_live()`: it duplicated the `wanted -
  known` compare that `reconcile_mcp_servers_with_config()` already does.
- `reconcile_mcp_servers_with_config()` excludes servers inside their
  per-server connect cooldown from `added`, so a chronically failing server
  neither enters `discover_mcp_tools()` (cross-process discovery lock, up to
  120 s of waiting when another process holds it, a "(1 failed)" log line)
  every minute, nor is reported as "added" when nothing was attempted.
- A discovery-pass timeout now stamps the cooldown for the servers it
  strands: their attempt is still running on the MCP loop and the next
  reconcile tick would otherwise spawn a second one beside it.
- Tests trimmed to two invariants: the chore reconciles each tick after the
  baseline with OAuth suppressed (gateway); the reconcile skips a server in
  cooldown, retries it once the cooldown lapsed, and a timed-out pass stamps
  the cooldown (tools).

Fixes #112445
2026-09-16 17:01:11 -07:00

55 lines
2.5 KiB
Python

"""No gateway MCP path may start a browser OAuth flow (nobody can complete it), and the gateway
tracks ``mcp_servers`` edits made after boot."""
from __future__ import annotations
from pathlib import Path
import pytest
from gateway.config import GatewayConfig
@pytest.mark.asyncio
async def test_gateway_startup_discovery_suppresses_interactive_oauth(monkeypatch):
import gateway.run as gateway_run
from tools import mcp_tool_discovery as _mcp_discovery
from tools.mcp_oauth import _is_interactive, force_interactive_oauth
seen: list = []
monkeypatch.setattr(_mcp_discovery, "discover_mcp_tools", lambda: seen.append(_is_interactive()) or [])
with force_interactive_oauth(): # even a "forced interactive" parent context is overridden
await gateway_run._discover_gateway_mcp_tools(GatewayConfig(multiplex_profiles=False))
assert seen == [False]
def test_mcp_config_reconciler_reconciles_every_tick_after_baseline(monkeypatch, tmp_path: Path):
"""The chore reconciles on DRIFT, not only on a config edit (#112445): a server whose FIRST
connect failed never reached ``_servers`` and its config never changes, so a signature-gated
chore never came back for it. First tick is baseline only (startup discovery owns it); every
later tick reconciles with interactive OAuth suppressed; nothing to report stays silent."""
from gateway.run_profile_reconcile import _mcp_config_reconciler
from tools import mcp_tool_discovery as _mcp_discovery
from tools.mcp_oauth import _is_interactive
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("mcp_servers:\n linear:\n url: https://x/mcp\n")
calls: list = []
added: list = ["linear"] # enabled in config, never connected, cooldown lapsed
def fake_reconcile():
calls.append(_is_interactive())
return {"removed": [], "added": list(added), "pending": []}
monkeypatch.setattr(_mcp_discovery, "reconcile_mcp_servers_with_config", fake_reconcile)
tick = _mcp_config_reconciler(runner=None)
tick() # baseline only: startup discovery already reflects this file
assert calls == []
tick() # config.yaml untouched -- the missing server is still retried
tick()
assert calls == [False, False], "reconcile runs each tick after the baseline, OAuth suppressed"
added.clear() # it connected: the chore keeps checking, cheaply, and has nothing to report
tick()
assert calls == [False, False, False]