Files
hermes-agent/tools/mcp_tool.py
kshitijk4poor 684f7e9927 refactor(mcp): declare _resolved_identity in MCPServerTask's slots and init it
The transport publishes _resolved_identity and server_run resets it, but the attribute was
neither in __slots__ nor set in __init__, so a task that never reached the transport raised
AttributeError on read and the attribute silently lived in the mixins' __dict__. Declare it and
start it at None (not shareable until the transport publishes a digest). The getattr in
registration stays for test fakes that are not MCPServerTask instances.
2026-09-26 22:40:54 +05:30

755 lines
41 KiB
Python

#!/usr/bin/env python3
"""MCP (Model Context Protocol) client: connects to the ``mcp_servers`` configured in
~/.hermes/config.yaml (stdio, Streamable HTTP or SSE), discovers their tools and registers them
into the hermes tool registry. The ``mcp`` package is optional (no-op without it).
One background event loop (``_mcp_loop``) in a daemon thread runs each server as a long-lived
Task (``MCPServerTask``) so the transport's anyio cancel scopes enter and exit in one Task; every
``_servers``/loop mutation holds ``_lock``. This module keeps the SDK loader, ``MCPServerTask`` and
all shared state; the ``mcp_tool_*`` siblings read that state back through ``tools.mcp_tool`` at
call time (``_core``) and are imported directly by their callers."""
import asyncio
import contextvars
import importlib
import importlib.util
import inspect
import logging
import os
import sys
import threading
import time
from typing import Any, Callable, Dict, List, Optional, Set
logger = logging.getLogger(__name__)
from tools.mcp_tool_common import _DEFAULT_TOOL_TIMEOUT, mcp_field
from tools.mcp_tool_config import _get_mcp_stderr_log, _npx_cached_bin
from tools.mcp_tool_sampling import ElicitationHandler, SamplingHandler
from tools.mcp_tool_transport import MCPServerTransportMixin
from tools.mcp_tool_server_run import MCPServerRunMixin
from tools.mcp_tool_health import MCPServerHealthMixin
# Wall-clock bound on the fail-open OSV malware preflight before a stdio spawn; just ABOVE
# osv_check._TIMEOUT (10s) so it only bites when a stalled SSL handshake defeats that.
_OSV_MALWARE_CHECK_TIMEOUT_S = 12.0
async def _preflight_stdio_command(server_name: str, command: str, args: list) -> tuple[str, list]:
"""OSV malware preflight (off-loop, wall-clock bound, fail-open on timeout), THEN the
cached-npx swap. The preflight must see the REAL command/args: anything that rewrites argv to a
wrapper or resolved binary has to happen after it, or the check silently inspects the wrapper
and becomes a no-op (``_infer_ecosystem`` keys off the command basename being npx/uvx/pipx)."""
from tools.osv_check import check_package_for_malware
try:
malware_error = await asyncio.wait_for(
asyncio.to_thread(check_package_for_malware, command, args), timeout=_OSV_MALWARE_CHECK_TIMEOUT_S)
except asyncio.TimeoutError:
logger.warning("MCP server '%s': OSV malware preflight timed out after %.0fs "
"(network slow/unreachable) — proceeding without the check.",
server_name, _OSV_MALWARE_CHECK_TIMEOUT_S)
malware_error = None
if malware_error:
raise ValueError(f"MCP server '{server_name}': {malware_error}")
# npx resolves the package and then FORKS, staying resident as the real server's parent for
# nothing (~48 MB per server, measured). Hermes already supervises the child (shared death
# supervisor), so a cached package is spawned directly; a cache miss leaves npx untouched.
if os.path.basename(command).lower().startswith("npx"):
cached = _npx_cached_bin(args)
if cached:
direct_command, direct_args = cached
logger.debug("MCP server '%s': using cached npx binary %s (skipping the "
"resident `npm exec` parent)", server_name, direct_command)
command, args = direct_command, direct_args
return command, args
# ---- Optional MCP SDK: availability probe now, symbol import on first use ----
_MCP_AVAILABLE = _MCP_HTTP_AVAILABLE = _MCP_NEW_HTTP = _MCP_LEGACY_HTTP = False
_MCP_SAMPLING_TYPES = _MCP_NOTIFICATION_TYPES = _MCP_ELICITATION_TYPES = False
_MCP_MESSAGE_HANDLER_SUPPORTED = _MCP_LOGGING_CALLBACK_SUPPORTED = False
sse_client = None
# Fallback for SDKs without LATEST_PROTOCOL_VERSION (Streamable HTTP arrived with 2025-03-26).
LATEST_PROTOCOL_VERSION = "2025-03-26"
# Newest revision ``ClientSession.initialize()`` speaks; from 2026-07-28 the handshake is a
# per-request envelope so this can be OLDER than LATEST_PROTOCOL_VERSION, and the
# MCP-Protocol-Version header must be seeded from THIS one.
LATEST_HANDSHAKE_VERSION = LATEST_PROTOCOL_VERSION
# Importing ``mcp`` costs ~260ms, so it is deferred to first use (_ensure_mcp_sdk); availability
# is decided now via find_spec so every ``if not _MCP_AVAILABLE`` gate / patch / skipif holds.
try:
_MCP_AVAILABLE = importlib.util.find_spec("mcp") is not None
except Exception:
_MCP_AVAILABLE = False
if not _MCP_AVAILABLE:
logger.debug("mcp package not installed -- MCP tool support disabled")
ClientSession: Any = None
_MCP_SDK_IMPORT_ATTEMPTED = False
_MCP_SDK_IMPORT_LOCK = threading.Lock()
# Optional SDK type families (module, names, debug message when absent), bound in this order to
# _MCP_SAMPLING_TYPES / _MCP_ELICITATION_TYPES / _MCP_NOTIFICATION_TYPES; an older SDK only
# loses that feature, not MCP.
_OPTIONAL_TYPE_FAMILIES = (
("mcp.types", ("CreateMessageResult", "CreateMessageResultWithTools", "ErrorData", "SamplingCapability",
"SamplingToolsCapability", "TextContent", "ToolUseContent"),
"MCP sampling types not available -- sampling disabled"),
("mcp.types", ("ElicitRequestParams", "ElicitResult"),
"MCP elicitation types not available -- elicitation disabled"),
("mcp.types", ("ServerNotification", "ToolListChangedNotification", "PromptListChangedNotification",
"ResourceListChangedNotification"),
"MCP notification types not available -- dynamic tool discovery disabled"),
)
# Bound by _ensure_mcp_sdk(); module __getattr__ (PEP 562) imports the SDK on first external
# access so mock.patch("tools.mcp_tool.stdio_client") sees a real original, never clobbered.
_MCP_SDK_LAZY_SYMBOLS = frozenset(
{"StdioServerParameters", "stdio_client", "streamablehttp_client", "streamable_http_client"}
| {n for _mod, names, _msg in _OPTIONAL_TYPE_FAMILIES for n in names})
def __getattr__(name: str):
if name in _MCP_SDK_LAZY_SYMBOLS:
_ensure_mcp_sdk()
try:
return globals()[name]
except KeyError:
pass # SDK missing or symbol absent on this SDK build
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _import_sdk_names(module: str, names: tuple, missing_msg: Optional[str] = None) -> bool:
"""Bind ``names`` from SDK ``module`` into this module's globals; False (nothing bound,
optional debug line) when this SDK build lacks the module or any of the names."""
try:
mod = importlib.import_module(module)
values = {n: getattr(mod, n) for n in names}
except (ImportError, AttributeError):
if missing_msg:
logger.debug(missing_msg)
return False
globals().update(values)
return True
def _ensure_mcp_sdk() -> bool:
"""Import the optional ``mcp`` SDK on first use; return availability. Idempotent and
thread-safe; honors a test-patched ``_MCP_AVAILABLE=False`` (no import) and pre-installed
mocks (``ClientSession`` already set means no re-import)."""
global _MCP_SDK_IMPORT_ATTEMPTED, _MCP_AVAILABLE, _MCP_HTTP_AVAILABLE, _MCP_NEW_HTTP, _MCP_LEGACY_HTTP
global _MCP_SAMPLING_TYPES, _MCP_NOTIFICATION_TYPES, _MCP_ELICITATION_TYPES, sse_client
global _MCP_MESSAGE_HANDLER_SUPPORTED, _MCP_LOGGING_CALLBACK_SUPPORTED, LATEST_HANDSHAKE_VERSION
global _JSONRPC_METHOD_NOT_FOUND
if not _MCP_AVAILABLE:
return False
if _MCP_SDK_IMPORT_ATTEMPTED or ClientSession is not None:
return _MCP_AVAILABLE
with _MCP_SDK_IMPORT_LOCK:
if _MCP_SDK_IMPORT_ATTEMPTED or ClientSession is not None:
return _MCP_AVAILABLE
if (_import_sdk_names("mcp", ("ClientSession", "StdioServerParameters"))
and _import_sdk_names("mcp.client.stdio", ("stdio_client",))):
_MCP_AVAILABLE = True
# mcp >= 1.24 ships streamable_http_client; 2.0 dropped the deprecated
# streamablehttp_client alias. Either one gives HTTP.
_MCP_NEW_HTTP = _import_sdk_names("mcp.client.streamable_http", ("streamable_http_client",))
_MCP_LEGACY_HTTP = _import_sdk_names("mcp.client.streamable_http", ("streamablehttp_client",))
_MCP_HTTP_AVAILABLE = _MCP_NEW_HTTP or _MCP_LEGACY_HTTP
_import_sdk_names("mcp.types", ("LATEST_PROTOCOL_VERSION",),
"mcp.types.LATEST_PROTOCOL_VERSION not available -- using fallback protocol version")
if not _import_sdk_names("mcp.client.session", ("LATEST_HANDSHAKE_VERSION",)):
# Pre-2.x SDKs: newest revision IS the handshake revision.
LATEST_HANDSHAKE_VERSION = LATEST_PROTOCOL_VERSION
if not _import_sdk_names("mcp.client.sse", ("sse_client",),
"mcp.client.sse.sse_client not available -- SSE transport disabled"):
sse_client = None
_MCP_SAMPLING_TYPES, _MCP_ELICITATION_TYPES, _MCP_NOTIFICATION_TYPES = [
_import_sdk_names(*family) for family in _OPTIONAL_TYPE_FAMILIES]
else:
logger.debug("mcp package not installed -- MCP tool support disabled")
if _MCP_AVAILABLE:
try:
_JSONRPC_METHOD_NOT_FOUND = importlib.import_module("mcp.types").METHOD_NOT_FOUND
except Exception: # pragma: no cover — SDK without the constant
pass
_MCP_MESSAGE_HANDLER_SUPPORTED = _client_session_accepts("message_handler")
if _MCP_AVAILABLE and not _MCP_MESSAGE_HANDLER_SUPPORTED:
logger.debug("MCP SDK does not support message_handler -- dynamic tool discovery disabled")
_MCP_LOGGING_CALLBACK_SUPPORTED = _client_session_accepts("logging_callback")
_MCP_SDK_IMPORT_ATTEMPTED = True
return _MCP_AVAILABLE
_SDK_HTTPX_MOD = None
def sdk_httpx():
"""The httpx module the *installed* MCP SDK is built against (mcp 2.0 moved to ``httpx2``).
Every object crossing the SDK boundary (AsyncClient, OAuth Request, exception classes) must
come from the module the SDK itself imports or it fails at the transport layer. Resolved
from the SDK's transport module, else the newest present; ``None`` if neither imports."""
global _SDK_HTTPX_MOD
if _SDK_HTTPX_MOD is not None:
return _SDK_HTTPX_MOD
try:
from mcp.client import streamable_http as _transport
_SDK_HTTPX_MOD = getattr(_transport, "httpx2", None) or getattr(_transport, "httpx", None)
except ImportError:
_SDK_HTTPX_MOD = None
for fallback in ("httpx2", "httpx"):
if _SDK_HTTPX_MOD is not None:
break
try:
_SDK_HTTPX_MOD = importlib.import_module(fallback)
except ImportError:
pass
return _SDK_HTTPX_MOD
def _client_session_accepts(kwarg: str) -> bool:
"""Whether this SDK's ``ClientSession.__init__`` takes ``kwarg`` (older SDKs lack
``message_handler`` and ``logging_callback``)."""
if not _MCP_AVAILABLE:
return False
try:
return kwarg in inspect.signature(ClientSession).parameters
except (TypeError, ValueError):
return False
# MCP logging levels (RFC 5424 syslog severities) -> Python logging levels.
# Port of anomalyco/opencode#34529's serverLog mapping.
_MCP_LOG_LEVEL_MAP = {
"debug": logging.DEBUG, "info": logging.INFO, "notice": logging.INFO,
"warning": logging.WARNING, "error": logging.ERROR, "critical": logging.ERROR,
"alert": logging.ERROR, "emergency": logging.ERROR}
# ---- Reconnect / keepalive tuning ----
_DEFAULT_CONNECT_TIMEOUT = 60 # seconds for initial connection per server
_MAX_RECONNECT_RETRIES = 5
_MAX_INITIAL_CONNECT_RETRIES = 3 # retries for the very first connection attempt
_MAX_BACKOFF_SECONDS = 60
_RECYCLED_RECONNECT_TIMEOUT = 15.0
# Parked servers (tools deregistered) self-probe on this cadence: nothing else can revive them.
_PARKED_RETRY_INTERVAL = 300
# Bounded wait for a respawned stdio child when a call finds it dead (gateway restarts kill
# every MCP child); bounded so a broken server still parks via run()'s rapid-drop budget.
_STDIO_RESPAWN_WAIT_SEC = 15.0
# Remote clients MUST ping faster than the server's idle-session TTL (short-TTL servers need a
# smaller configured ``keepalive_interval``); stdio only opts in explicitly because local pipes
# have no remote session TTL. The floor stops a tiny interval busy-looping.
_DEFAULT_KEEPALIVE_INTERVAL, _MIN_KEEPALIVE_INTERVAL = 180, 5
# One bounded cancellation cycle at final shutdown so resistant tasks cannot hang exit.
_MCP_LOOP_DRAIN_TIMEOUT = 3.0
# JSON-RPC 2.0 "method not found" (server without optional ``ping``); _ensure_mcp_sdk()
# overrides it from mcp.types once loaded.
_JSONRPC_METHOD_NOT_FOUND = -32601
# nextCursor pagination cap so a forever-cursor cannot spin discovery (50 pages = thousands).
_MCP_LIST_MAX_PAGES = 50
async def _paginate_full_list(list_method, items_attr: str, server_name: str,
cache_meta_out: Optional[dict] = None):
"""Drain a paginated ``list_*`` call by following ``nextCursor``; ``cache_meta_out`` gets the
first page's SEP-2549 hints. Callers must hold the server's ``_rpc_lock``."""
items: list = []
cursor = None
for _ in range(_MCP_LIST_MAX_PAGES):
if not cursor:
result = await list_method()
else:
# mcp 2.0 takes params=PaginatedRequestParams, 1.x takes cursor=.
# Inspect before awaiting: an internal TypeError is not a signature mismatch.
import inspect
try:
signature = inspect.signature(list_method)
except (TypeError, ValueError):
accepts_params = True # Opaque callables use the current SDK convention.
else:
accepts_params = any(
p.kind == inspect.Parameter.VAR_KEYWORD
or (p.name == "params" and p.kind != inspect.Parameter.POSITIONAL_ONLY)
for p in signature.parameters.values()
)
if accepts_params:
import mcp.types as _types # late: keeps the SDK import lazy
_params_cls = getattr(_types, "PaginatedRequestParams", None)
if _params_cls is not None:
result = await list_method(params=_params_cls(cursor=cursor))
else:
result = await list_method(cursor=cursor)
else:
result = await list_method(cursor=cursor)
if cache_meta_out is not None and not items:
for key, snake, camel in (("ttl_ms", "ttl_ms", "ttlMs"), ("cache_scope", "cache_scope", "cacheScope")):
hint = mcp_field(result, snake, camel)
if hint is not None:
cache_meta_out[key] = hint
items.extend(getattr(result, items_attr, None) or [])
cursor = mcp_field(result, "next_cursor", "nextCursor")
# Cursor is an opaque string; anything else (incl. mocks) = last page.
if not isinstance(cursor, str) or not cursor:
break
else:
logger.warning("MCP server '%s': %s pagination exceeded %d pages; truncating at %d items",
server_name, items_attr, _MCP_LIST_MAX_PAGES, len(items))
return items
# ---- Server task -- each MCP server lives in one long-lived asyncio Task ----
class MCPServerTask(MCPServerRunMixin, MCPServerTransportMixin, MCPServerHealthMixin):
"""One MCP server connection in one long-lived asyncio Task (the transport's anyio cancel
scopes must enter/exit in the same Task). Run state machine, transport bring-up and
keepalive/liveness live in the three mixins."""
__slots__ = (
"name", "session", "tool_timeout", "_task", "_ready", "_shutdown_event", "_reconnect_event",
"_tools", "_error", "_config", "_sampling", "_elicitation", "_registered_tool_names",
"_auth_type", "_refresh_lock", "_rpc_lock", "_pending_refresh_tasks", "_pending_call_context",
"_lifecycle_started_at", "_last_tool_call_at", "_idle_timeout_seconds", "_max_lifetime_seconds",
"_recycled_reason", "initialize_result", "_ping_unsupported", "_list_cache_meta",
"_reconnect_retries", "_session_proven", "_was_parked", "_inflight_tasks", "_reconnecting",
"_suspect_reason", "_teardown_race", "_permanent_grace_used", "_stdio_child_pids",
"_ever_connected", "_sse_fallback", "_park_reason", "_last_park_line", "_resolved_identity")
def __init__(self, name: str):
self.name = name
self.session: Optional[Any] = None
self.tool_timeout: float = _DEFAULT_TOOL_TIMEOUT
self._task: Optional[asyncio.Task] = None
self._ready = asyncio.Event()
self._shutdown_event = asyncio.Event()
# Set -> _run_http/_run_stdio exit cleanly and run() re-enters the transport.
self._reconnect_event = asyncio.Event()
self._tools: list = []
self._registered_tool_names: list[str] = []
self._config: dict = {}
self._error: Optional[Exception] = None
self._sampling: Optional[SamplingHandler] = None
self._elicitation: Optional[ElicitationHandler] = None
self._reconnect_retries: int = 0
# Rapid-drop budget: a session is UNPROVEN until it survives a keepalive interval or a
# successful call; only a proven session clears the budget, so a post-handshake flapper
# still parks.
# Rapid-drop budget (#62212): a freshly (re)established session is UNPROVEN until it demonstrates
# real health — it survived at least one full keepalive interval (keepalive success path) or served
# at least one successful tool call. Only a proven session clears the reconnect budget; a transport
# that flaps right after the handshake keeps getting charged and still reaches the park instead of
# hot-cycling respawns forever.
self._session_proven: bool = False
# Never cleared (unlike _ready): separates first-connect from reconnect failures.
self._ever_connected: bool = False
# Latched when the Streamable HTTP -> SSE fallback connects: reconnects reuse SSE directly.
self._sse_fallback: bool = False
# Status/URL/body of the last HTTP rejection the Streamable HTTP client saw; names the real
# cause when the SDK reports only ``Server returned an error response``.
self._http_rejection: dict = {}
# True from park until proven healthy again; logs the revival once.
self._was_parked: bool = False
# Why the server is parked (the revival_reason handed to _park), None once healthy again.
# Lets cron preflight tell a network-blip park (recovering) from a permanent-error park
# (revoked credentials, dead endpoint) that must not run the job tool-less forever.
self._park_reason: Optional[str] = None
self._last_park_line: Optional[str] = None # last park WARNING text; identical re-parks log at DEBUG
# Digest of the resolved inputs the transport last connected with; a cross-profile adopter
# must resolve the same digest in its own scope. None until the transport publishes it.
self._resolved_identity: Optional[str] = None
# In-flight RPC tasks so a deliberate teardown fails them fast; _reconnecting is True
# during that teardown so _track_inflight_rpc turns the cancel into a retryable error.
# In-flight RPC bookkeeping (#48069 salvage): user-visible requests registered while running so a
# reconnect/shutdown teardown can fail them fast instead of orphaning them on a dying transport.
self._inflight_tasks: set = set()
self._reconnecting: bool = False
# Latched by races (teardown-vs-keepalive, auth-lock corruption); ensure_healthy()
# verifies before the next call.
# See #77765, #81051, #84132.
self._suspect_reason: Optional[str] = None
# Teardown that failed in-flight calls => next reconnect is RACE RECOVERY, not a
# budget charge.
self._teardown_race: bool = False
# One-time grace: auth/permanent failure on a PROVEN session gets one suspect+reconnect
# cycle before parking.
self._permanent_grace_used: bool = False
# Children of the current stdio transport: in-flight calls fail FAST when one dies.
# PIDs of the stdio subprocess spawned for the current transport (captured in _run_stdio). Used to
# fail in-flight calls FAST when the child dies instead of waiting out the full tool timeout
# (#81995).
self._stdio_child_pids: Set[int] = set()
self._auth_type: str = ""
self._refresh_lock = asyncio.Lock()
# A stdio session is one JSON-RPC stream (a concurrent list_tools can wedge a tool
# call): serialize client-initiated RPCs per server (HTTP too, for ordering).
self._rpc_lock = asyncio.Lock()
self._pending_refresh_tasks: set[asyncio.Task] = set()
# contextvars snapshot inside session.call_tool(): the SDK runs elicitation/create on a
# task that does not inherit HERMES_SESSION_PLATFORM, so the callback replays this.
self._pending_call_context: Optional[contextvars.Context] = None
self._lifecycle_started_at = self._last_tool_call_at = time.monotonic()
self._idle_timeout_seconds = self._max_lifetime_seconds = self._recycled_reason = None
# Handshake InitializeResult: the server's REAL advertised capabilities.
# Captures the ``InitializeResult`` returned by ``await session.initialize()`` so downstream code
# can inspect the server's real advertised capabilities (``.capabilities.resources``,
# ``.capabilities.prompts``) instead of assuming every ``ClientSession`` method attribute
# corresponds to a supported server method. See #18051.
self.initialize_result: Optional[Any] = None
# SEP-2549 cache hints from the last tools/list (ttl_ms, cache_scope).
self._list_cache_meta: dict = {}
# Latched when ``ping`` returns -32601; keepalives then use list_tools. Reset per connect.
self._ping_unsupported: bool = False
# Content types a real Streamable-HTTP endpoint may return on the initial POST/GET;
# anything else on a 2xx means the URL is not an MCP endpoint.
_MCP_CONTENT_TYPES = ("application/json", "text/event-stream")
# ---- Module-level state (every mutation under ``_lock``) ----
#
# Every ledger below is keyed by the CONNECTION KEY from ``tools.mcp_tool_scope``: the bare
# server name outside a multiplexer, ``(owner_scope, name)`` under one. Two profiles naming the
# same server with their own credentials are two connections; a name-keyed ledger let the first
# profile's connection shadow the second's (never connected, silently tool-less — #106005).
_servers: Dict[Any, MCPServerTask] = {}
# Profile registry scope per live connection (None outside multiplex) so a multiplexed
# /reload-mcp tears down only its own profile's servers.
_server_scope_keys: Dict[Any, Optional[str]] = {}
# Registry scopes that have adopted a live server connection. The owning scope above remains
# authoritative for connection teardown; this set preserves visibility for shared connections.
_server_tool_scopes: Dict[Any, set] = {}
_server_connecting: set = set()
_server_connect_errors: Dict[Any, str] = {}
# adopter scope -> server names whose shared connection an owner's scoped shutdown tore down;
# drained by the next discovery pass so the adopter is re-registered (see mcp_tool_lifecycle).
_orphaned_adopters: Dict[str, set] = {}
# Lazy startup: servers registered from the schema cache without connecting; popped on
# first real connection.
# Keyed by connection key; entries are popped once a real connection is established on first use. See #56832.
_lazy_server_configs: Dict[Any, dict] = {}
_lazy_server_fingerprints: Dict[Any, str] = {}
_lazy_server_tool_names: Dict[Any, List[str]] = {}
# Task-local claim around ``_connect_server``: discovery retains a recoverable parked task
# while standalone probes never publish failed servers into module-global ownership.
_connect_server_claim: contextvars.ContextVar[Optional[Callable[[MCPServerTask], None]]] = (
contextvars.ContextVar("mcp_connect_server_claim", default=None))
# Per-server connect cooldown: a server that fails to spawn never reaches ``_servers``, so
# without it every ``discover_mcp_tools()`` (one per worker session) would respawn it — a
# restart storm whose unreaped children destabilise healthy servers. Exponential-backoff
# deadline honoured by ``register_mcp_servers``; cleared on success.
# Connection-retry cooldown (per-server isolation against restart storms). A single stdio MCP server that
# fails to spawn (bad PATH, ``exec: not found``, crash-on-start) is never recorded in ``_servers`` --
# ``start()`` raises and ``_discover_and_register_server`` aborts before the ``_servers[name] = server``
# line. Without a cooldown, EVERY subsequent ``discover_mcp_tools()`` (one per agent worker session, i.e.
# every few seconds) sees the server as "not connected" and re-spawns it from scratch. That is the restart
# storm in #50394: the failing server is re-attempted on the shared MCP event loop on every worker session,
# the subprocesses pile up unreaped, and the churn destabilises the healthy co-located servers (their tools
# intermittently surface as "Unknown tool"). Fix: after a failed connection attempt, stamp a monotonic
# ``retry_after`` deadline with exponential backoff. ``register_mcp_servers`` skips a server whose cooldown
# has not elapsed, so a chronically failing server is retried on a backoff schedule instead of on every
# worker session -- isolating it from the rest of the bridge. A successful connection clears the state.
_server_connect_retry_after: Dict[Any, float] = {} # connection key -> monotonic deadline
_server_connect_failures: Dict[Any, int] = {} # connection key -> consecutive failures
_CONNECT_RETRY_BASE_BACKOFF_SEC, _CONNECT_RETRY_MAX_BACKOFF_SEC = 30.0, 600.0
# Per-server circuit breaker: closed -> open (calls short-circuit until the cooldown) ->
# half-open (next call probes). Mutate only via _bump_server_error / _reset_server_error.
# After _CIRCUIT_BREAKER_THRESHOLD consecutive failures, the handler returns a "server unreachable" message
# that tells the model to stop retrying, preventing the 90-iteration burn loop described in #10447. State
# machine: closed — error count below threshold; all calls go through. open — threshold reached;
# calls short-circuit until the cooldown elapses. half-open — cooldown elapsed; the next call is a probe
# that actually hits the session. Probe success → closed. Probe failure → reopens (cooldown re-armed).
# ``_server_breaker_opened_at`` records the monotonic timestamp when the breaker most recently transitioned
# into the open state. Use the ``_bump_server_error`` / ``_reset_server_error`` helpers to mutate this state
# — they keep the count and timestamp in sync.
_server_error_counts: Dict[Any, int] = {}
_server_breaker_opened_at: Dict[Any, float] = {}
# True while every strike in the current streak was the tool's own error payload (server reachable,
# call rejected); picks the open-breaker wording, since "unreachable" was false for that case (#11113).
_server_errors_all_application: Dict[Any, bool] = {}
_CIRCUIT_BREAKER_THRESHOLD, _CIRCUIT_BREAKER_COOLDOWN_SEC = 3, 60.0
# Trust-tier gating (``trust: full | untrusted``): on an untrusted server every write-capable
# call (discovery-time ``readOnlyHint`` not exactly True; malformed fails closed) needs approval
# before the RPC fires. A lying readOnlyHint can only skip approval for calls the operator was
# already warned about, never widen access. Missing trust = full; unrecognized = untrusted (a
# typo must never disable the gate). Classified at CALL time from DISCOVERY data: no schema
# mutation, prompt cache intact. ``_server_trust_levels`` is keyed by the CONSUMING profile's own
# key (its policy for the name, even when it adopted another profile's connection);
# ``_tool_read_only_hints`` by the connection key (the server's own tool annotations).
_server_trust_levels: Dict[Any, str] = {}
_tool_read_only_hints: Dict[Any, Dict[str, bool]] = {}
_TRUST_FULL, _TRUST_UNTRUSTED = "full", "untrusted"
def _bump_server_error(server_name: str, *, application: bool = False) -> None:
"""Count a failure; at the threshold (re)stamp the breaker-open time. Keyed by the calling
scope's connection so one profile's failing server never opens another profile's breaker.
*application*: the call completed and the payload was an error (transport is fine)."""
from tools.mcp_tool_scope import _resolve_server_key
key = _resolve_server_key(server_name)
n = _server_error_counts.get(key, 0) + 1
_server_error_counts[key] = n
_server_errors_all_application[key] = application and (n == 1 or _server_errors_all_application.get(key, False))
if n >= _CIRCUIT_BREAKER_THRESHOLD:
_server_breaker_opened_at[key] = time.monotonic()
def _reset_server_error(server_name: str) -> None:
"""Close the breaker on any unambiguous success signal."""
from tools.mcp_tool_scope import _resolve_server_key
key = _resolve_server_key(server_name)
_server_error_counts[key] = 0
_server_breaker_opened_at.pop(key, None)
_server_errors_all_application.pop(key, None)
# Servers opted into parallel tool calls, keyed by the consuming profile's own key (``foo-bar``/
# ``foo_bar`` sanitize alike but must not share policy; neither do two profiles' same-named servers).
_parallel_safe_servers: set = set()
# registry tool name -> raw server name (the generated name is lossy; never re-parse it).
_mcp_tool_server_names: Dict[str, str] = {}
# Dedicated event loop in a background daemon thread; _lock guards the loop handles, _servers,
# the status maps and the PID ledgers.
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
_mcp_thread: Optional[threading.Thread] = None
_lock = threading.Lock()
# ---- Shared parent-death supervisor (state lives HERE: tests rebind ``_death_supervisor``) ----
# If this process dies without running its cleanup path (kill -9, OOM, crash, force-quit), stdio
# MCP children reparent to init and run forever; macOS has no PR_SET_PDEATHSIG, so something has
# to outlive us and reap them. ONE supervisor process serves all stdio servers and is told which
# process groups to reap over a pipe; it detects our death as EOF on that pipe (exact, instant)
# rather than polling getppid(). Replaced the per-server watchdog wrapper (~10 MB resident per
# server, plus a signal-forwarding layer because wrapping put the server in a different session
# from the pgid tracked for killpg). See tools/mcp_death_supervisor.py. POSIX-only, matching the
# killpg-based orphan cleanup below.
_death_supervisor = None # Optional[subprocess.Popen]
_death_supervisor_lock = threading.Lock()
# Groups the supervisor is reaping on our behalf; replayed verbatim on respawn so a respawn never
# silently drops coverage for servers that are still running.
_supervised_pgids: set = set()
def _spawn_death_supervisor():
"""Start the shared supervisor, or None if it cannot be started."""
import subprocess
supervisor = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp_death_supervisor.py")
try:
# start_new_session=True is load-bearing: shutdown paths killpg this process's own group,
# which would kill the supervisor before it could reap anything.
return subprocess.Popen(
[sys.executable, supervisor, "--parent-pgid", str(os.getpgid(0))],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=_get_mcp_stderr_log(),
start_new_session=True, close_fds=True, text=True)
except Exception:
# Never let supervisor bookkeeping block a real MCP connection: graceful shutdown paths
# still reap normally; only the ungraceful-exit safety net is lost.
logger.debug("Could not start the MCP parent-death supervisor", exc_info=True)
return None
def _prune_dead_supervised_pgids() -> set:
"""Forget supervised groups with no members left; return what went. Caller holds
``_death_supervisor_lock``. Signal 0 is a pure existence probe (cannot terminate anything).
It narrows, but cannot close, the window where a dead group's pgid is recycled before we
notice (residual-risk note in ``tools/mcp_death_supervisor.py``)."""
killpg = getattr(os, "killpg", None)
if killpg is None: # windows-footgun: ok - POSIX-only, guarded
return set()
stale = set()
for pgid in list(_supervised_pgids):
try:
killpg(pgid, 0)
except ProcessLookupError:
stale.add(pgid)
except (PermissionError, OSError):
# Exists but not ours to signal, or the probe failed: keep it — dropping coverage on
# an ambiguous answer is the more expensive mistake.
pass
_supervised_pgids.difference_update(stale)
return stale
def _update_death_supervisor(verb: str, pgids) -> None:
"""Register or unregister process groups (``verb`` is ``"register"``/``"unregister"``) with
the shared supervisor. Failures are swallowed: losing the safety net must never fail a live
MCP session."""
if os.name != "posix":
return
wanted = {int(pgid) for pgid in pgids}
if not wanted:
return
global _death_supervisor
with _death_supervisor_lock:
if verb == "register":
_supervised_pgids.update(wanted)
else:
_supervised_pgids.difference_update(wanted)
# A registration outlives the server only while some member survives (e.g. an orphaned
# grandchild teardown failed to kill, deliberately kept registered). Once that group is
# empty its pgid can be recycled by a stranger, so prune here too — the orphan sweep
# unregisters what it reaps but is not guaranteed to run in a given process.
stale = _prune_dead_supervised_pgids()
proc = _death_supervisor
if proc is None or proc.poll() is not None:
if not _supervised_pgids:
# Nothing left to cover: nothing to tell and nothing to respawn for. Keyed on
# the SET, not the verb: after a broken-pipe write dropped the supervisor with
# groups still registered, an unregister must still rebuild coverage for the
# survivors.
return
# See #93517.
proc = _spawn_death_supervisor()
_death_supervisor = proc
if proc is None:
return
# A fresh supervisor knows nothing: replay live coverage (already reflects this
# call's mutation and the prune, so pruned groups never reach the replacement).
payload = "".join(f"register {pgid}\n" for pgid in _supervised_pgids)
else:
payload = "".join(f"{verb} {pgid}\n" for pgid in wanted)
payload += "".join(f"unregister {pgid}\n" for pgid in stale)
try:
proc.stdin.write(payload)
proc.stdin.flush()
except (BrokenPipeError, ValueError, OSError):
# It exited between poll() and write(). Drop it so the next call respawns and replays
# from ``_supervised_pgids`` (the set, not the pipe, is the record of what needs reaping).
_death_supervisor = None
return
if not _supervised_pgids:
# Nothing left to reap: release the supervisor rather than keep a ~15 MB process and a
# pipe resident for the life of a gateway. Closing our write end is the same EOF parent
# death sends; with an empty set it exits. The next register respawns and replays.
try:
proc.stdin.close()
except (BrokenPipeError, ValueError, OSError):
pass
# Reap it, or the exited supervisor stays a zombie until the next Popen in this process.
try:
proc.wait(timeout=5)
except Exception: # noqa: BLE001 - timeout or already gone; either way we drop it
pass
_death_supervisor = None
def _mcp_registry_scope() -> Optional[str]:
"""Registry scope for MCP registrations: a profile overlay when this process serves profiles,
else None. Under ``gateway.multiplex_profiles`` every turn runs scoped; a process that serves a
routed profile through the HERMES_HOME override (dashboard/desktop backend, per-profile cron
ticker) is a multiplexer too, even with the flag off — keying its connections by the bare name
would hand one profile's credentialed connection to every other served profile (#111151).
Single-profile processes (no override, or an override naming their own home) keep bare names."""
from agent.secret_scope import serves_routed_profile
if not serves_routed_profile():
return None
from tools.registry import registry
return registry.current_scope_key()
def _server_registry_scope(key) -> Optional[str]:
"""Scope owning the connection under *key*'s tools: the one captured at adoption (teardown
runs on the MCP loop without the discovering profile's context), else the current one."""
if key in _server_scope_keys:
return _server_scope_keys[key]
from tools.mcp_tool_scope import _key_scope
return _key_scope(key) or _mcp_registry_scope()
def _server_visible_in_scope(key, scope: Optional[str]) -> bool:
"""Whether the live connection under *key* is visible from ``scope`` without changing its
teardown owner."""
if scope is None:
return True
return (_server_scope_keys.get(key) == scope
or scope in _server_tool_scopes.get(key, ()))
# Cross-process discovery guard: advisory file lock so gateway + CLI + TUI don't all discover.
# See issue #62771.
_LOCK_UNAVAILABLE: Any = object() # sentinel: locking broken/unavailable
_MCP_DISCOVERY_LOCK_PATH: Optional[str] = None # resolved lazily
# A discovery pass (bounded gathers, one 120 s budget per wave) may legitimately
# run past the 120 s a scatter completes in. The waiter must outlast the worst
# legitimate holder (pass ceiling + slack), or it fails over at 120 s, discovers
# unguarded beside a still-connecting holder, and spawns duplicate stdio trees.
# See #117373: the concurrency cap made the old 120 s waiter budget stale.
_MCP_DISCOVERY_PASS_MAX_SEC = 300 # overall pass ceiling
_MCP_DISCOVERY_LOCK_RETRY_DELAY_S = 0.5
# Waiter budget (max_retries * delay) must outlast the pass ceiling: 320 s > 300 s.
_MCP_DISCOVERY_LOCK_MAX_RETRIES = int(
_MCP_DISCOVERY_PASS_MAX_SEC / _MCP_DISCOVERY_LOCK_RETRY_DELAY_S) + 20
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Coroutine # noqa: F401,E402
from types import SimpleNamespace # noqa: F401,E402
from typing import Tuple # noqa: F401,E402
from contextlib import asynccontextmanager # noqa: F401,E402
import concurrent.futures # noqa: F401,E402
from datetime import datetime # noqa: F401,E402
import errno # noqa: F401,E402
import fnmatch # noqa: F401,E402
import json # noqa: F401,E402
import math # noqa: F401,E402
import random # noqa: F401,E402
import re # noqa: F401,E402
import shutil # noqa: F401,E402
from urllib.parse import urlparse # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'InvalidMcpUrlError': ('tools.mcp_tool_errors', 'InvalidMcpUrlError'),
'MCP_TOOL_NAME_PREFIX': ('tools.mcp_tool_schema', 'MCP_TOOL_NAME_PREFIX'),
'NonMcpEndpointError': ('tools.mcp_tool_errors', 'NonMcpEndpointError'),
'discover_mcp_tools': ('tools.mcp_tool_discovery', 'discover_mcp_tools'),
'get_mcp_status': ('tools.mcp_tool_discovery', 'get_mcp_status'),
'get_registered_mcp_server_names': ('tools.mcp_tool_discovery', 'get_registered_mcp_server_names'),
'has_registered_mcp_tools': ('tools.mcp_tool_discovery', 'has_registered_mcp_tools'),
'is_mcp_tool_parallel_safe': ('tools.mcp_tool_discovery', 'is_mcp_tool_parallel_safe'),
'matches_name_filter': ('tools.mcp_tool_schema', 'matches_name_filter'),
'mcp_prefixed_tool_name': ('tools.mcp_tool_schema', 'mcp_prefixed_tool_name'),
'persist_agent_tool_names': ('tools.mcp_tool_agent', 'persist_agent_tool_names'),
'probe_mcp_server_tools': ('tools.mcp_tool_discovery', 'probe_mcp_server_tools'),
'reconnect_mcp_server': ('tools.mcp_tool_loop', 'reconnect_mcp_server'),
'refresh_agent_mcp_tools': ('tools.mcp_tool_agent', 'refresh_agent_mcp_tools'),
'register_mcp_servers': ('tools.mcp_tool_discovery', 'register_mcp_servers'),
'reprobe_tool_availability': ('tools.mcp_tool_agent', 'reprobe_tool_availability'),
'restore_agent_tool_prefix': ('tools.mcp_tool_agent', 'restore_agent_tool_prefix'),
'sanitize_mcp_name_component': ('tools.mcp_tool_schema', 'sanitize_mcp_name_component'),
'shutdown_mcp_servers': ('tools.mcp_tool_lifecycle', 'shutdown_mcp_servers'),
'strip_unicode_tags': ('tools.ansi_strip', 'strip_unicode_tags'),
'tool_error': ('tools.registry', 'tool_error'),
}
_plugin_compat_prev_getattr = __getattr__
def __getattr__(name): # PEP 562 — chained onto the module's own __getattr__
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
return _plugin_compat_prev_getattr(name)
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----