"""Regression tests for bounded/lazy CLI MCP startup.""" from __future__ import annotations from argparse import Namespace from contextlib import nullcontext import sys import threading import time import types import pytest import cli as cli_mod from hermes_cli import main as main_mod from hermes_cli import mcp_startup @pytest.fixture(autouse=True) def _reset_mcp_startup_state(): saved_started = mcp_startup._mcp_discovery_started saved_thread = mcp_startup._mcp_discovery_thread try: mcp_startup._mcp_discovery_started = False mcp_startup._mcp_discovery_thread = None yield finally: thread = mcp_startup._mcp_discovery_thread if thread is not None and thread.is_alive(): thread.join(timeout=1.0) mcp_startup._mcp_discovery_started = saved_started mcp_startup._mcp_discovery_thread = saved_thread def _agent_args(**overrides) -> Namespace: base = { "accept_hooks": False, "command": "chat", "cron_command": None, "gateway_command": None, "mcp_action": None, "tui": False, } base.update(overrides) return Namespace(**base) def test_prepare_agent_startup_backgrounds_blocking_mcp_for_chat(monkeypatch): stop = threading.Event() calls = {"mcp": 0} def _blocking_discover(): calls["mcp"] += 1 stop.wait() monkeypatch.setitem( sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None), ) monkeypatch.setitem( sys.modules, "hermes_cli.config", types.SimpleNamespace( read_raw_config=lambda: {"mcp_servers": {"demo": {"transport": "stdio"}}}, load_config=lambda: {}, ), ) monkeypatch.setitem( sys.modules, "agent.shell_hooks", types.SimpleNamespace(register_from_config=lambda *_a, **_k: None), ) # Stub mcp_oauth so the background thread doesn't pay the real (cold, # ~0.75s) ``tools.mcp_oauth`` import before calling discovery. This test # asserts the *backgrounding contract* (main thread returns fast, discovery # runs off-thread), not OAuth suppression — the unrelated import latency # would otherwise blow the polling deadline on a loaded CI runner. monkeypatch.setitem( sys.modules, "tools.mcp_oauth", types.SimpleNamespace(suppress_interactive_oauth=lambda: nullcontext()), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=_blocking_discover), ) try: start = time.monotonic() main_mod._prepare_agent_startup(_agent_args()) elapsed = time.monotonic() - start assert elapsed < 0.2 deadline = time.monotonic() + 3.0 while calls["mcp"] == 0 and time.monotonic() < deadline: time.sleep(0.01) assert calls["mcp"] == 1 assert mcp_startup._mcp_discovery_thread is not None assert mcp_startup._mcp_discovery_thread.is_alive() finally: stop.set() def test_prepare_agent_startup_skips_discovery_when_chat_resolves_to_tui( monkeypatch, ): """Bare ``hermes`` / ``hermes chat`` on a TTY with ``display.interface: tui`` resolves to the TUI via ``_resolve_use_tui``, but does NOT pass ``--tui`` or ``HERMES_TUI``. Discovery must be skipped in the wrapper: the TUI gateway owns it, and the wrapper would otherwise hold a dead MCP server for the entire session (3 copies per TUI instance). """ calls = {"background": 0, "inline": 0} monkeypatch.setattr(main_mod, "_resolve_use_tui", lambda _args: True) monkeypatch.setattr( mcp_startup, "start_background_mcp_discovery", lambda **_kwargs: calls.__setitem__("background", calls["background"] + 1), ) monkeypatch.setitem( sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None), ) monkeypatch.setitem( sys.modules, "hermes_cli.config", types.SimpleNamespace( read_raw_config=lambda: {"mcp_servers": {"demo": {"transport": "stdio"}}}, load_config=lambda: {}, ), ) monkeypatch.setitem( sys.modules, "agent.shell_hooks", types.SimpleNamespace(register_from_config=lambda *_a, **_k: None), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace( discover_mcp_tools=lambda: calls.__setitem__("inline", calls["inline"] + 1), ), ) main_mod._prepare_agent_startup(_agent_args(command=None)) assert calls["background"] == 0 assert calls["inline"] == 0 assert mcp_startup._mcp_discovery_thread is None def test_prepare_agent_startup_keeps_discovery_for_non_chat_commands( monkeypatch, ): """Non-chat commands never launch the TUI, so they must keep their own MCP discovery even when the ambient display config resolves to TUI — ``_is_tui_chat_launch`` must not consult ``_resolve_use_tui`` there.""" calls = {"inline": 0} monkeypatch.setattr(main_mod, "_resolve_use_tui", lambda _args: True) monkeypatch.setitem( sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None), ) monkeypatch.setitem( sys.modules, "hermes_cli.config", types.SimpleNamespace( read_raw_config=lambda: {"mcp_servers": {"demo": {"transport": "stdio"}}}, load_config=lambda: {}, ), ) monkeypatch.setitem( sys.modules, "agent.shell_hooks", types.SimpleNamespace(register_from_config=lambda *_a, **_k: None), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace( discover_mcp_tools=lambda: calls.__setitem__("inline", calls["inline"] + 1), ), ) main_mod._prepare_agent_startup(_agent_args(command="mcp", mcp_action="serve")) assert calls["inline"] == 1 def test_background_mcp_discovery_suppresses_interactive_oauth(monkeypatch): state = {"active": False, "during_discover": None} class SuppressInteractiveOAuth: def __enter__(self): state["active"] = True def __exit__(self, *_exc): state["active"] = False def _discover(): state["during_discover"] = state["active"] monkeypatch.setitem( sys.modules, "hermes_cli.config", types.SimpleNamespace( read_raw_config=lambda: {"mcp_servers": {"demo": {"url": "https://mcp.example.test/mcp"}}}, ), ) monkeypatch.setitem( sys.modules, "tools.mcp_oauth", types.SimpleNamespace( suppress_interactive_oauth=lambda: SuppressInteractiveOAuth(), ), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=_discover), ) mcp_startup.start_background_mcp_discovery( logger=types.SimpleNamespace(debug=lambda *_a, **_k: None), thread_name="test-mcp-discovery", ) assert mcp_startup._mcp_discovery_thread is not None mcp_startup._mcp_discovery_thread.join(timeout=1.0) assert state["during_discover"] is True assert state["active"] is False def test_portable_only_mcp_configuration_opens_startup_gate(monkeypatch): monkeypatch.setitem( sys.modules, "hermes_cli.config", types.SimpleNamespace(read_raw_config=lambda: {}), ) monkeypatch.setitem( sys.modules, "hermes_cli.agent_plugins", types.SimpleNamespace( has_enabled_agent_plugin_mcp=lambda _config: True, ), ) assert mcp_startup._has_configured_mcp_servers() is True def _retry_logger(): return types.SimpleNamespace( debug=lambda *_a, **_k: None, warning=lambda *_a, **_k: None, ) def _install_retry_stubs(monkeypatch, *, connected: bool, calls: dict): monkeypatch.setitem( sys.modules, "hermes_cli.config", types.SimpleNamespace( read_raw_config=lambda: {"mcp_servers": {"demo": {"transport": "stdio"}}}, ), ) monkeypatch.setitem( sys.modules, "tools.mcp_oauth", types.SimpleNamespace(suppress_interactive_oauth=lambda: nullcontext()), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace( discover_mcp_tools=lambda: calls.__setitem__("mcp", calls["mcp"] + 1), get_mcp_status=lambda: [{"connected": connected}], ), ) # --- -t/--toolsets MCP spawn filter (#19000) -------------------------------- @pytest.fixture def _reset_mcp_server_filter(): saved = mcp_startup._mcp_server_filter try: yield finally: mcp_startup._mcp_server_filter = saved @pytest.mark.parametrize( ("toolsets", "expected"), [ (None, None), ("", None), ("all", None), (["*"], None), ("terminal,web", ["terminal", "web"]), (["terminal", "code-mcp,web"], ["terminal", "code-mcp", "web"]), ], ) def test_set_mcp_server_filter_normalizes(_reset_mcp_server_filter, toolsets, expected): assert mcp_startup.set_mcp_server_filter(toolsets) == expected assert mcp_startup.get_mcp_server_filter() == expected def test_discover_mcp_tools_spawns_only_allowed_servers(monkeypatch): """The filter must narrow the spawn set before any server is connected; built-in toolset names in the list are ignored.""" from tools import mcp_tool servers = { "code-mcp": {"command": "true"}, "docs-mcp": {"command": "true"}, } seen: dict[str, dict] = {} sdk_probes = {"n": 0} def _fake_ensure_sdk(): sdk_probes["n"] += 1 return True monkeypatch.setattr(mcp_tool, "_load_mcp_config", lambda: dict(servers)) monkeypatch.setattr(mcp_tool, "_ensure_mcp_sdk", _fake_ensure_sdk) monkeypatch.setattr(mcp_tool, "_try_acquire_mcp_discovery_lock", lambda: mcp_tool._LOCK_UNAVAILABLE) monkeypatch.setattr(mcp_tool, "_release_mcp_discovery_lock", lambda *_a, **_k: None, raising=False) def _fake_register(cfgs): seen.update(cfgs) return [] monkeypatch.setattr(mcp_tool, "register_mcp_servers", _fake_register) monkeypatch.setattr(mcp_tool, "_servers", {}) monkeypatch.setattr(mcp_tool, "_server_connecting", set()) # Everything (no filter) — both would be registered. mcp_tool.discover_mcp_tools() assert set(seen) == {"code-mcp", "docs-mcp"} # `-t terminal,code-mcp` — only the matching server; "terminal" is a no-op. seen.clear() mcp_tool.discover_mcp_tools(allowed_mcp_names=["terminal", "code-mcp"]) assert set(seen) == {"code-mcp"} # `-t terminal` — no MCP server in the filter: skip the whole MCP load, # including the ~260ms `mcp` SDK import. seen.clear() sdk_probes["n"] = 0 assert mcp_tool.discover_mcp_tools(allowed_mcp_names=["terminal"]) == [] assert seen == {} assert sdk_probes["n"] == 0 def test_background_discovery_honors_server_filter(monkeypatch, _reset_mcp_server_filter): calls: list = [] monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda allowed_mcp_names=None: calls.append(allowed_mcp_names)), ) monkeypatch.setitem( sys.modules, "tools.mcp_oauth", types.SimpleNamespace(suppress_interactive_oauth=nullcontext), ) mcp_startup.set_mcp_server_filter("terminal,code-mcp") mcp_startup._discover_mcp_tools_without_interactive_oauth() assert calls == [["terminal", "code-mcp"]] def test_prepare_agent_startup_installs_server_filter(monkeypatch, _reset_mcp_server_filter): monkeypatch.setitem( sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None), ) monkeypatch.setattr(main_mod, "_should_background_mcp_startup", lambda args: False) monkeypatch.setattr(main_mod, "_command_has_dedicated_mcp_startup", lambda args: True) main_mod._prepare_agent_startup(_agent_args(toolsets="terminal,code-mcp")) assert mcp_startup.get_mcp_server_filter() == ["terminal", "code-mcp"]