diff --git a/cli.py b/cli.py index 770483df5..b22e26333 100644 --- a/cli.py +++ b/cli.py @@ -787,8 +787,10 @@ def AIAgent(*args, **kwargs): def get_tool_definitions(*args, **kwargs): + from hermes_cli.mcp_startup import wait_for_mcp_discovery from model_tools import get_tool_definitions as _get_tool_definitions + wait_for_mcp_discovery() return _get_tool_definitions(*args, **kwargs) @@ -896,9 +898,12 @@ def _prepare_deferred_agent_startup() -> None: exc_info=True, ) try: - from tools.mcp_tool import discover_mcp_tools + from hermes_cli.mcp_startup import start_background_mcp_discovery - discover_mcp_tools() + start_background_mcp_discovery( + logger=logger, + thread_name="termux-cli-mcp-discovery", + ) except Exception: logger.debug( "MCP tool discovery failed at deferred CLI startup", @@ -4871,6 +4876,10 @@ class HermesCLI: if not self._ensure_runtime_credentials(): return False + from hermes_cli.mcp_startup import wait_for_mcp_discovery + + wait_for_mcp_discovery() + # Initialize SQLite session store for CLI sessions (if not already done in __init__) if self._session_db is None: try: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0cfcd03d1..27105c570 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11262,6 +11262,26 @@ _AGENT_SUBCOMMANDS = { } +def _is_tui_chat_launch(args) -> bool: + return bool(getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1") + + +def _command_has_dedicated_mcp_startup(args) -> bool: + if args.command == "acp": + return True + if args.command == "gateway" and getattr(args, "gateway_command", None) == "run": + return True + if args.command == "cron" and getattr(args, "cron_command", None) in {"run", "tick"}: + return True + return False + + +def _should_background_mcp_startup(args) -> bool: + if _is_tui_chat_launch(args): + return False + return args.command in {None, "chat", "rl"} + + def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) @@ -11281,19 +11301,42 @@ def _prepare_agent_startup(args) -> None: "plugin discovery failed at CLI startup", exc_info=True, ) - try: - # MCP tool discovery — no event loop running in CLI/TUI startup, - # so inline is safe. Moved here from model_tools.py module scope - # to avoid freezing the gateway's event loop on its first message - # via the same lazy import path (#16856). - from tools.mcp_tool import discover_mcp_tools + _run_inline_mcp_discovery = True + if _is_tui_chat_launch(args): + # The TUI launcher hands off to a dedicated startup path that already + # backgrounds MCP discovery with a bounded join before the first tool + # snapshot. + _run_inline_mcp_discovery = False + elif _command_has_dedicated_mcp_startup(args): + # These entrypoints already do their own MCP startup later on the real + # runtime path (gateway executor, ACP launcher, cron job runner). + _run_inline_mcp_discovery = False + elif _should_background_mcp_startup(args): + try: + from hermes_cli.mcp_startup import start_background_mcp_discovery - discover_mcp_tools() - except Exception: - logger.debug( - "MCP tool discovery failed at CLI startup", - exc_info=True, - ) + start_background_mcp_discovery( + logger=logger, + thread_name="cli-mcp-discovery", + ) + except Exception: + logger.debug( + "Background MCP tool discovery failed at CLI startup", + exc_info=True, + ) + _run_inline_mcp_discovery = False + if _run_inline_mcp_discovery: + try: + # MCP tool discovery remains synchronous for entrypoints that do + # not own a later bounded/executor startup path. + from tools.mcp_tool import discover_mcp_tools + + discover_mcp_tools() + except Exception: + logger.debug( + "MCP tool discovery failed at CLI startup", + exc_info=True, + ) try: from hermes_cli.config import load_config from agent.shell_hooks import register_from_config diff --git a/hermes_cli/mcp_startup.py b/hermes_cli/mcp_startup.py new file mode 100644 index 000000000..6d81853bc --- /dev/null +++ b/hermes_cli/mcp_startup.py @@ -0,0 +1,59 @@ +"""Shared CLI/TUI-safe helpers for background MCP discovery.""" + +from __future__ import annotations + +import threading +from typing import Optional + +_mcp_discovery_lock = threading.Lock() +_mcp_discovery_started = False +_mcp_discovery_thread: Optional[threading.Thread] = None + + +def _has_configured_mcp_servers() -> bool: + """Cheap config probe so non-MCP users avoid importing the MCP stack.""" + try: + from hermes_cli.config import read_raw_config + + mcp_servers = (read_raw_config() or {}).get("mcp_servers") + return isinstance(mcp_servers, dict) and len(mcp_servers) > 0 + except Exception: + # Be conservative: if config probing fails, try discovery in the + # background so startup still can't block. + return True + + +def start_background_mcp_discovery(*, logger, thread_name: str) -> None: + """Spawn one shared background MCP discovery thread for this process.""" + global _mcp_discovery_started, _mcp_discovery_thread + + with _mcp_discovery_lock: + if _mcp_discovery_started: + return + _mcp_discovery_started = True + if not _has_configured_mcp_servers(): + return + + def _discover() -> None: + try: + from tools.mcp_tool import discover_mcp_tools + + discover_mcp_tools() + except Exception: + logger.debug("Background MCP tool discovery failed", exc_info=True) + + thread = threading.Thread( + target=_discover, + name=thread_name, + daemon=True, + ) + _mcp_discovery_thread = thread + thread.start() + + +def wait_for_mcp_discovery(timeout: float = 0.75) -> None: + """Briefly wait for background MCP discovery before the first tool snapshot.""" + thread = _mcp_discovery_thread + if thread is None or not thread.is_alive(): + return + thread.join(timeout=timeout) diff --git a/tests/hermes_cli/test_mcp_startup.py b/tests/hermes_cli/test_mcp_startup.py new file mode 100644 index 000000000..08639abbc --- /dev/null +++ b/tests/hermes_cli/test_mcp_startup.py @@ -0,0 +1,166 @@ +"""Regression tests for bounded/lazy CLI MCP startup.""" + +from __future__ import annotations + +from argparse import Namespace +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), + ) + 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 + 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_mcp_bootstrap_for_tui_chat(monkeypatch): + calls = {"mcp": 0} + + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.config", + types.SimpleNamespace(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__("mcp", calls["mcp"] + 1) + ), + ) + + main_mod._prepare_agent_startup(_agent_args(tui=True)) + + assert calls["mcp"] == 0 + assert mcp_startup._mcp_discovery_thread is None + + +def test_cli_get_tool_definitions_briefly_waits_for_fast_mcp_thread(monkeypatch): + thread = threading.Thread(target=lambda: time.sleep(0.05), daemon=True) + thread.start() + mcp_startup._mcp_discovery_thread = thread + + monkeypatch.setitem( + sys.modules, + "model_tools", + types.SimpleNamespace(get_tool_definitions=lambda *_a, **_k: ["ok"]), + ) + + start = time.monotonic() + result = cli_mod.get_tool_definitions(enabled_toolsets=["web"], quiet_mode=True) + elapsed = time.monotonic() - start + + assert result == ["ok"] + assert elapsed >= 0.04 + assert not thread.is_alive() + + +def test_init_agent_waits_for_mcp_discovery_before_agent_build(monkeypatch): + waited = {"done": False} + + cli = cli_mod.HermesCLI(compact=True) + cli._session_db = object() + cli._resumed = False + cli.conversation_history = [] + cli._install_tool_callbacks = lambda: None + cli._ensure_tirith_security = lambda: None + cli._ensure_runtime_credentials = lambda: True + + monkeypatch.setattr( + mcp_startup, + "wait_for_mcp_discovery", + lambda timeout=0.75: waited.__setitem__("done", True), + ) + + def _fake_agent(*_a, **_k): + assert waited["done"] is True + return types.SimpleNamespace() + + monkeypatch.setattr(cli_mod, "AIAgent", _fake_agent) + + assert cli._init_agent() is True