Files
hermes-agent/hermes_cli/mcp_startup.py
Austin Pickett 1b57acf94a fix(mcp): budget discovery-thread GIL so agent build fits the 30s wait
Background MCP discovery does bursty CPU work (SDK/pydantic imports, JSON-RPC
schema parsing, tool registration) that at the default 5ms switch interval rides
the GIL convoy effect and starves the concurrent agent-build thread and the main
event loop for tens of seconds after a serve restart — _wait_agent(30s) times
out with 'agent initialization timed out' (error 5032).

Lower the interpreter switch interval while discovery runs (restored afterwards)
so its CPU bursts are sliced finely enough that waiters stay responsive.

Fixes #60371
2026-09-25 15:01:16 -04:00

305 lines
13 KiB
Python

"""Shared CLI/TUI-safe helpers for background MCP discovery."""
from __future__ import annotations
import sys
import threading
from contextlib import contextmanager, nullcontext
from contextvars import copy_context
from typing import Dict, Iterator, Optional, Set
from hermes_constants import hermes_home_key
_mcp_discovery_lock = threading.Lock()
# Discovery slot per profile home (``hermes_home_key()`` follows the context-local HERMES_HOME
# override): a shared Desktop/dashboard backend serving several profiles runs one discovery per
# profile instead of the first profile to build an agent claiming the slot for everybody (#67605).
# A single-profile process has exactly one key, so behaviour is the old single-slot form.
_mcp_discovery_started: Set[str] = set()
_mcp_discovery_thread: Dict[str, threading.Thread] = {}
_mcp_discovery_deferred: Optional[threading.Timer] = None
# Process-wide MCP server-name allowlist derived from ``-t/--toolsets``.
# ``None`` = no filter (spawn every configured server). Set once at CLI
# startup by ``set_mcp_server_filter`` and honored by every discovery path
# in this module (inline, background, deferred), so a ``-t terminal``
# oneshot never cold-starts MCP subprocesses it cannot use.
_mcp_server_filter: Optional[list[str]] = None
def set_mcp_server_filter(toolsets: object) -> Optional[list[str]]:
"""Derive the MCP spawn allowlist from a ``-t/--toolsets`` value.
Built-in toolset names in the list are harmless (they never match a
configured ``mcp_servers`` key). ``all``/``*`` or an empty/absent value
clears the filter. Returns the stored list for logging/tests.
"""
global _mcp_server_filter
names: list[str] = []
if isinstance(toolsets, str):
names = [t.strip() for t in toolsets.split(",") if t.strip()]
elif isinstance(toolsets, (list, tuple, set)):
for item in toolsets:
names.extend(t.strip() for t in str(item).split(",") if t.strip())
if not names or "all" in names or "*" in names:
_mcp_server_filter = None
else:
_mcp_server_filter = names
return _mcp_server_filter
def get_mcp_server_filter() -> Optional[list[str]]:
return _mcp_server_filter
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
raw_config = read_raw_config() or {}
if isinstance(raw_config.get("mcp_servers"), dict) and raw_config["mcp_servers"]:
return True
from hermes_cli.agent_plugins import has_enabled_agent_plugin_mcp
return has_enabled_agent_plugin_mcp(raw_config)
except Exception:
return True # conservative: still try discovery in the background; startup can't block
def _discovery_registered_servers(status) -> bool:
"""True when a discovery run left servers usable: a live session OR a lazy registration.
A ``lazy: true`` server never connects until first use, so an all-lazy config (the
memory-saving setup the feature exists for) looked like a run that achieved nothing:
the zero-connected warning fired on every startup and the retry path re-ran discovery
on every later call (#111717).
"""
return any(entry.get("connected") or entry.get("status") == "lazy" for entry in (status or []))
def _any_mcp_connected() -> bool:
from tools.mcp_tool_discovery import get_mcp_status
return _discovery_registered_servers(get_mcp_status() or [])
def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
"""Spawn one background MCP discovery thread per profile home.
If the first run exits without connecting any server (e.g. startup cancellation / OOM restart),
later calls may retry instead of pinning the profile in "already started" with zero MCP tools.
"""
home_key = hermes_home_key()
with _mcp_discovery_lock:
if home_key in _mcp_discovery_started:
thread = _mcp_discovery_thread.get(home_key)
if thread is not None and thread.is_alive():
return
try:
if _any_mcp_connected():
return
except Exception:
return
logger.warning(
"Background MCP discovery previously exited with no connected "
"servers; retrying discovery thread"
)
_mcp_discovery_started.discard(home_key)
_mcp_discovery_thread.pop(home_key, None)
_mcp_discovery_started.add(home_key)
if not _has_configured_mcp_servers():
return
# Bare threads start from an empty context: run discovery under a copy of the caller's, so
# the context-local HERMES_HOME override (multi-profile dashboard/desktop backends, #67605)
# AND the profile's secret scope reach it. Without the scope a session switched to profile
# X would discover the LAUNCH profile's mcp_servers, and ``${TOKEN}`` interpolation / the
# stdio child env would fail closed (multiplex) or resolve the launch profile's value.
# The config gate above already runs on the caller's thread, so it sees the same context.
def _discover() -> None:
try:
_discover_mcp_tools_without_interactive_oauth()
try:
if not _any_mcp_connected():
logger.warning("Background MCP discovery completed with zero connected servers")
except Exception:
logger.debug("Failed to inspect MCP status after background discovery", exc_info=True)
except Exception:
logger.debug("Background MCP tool discovery failed", exc_info=True)
finally:
with _mcp_discovery_lock:
_mcp_discovery_thread.pop(home_key, None)
thread = threading.Thread(target=copy_context().run, args=(_discover,), name=thread_name, daemon=True)
_mcp_discovery_thread[home_key] = thread
thread.start()
def _resolve_discovery_timeout(explicit: "float | None", *, single_query: bool = False) -> float:
"""Resolve the MCP discovery wait bound: explicit arg > config.yaml > ``DEFAULT_CONFIG``.
Lazy and fail-safe: a missing/invalid value or broken config falls back to a short bound so
startup can never hang or crash.
"""
if explicit is not None:
return explicit
key = "mcp_single_query_discovery_timeout" if single_query else "mcp_discovery_timeout"
fallback = 15.0 if single_query else 1.5
try:
from hermes_cli.config import load_config, DEFAULT_CONFIG
default = float(DEFAULT_CONFIG.get(key, fallback))
except Exception:
return fallback
try:
val = float((load_config() or {}).get(key, default))
return val if val > 0 else default
except Exception:
return default
# GIL budget for discovery: the discovery thread does bursty CPU work (mcp/pydantic imports,
# JSON-RPC schema parsing, tool registration) that, at the default 5 ms switch interval, rides
# the GIL convoy effect and can starve concurrent threads — the agent-build thread and the main
# event loop — for tens of seconds (#60371: "agent initialization timed out" after a serve
# restart, _wait_agent(30s) error 5032). While discovery runs we drop the switch interval so its
# CPU bursts are sliced finely enough that waiters stay responsive, then restore it.
_DISCOVERY_SWITCH_INTERVAL_S = 0.0005
@contextmanager
def _discovery_gil_budget() -> Iterator[None]:
"""Temporarily lower the interpreter switch interval so discovery's CPU work can't
monopolize the GIL against concurrent threads (see _DISCOVERY_SWITCH_INTERVAL_S)."""
prev = sys.getswitchinterval()
sys.setswitchinterval(_DISCOVERY_SWITCH_INTERVAL_S)
try:
yield
finally:
sys.setswitchinterval(prev)
def _discover_mcp_tools_without_interactive_oauth() -> None:
"""Run MCP discovery without letting OAuth read from the user's stdin."""
try:
from tools.mcp_oauth import suppress_interactive_oauth
except Exception:
suppress_interactive_oauth = nullcontext
with _discovery_gil_budget(), suppress_interactive_oauth():
from tools.mcp_tool_discovery import discover_mcp_tools
# Only pass the kwarg when a filter is set: many tests (and any
# out-of-tree caller) stub discover_mcp_tools with a zero-arg
# callable, and the unfiltered call shape is unchanged.
if _mcp_server_filter is None:
discover_mcp_tools()
else:
discover_mcp_tools(allowed_mcp_names=_mcp_server_filter)
def defer_background_mcp_discovery(*, logger, thread_name: str, delay: float | None) -> None:
"""Arm ``start_background_mcp_discovery`` to run ``delay`` seconds from now.
Used by the Desktop ``serve`` backend after its socket is announced: the thread's first act is
the ~350ms ``mcp`` SDK import, which would hold the GIL against the renderer's connect + first
hydration reads (or the web_server import) if started earlier.
``delay=None`` arms without a clock: the standalone dashboard fires it from the first ``/api/ws``
client (``start_deferred_mcp_discovery_now``) or the first agent build (``wait_for_mcp_discovery``),
so an idle, unvisited dashboard never spawns the configured stdio MCP servers (#58733).
"""
global _mcp_discovery_deferred
with _mcp_discovery_lock:
if hermes_home_key() in _mcp_discovery_started or _mcp_discovery_deferred is not None:
return
def _fire() -> None:
global _mcp_discovery_deferred
with _mcp_discovery_lock:
_mcp_discovery_deferred = None
start_background_mcp_discovery(logger=logger, thread_name=thread_name)
# ``None`` builds the Timer only as the holder of ``_fire``; it is never started and
# ``start_deferred_mcp_discovery_now`` runs ``timer.function()`` directly.
timer = threading.Timer(0 if delay is None else delay, _fire)
timer.daemon = True
timer.name = f"{thread_name}-deferred"
_mcp_discovery_deferred = timer
if delay is not None:
timer.start()
def start_deferred_mcp_discovery_now() -> None:
"""Run an armed deferred start immediately (idempotent, thread-safe)."""
global _mcp_discovery_deferred
with _mcp_discovery_lock: # take the slot atomically: two racing first clients fire once
timer, _mcp_discovery_deferred = _mcp_discovery_deferred, None
if timer is None:
return
timer.cancel()
timer.function()
def wait_for_mcp_discovery(timeout: "float | None" = None, *, single_query: bool = False) -> None:
"""Wait for background MCP discovery before the first tool snapshot.
``join`` returns the instant discovery completes, so this only blocks for a still-pending
server's real connect time. ``single_query`` uses ``mcp_single_query_discovery_timeout``
(15s vs 1.5s) because one-shot sessions have no second turn to recover.
"""
start_deferred_mcp_discovery_now()
thread = _current_home_thread()
if thread is None or not thread.is_alive():
return
thread.join(timeout=_resolve_discovery_timeout(timeout, single_query=single_query))
def _current_home_thread() -> Optional[threading.Thread]:
"""Discovery thread for the profile home the caller is scoped to, if any."""
return _mcp_discovery_thread.get(hermes_home_key())
def mcp_discovery_in_flight() -> bool:
"""True if THIS module's discovery thread (for the current profile home) is still running.
Mirrors ``tui_gateway.entry.mcp_discovery_in_flight``; surfaces that start discovery here
(desktop, dashboard sidecar) populate this thread, so the late-refresh scheduler consults both.
Those processes populate THIS module's ``_mcp_discovery_thread``, not ``tui_gateway.entry``'s, so the
late-refresh scheduler must consult both to decide whether a slow server's tools are still pending (see
#51587).
"""
thread = _current_home_thread()
return thread is not None and thread.is_alive()
def join_mcp_discovery(timeout: "float | None" = None) -> bool:
"""Block up to ``timeout`` for THIS module's discovery; True once complete, False if still
running. For the off-critical-path late-refresh waiter (accepts a long wait, reports outcome)."""
thread = _current_home_thread()
if thread is None:
return True
thread.join(timeout=timeout)
return not thread.is_alive()
def ensure_mcp_discovery_before_agent_build(
*,
logger,
timeout: "float | None" = None,
single_query: bool = False,
thread_name: str = "cli-mcp-discovery") -> None:
"""Give configured MCP tools a bounded chance to register before AIAgent.
Non-interactive first turns (``chat -q``, ``hermes -z``) can construct ``AIAgent`` before any
path started discovery, and ``wait_for_mcp_discovery()`` only joins an existing thread — so
start discovery if needed, then wait up to the configured bound.
"""
try:
start_background_mcp_discovery(logger=logger, thread_name=thread_name)
wait_for_mcp_discovery(timeout=timeout, single_query=single_query)
except Exception:
logger.debug("MCP discovery readiness check failed before agent build", exc_info=True)