diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 3089f78c27..33e28092f0 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -112,6 +112,17 @@ def main() -> None: import acp from .server import HermesACPAgent + # MCP tool discovery from config.yaml — run before asyncio.run() so + # it's safe to use blocking waits. (ACP also registers per-session + # MCP servers dynamically via asyncio.to_thread inside the event + # loop; that path is unaffected.) Moved from model_tools.py module + # scope to avoid freezing the gateway's loop on lazy import (#16856). + try: + from tools.mcp_tool import discover_mcp_tools + discover_mcp_tools() + except Exception: + logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) + agent = HermesACPAgent() try: asyncio.run(acp.run_agent(agent, use_unstable_protocol=True)) diff --git a/gateway/run.py b/gateway/run.py index 60fbd490a7..1cda4fb8f6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11663,6 +11663,19 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = atexit.register(remove_pid_file) atexit.register(release_gateway_runtime_lock) + # MCP tool discovery — run in an executor so the asyncio event loop + # stays responsive even when a configured MCP server is slow or + # unreachable. discover_mcp_tools() uses a blocking 120s wait + # internally; calling it from the loop thread would freeze platform + # heartbeats (Discord shard, Telegram polling) until it returned. + # See #16856. + try: + from tools.mcp_tool import discover_mcp_tools + _loop = asyncio.get_running_loop() + await _loop.run_in_executor(None, discover_mcp_tools) + except Exception as e: + logger.debug("MCP tool discovery failed: %s", e) + # Start the gateway success = await runner.start() if not success: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a9cd08e98f..c13e3dce3d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10193,6 +10193,17 @@ Examples: logger.debug( "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 + 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/model_tools.py b/model_tools.py index 539b0e13b4..31e3115dcf 100644 --- a/model_tools.py +++ b/model_tools.py @@ -138,12 +138,18 @@ def _run_async(coro): discover_builtin_tools() -# MCP tool discovery (external MCP servers from config) -try: - from tools.mcp_tool import discover_mcp_tools - discover_mcp_tools() -except Exception as e: - logger.debug("MCP tool discovery failed: %s", e) +# MCP tool discovery (external MCP servers from config) used to run here as +# a module-level side effect. It was removed because discover_mcp_tools() +# internally uses a blocking future.result(timeout=120) wait, and the +# gateway lazy-imports this module from inside the asyncio event loop on +# the first user message — freezing Discord/Telegram heartbeats for up to +# 120s whenever any configured MCP server was slow or unreachable (#16856). +# +# Each entry point now runs discovery explicitly at its own startup: +# - gateway/run.py -> start_gateway() uses run_in_executor +# - cli.py, hermes_cli/* -> inline on startup (no event loop) +# - tui_gateway/server.py -> inline on startup (no event loop) +# - acp_adapter/server.py -> asyncio.to_thread on session init # Plugin tool discovery (user/project/pip plugins) try: diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 4e03224ee8..38e00ecfac 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -105,6 +105,17 @@ def _log_exit(reason: str) -> None: def main(): _install_sidecar_publisher() + # MCP tool discovery — inline is safe here: TUI entry is a plain + # sync loop with no asyncio event loop to block. Previously ran as + # a model_tools.py module-level side effect; moved to explicit + # startup calls to avoid freezing the gateway's loop on lazy import + # (#16856). + try: + from tools.mcp_tool import discover_mcp_tools + discover_mcp_tools() + except Exception: + pass + if not write_json({ "jsonrpc": "2.0", "method": "event",