#!/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 it back through ``tools.mcp_tool`` at call time and every name they define is re-exported here so ``patch("tools.mcp_tool.X")`` works.""" import asyncio import contextvars import importlib import importlib.util import inspect import logging import os # noqa: F401 — tests patch ``tools.mcp_tool.os.*`` import shutil # noqa: F401 — tests patch ``tools.mcp_tool.shutil.which`` import threading import time from typing import Any, Callable, Dict, List, Optional, Set logger = logging.getLogger(__name__) from tools.mcp_tool_common import ( # noqa: F401 _DEFAULT_TOOL_TIMEOUT, _env_ref_name, _exc_str, _get_lifecycle_seconds, _jittered, _parse_boolish, _resolve_tool_timeout, _safe_numeric, _sanitize_error, mcp_field) from tools.mcp_tool_schema import ( # noqa: F401 MCP_TOOL_NAME_PREFIX, _build_utility_schemas, _convert_mcp_schema, _normalize_mcp_input_schema, _scan_mcp_description, matches_name_filter, mcp_prefixed_tool_name, sanitize_mcp_name_component) from tools.mcp_tool_content import ( # noqa: F401 _MCP_HARD_RESULT_CAP_CHARS, _MCP_RESOURCE_MAX_B64_CHARS, _MCP_RESOURCE_MAX_BYTES, _cache_mcp_audio_block, _cache_mcp_image_block, _is_reserved_mcp_meta_key, _mcp_image_extension_for_mime_type, _mcp_resource_filename, _render_mcp_resource_block, _truncate_mcp_text_result) from tools.mcp_tool_errors import ( # noqa: F401 InvalidMcpUrlError, NonMcpEndpointError, _EXC_TRAVERSAL_MAX_NODES, _JSONRPC_UNSUPPORTED_PROTOCOL_VERSION, _classify_mcp_failure, _format_connect_error, _handshake_rejected_as_modern, _is_auth_error, _is_method_not_found_error, _is_session_expired_error, _make_redirect_header_stripper, _resolve_client_cert, _resolve_identity_header, _unwrap_exception_group, _validate_remote_mcp_url) from tools.mcp_tool_config import ( # noqa: F401 _ENV_VAR_PATTERN, _build_safe_env, _filter_suspicious_mcp_servers, _get_mcp_stderr_log, _interpolate_env_vars, _load_mcp_config, _resolve_stdio_command, _warn_hidden_whitespace, _whitespace_warned, _workspace_folder, _wrap_command_with_watchdog, _write_stderr_log_header) from tools.mcp_tool_sampling import ( # noqa: F401 ElicitationHandler, SamplingHandler, _format_elicitation_schema_summary) from tools.mcp_tool_handlers import ( # noqa: F401 _handle_auth_error_and_retry, _handle_session_expired_and_retry, _make_check_fn, _make_get_prompt_handler, _make_list_prompts_handler, _make_list_resources_handler, _make_read_resource_handler, _make_tool_handler) from tools.mcp_tool_registration import ( # noqa: F401 _annotation_read_only_hint, _existing_tool_names, _forget_mcp_tool_server, _normalize_server_trust, _register_from_cache_sync, _register_server_tools, _select_utility_schemas, _track_mcp_tool_server) from tools.mcp_tool_lifecycle import ( # noqa: F401 _drain_and_stop_mcp_loop, _drain_mcp_loop_tasks, _filter_mcp_children, _kill_orphaned_mcp_children, _orphan_stdio_pid_servers, _orphan_stdio_pids, _snapshot_child_pids, _stdio_pgids, _stdio_pids, _stop_mcp_loop_if_idle, shutdown_mcp_servers) from tools.mcp_tool_agent import ( # noqa: F401 _reinject_post_build_tools, persist_agent_tool_names, refresh_agent_mcp_tools, reprobe_tool_availability, restore_agent_tool_prefix) from tools.mcp_tool_transport import MCPServerTransportMixin from tools.mcp_tool_server_run import MCPServerRunMixin from tools.mcp_tool_health import MCPServerHealthMixin from tools.mcp_tool_loop import ( # noqa: F401 _running_loop, _LockCookie, _acquire_lock_on_fh, _try_acquire_mcp_discovery_lock, _mcp_loop_exception_handler, _wrap_with_home_override, _wrap_with_dashboard_oauth_flow, _run_on_mcp_loop, _signal_reconnect, reconnect_mcp_server, _wait_for_server_session_ready, _signal_reconnect_and_wait, _ensure_mcp_loop, _stop_mcp_loop) from tools.mcp_tool_discovery import ( # noqa: F401 _record_connect_failure, _clear_connect_failure, _connect_cooldown_active, _connect_server, _request_lazy_reconnect, _resolve_server_lazy, _ensure_lazy_server_connected, _get_connected_server_for_call, _discover_and_register_server, register_mcp_servers, discover_mcp_tools, is_mcp_tool_parallel_safe, get_mcp_status, probe_mcp_server_tools, has_registered_mcp_tools, get_registered_mcp_server_names) # Wall-clock bound on the (fail-open) OSV malware preflight run off the loop before a # stdio spawn. Kept just ABOVE osv_check._TIMEOUT (10s) so the inner socket timeout # normally fires first; this only bites when a stalled SSL handshake defeats it. _OSV_MALWARE_CHECK_TIMEOUT_S = 12.0 # --------------------------------------------------------------------------- # 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 that don't export LATEST_PROTOCOL_VERSION (Streamable HTTP arrived # with 2025-03-26, so this stays valid for the HTTP path). LATEST_PROTOCOL_VERSION = "2025-03-26" # Newest revision ``ClientSession.initialize()`` actually speaks. From 2026-07-28 the # handshake is replaced by a per-request envelope, so this can be OLDER than # LATEST_PROTOCOL_VERSION; 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 real use (_ensure_mcp_sdk). # Availability is decided here with a metadata-only find_spec probe so every # ``if not _MCP_AVAILABLE`` gate / test patch / skipif keeps its exact semantics. 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() # SDK symbols 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 # and the mock is never clobbered (_ensure is idempotent). _MCP_SDK_LAZY_SYMBOLS = frozenset({ "StdioServerParameters", "stdio_client", "streamablehttp_client", "streamable_http_client", "CreateMessageResult", "CreateMessageResultWithTools", "ErrorData", "SamplingCapability", "SamplingToolsCapability", "TextContent", "ToolUseContent", "ElicitRequestParams", "ElicitResult", "ServerNotification", "ToolListChangedNotification", "PromptListChangedNotification", "ResourceListChangedNotification"}) # Optional SDK type families (module, names, debug message when absent) bound in this order to # _MCP_SAMPLING_TYPES / _MCP_ELICITATION_TYPES / _MCP_NOTIFICATION_TYPES. Each is gated # separately so 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"), ) 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 mock symbols (``ClientSession`` already set means no re-import, so mocks are never clobbered). """ 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(): """Return the httpx module the *installed* MCP SDK is built against. mcp 2.0 moved to ``httpx2`` (same API, separate distribution). Every object crossing the SDK boundary (the ``AsyncClient`` passed to the transport, OAuth ``Request`` objects, the 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, not a version number; falls back to the newest module present. ``None`` only when neither module is importable. """ 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 if _SDK_HTTPX_MOD is None: try: import httpx2 as _fallback except ImportError: try: import httpx as _fallback # type: ignore[no-redef] except ImportError: return None _SDK_HTTPX_MOD = _fallback 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. _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 # Parked servers (budget exhausted, tools deregistered) self-probe on this cadence: with # no tools registered nothing else can ever revive them. _PARKED_RETRY_INTERVAL = 300 _RECYCLED_RECONNECT_TIMEOUT = 15.0 # 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 # Servers may expire idle sessions on any TTL, so the client MUST ping faster than that TTL; # short-TTL servers (~15s) need a smaller configured ``keepalive_interval``. The floor stops # a tiny interval from busy-looping. _DEFAULT_KEEPALIVE_INTERVAL = 180 _MIN_KEEPALIVE_INTERVAL = 5 # One bounded cancellation cycle for pending loop tasks at final shutdown, so # cancellation-resistant tasks cannot hang process exit. _MCP_LOOP_DRAIN_TIMEOUT = 3.0 # JSON-RPC 2.0 "method not found" (e.g. a server without the optional ``ping``). # _ensure_mcp_sdk() overrides it from mcp.types once the SDK is loaded. _JSONRPC_METHOD_NOT_FOUND = -32601 # Cap on nextCursor pagination so a server returning a cursor forever cannot spin # discovery; 50 pages at 50-100 items/page covers thousands of entries. _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`` (the SDK fetches one page per call). ``cache_meta_out`` receives the first page's SEP-2549 hints (``ttl_ms``, ``cache_scope``). Callers must hold the server's ``_rpc_lock`` for a consistent snapshot.""" 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=. try: 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) except TypeError: result = await list_method(cursor=cursor) if cache_meta_out is not None and not items: _ttl = mcp_field(result, "ttl_ms", "ttlMs") _scope = mcp_field(result, "cache_scope", "cacheScope") if _ttl is not None: cache_meta_out["ttl_ms"] = _ttl if _scope is not None: cache_meta_out["cache_scope"] = _scope 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 living in one long-lived asyncio Task, so the transport's anyio cancel scopes are entered and exited in the same Task. Run state machine in ``MCPServerRunMixin``, transport bring-up in ``MCPServerTransportMixin``, keepalive / refresh / liveness in ``MCPServerHealthMixin``.""" __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") 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() # When set, _run_http/_run_stdio exit their async-with cleanly and run() re-enters # the transport (auth recovery, manual refresh, ...). self._reconnect_event = asyncio.Event() self._tools: list = [] self._error: Optional[Exception] = None self._config: dict = {} self._sampling: Optional[SamplingHandler] = None self._elicitation: Optional[ElicitationHandler] = None self._registered_tool_names: list[str] = [] self._reconnect_retries: int = 0 # Rapid-drop budget: a (re)established session is UNPROVEN until it survives a full # keepalive interval or serves a successful call. Only a proven session clears the # reconnect budget, so a transport that flaps right after the handshake still parks. self._session_proven: bool = False # Set once tools were ever registered, never cleared (unlike _ready, which clears every # reconnect cycle): separates first-connect from reconnect failures in run()'s ladders. self._ever_connected: bool = False # True from park until the session proves healthy again; logs the revival once. self._was_parked: bool = False # In-flight RPC tasks, so a reconnect/shutdown teardown can fail them fast instead of # orphaning them on a dying transport; _reconnecting is True while such a deliberate # teardown runs, letting _track_inflight_rpc turn the cancel into a retryable error. self._inflight_tasks: set = set() self._reconnecting: bool = False # Latched by races (teardown-vs-keepalive, auth-lock corruption); verified lazily by # ensure_healthy() before the next call. self._suspect_reason: Optional[str] = None # A teardown that failed >=1 in-flight call makes the next reconnect a RACE RECOVERY: # it must not charge the rapid-drop budget. self._teardown_race: bool = False # One-time grace: an auth/permanent-classified failure on a previously PROVEN session # gets one suspect+reconnect cycle before the park ladder applies. self._permanent_grace_used: bool = False # Children of the current stdio transport: in-flight calls fail FAST when the child # dies instead of riding out the tool timeout. 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 list_tools issued by the notification # handler while a tool call is in flight can wedge it. 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 of the agent task inside session.call_tool(). The SDK dispatches # elicitation/create on a separate task that does not inherit HERMES_SESSION_PLATFORM; # replaying this context in the elicitation callback routes the prompt correctly. self._pending_call_context: Optional[contextvars.Context] = None self._lifecycle_started_at = self._last_tool_call_at = time.monotonic() self._idle_timeout_seconds: Optional[float] = None self._max_lifetime_seconds: Optional[float] = None self._recycled_reason: Optional[str] = None # InitializeResult from the handshake: the server's REAL advertised capabilities. 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 keepalive ``ping`` returns -32601 (optional utility not implemented); # later keepalives use list_tools instead. Reset on every fresh transport connection. 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``) # --------------------------------------------------------------------------- _servers: Dict[str, MCPServerTask] = {} # Profile registry scope owning each live connection (None outside multiplex): a # multiplexed /reload-mcp tears down only its own profile's servers. _server_scope_keys: Dict[str, Optional[str]] = {} _server_connecting: set[str] = set() _server_connect_errors: Dict[str, str] = {} # Lazy startup: servers registered from the on-disk schema cache without connecting; # popped once a real connection is established on first use. _lazy_server_configs: Dict[str, dict] = {} _lazy_server_fingerprints: Dict[str, str] = {} _lazy_server_tool_names: Dict[str, List[str]] = {} # Discovery installs a task-local claim around ``_connect_server`` so it can retain a # recoverable parked task without standalone probe calls publishing 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 this every ``discover_mcp_tools()`` (one per worker session) would respawn it from # scratch — a restart storm whose unreaped subprocesses destabilise the healthy co-located # servers. Failed attempts stamp an exponential-backoff deadline that # ``register_mcp_servers`` honours; a successful connection clears it. _server_connect_retry_after: Dict[str, float] = {} # name -> monotonic deadline _server_connect_failures: Dict[str, int] = {} # name -> consecutive failures _CONNECT_RETRY_BASE_BACKOFF_SEC = 30.0 _CONNECT_RETRY_MAX_BACKOFF_SEC = 600.0 # Circuit breaker per server: closed (count < threshold) -> open (calls short-circuit with a # "stop retrying" message until the cooldown elapses) -> half-open (next call is a probe; # success closes, failure re-arms). Mutate only via _bump_server_error / _reset_server_error. _server_error_counts: Dict[str, int] = {} _server_breaker_opened_at: Dict[str, float] = {} _CIRCUIT_BREAKER_THRESHOLD = 3 _CIRCUIT_BREAKER_COOLDOWN_SEC = 60.0 # Trust-tier gating (``mcp_servers..trust: full | untrusted``). On an untrusted server # every write-capable call needs user approval before the RPC fires; a tool is write-capable # unless its discovery-time ``annotations.readOnlyHint`` is exactly True (malformed fails # closed). readOnlyHint is a server-supplied HINT and a hostile server can lie, but on an # untrusted server a lie can only skip approval for calls the operator was already warned # about — never widen access. Missing ``trust`` defaults to full (backward compatible); any # unrecognized value normalizes to untrusted (a typo must never disable the gate). # Classified at CALL time from DISCOVERY data: no schema mutation, prompt cache intact. _server_trust_levels: Dict[str, str] = {} _tool_read_only_hints: Dict[str, Dict[str, bool]] = {} _TRUST_FULL = "full" _TRUST_UNTRUSTED = "untrusted" def _bump_server_error(server_name: str) -> None: """Count a failure; at the threshold (re)stamp the breaker-open time.""" n = _server_error_counts.get(server_name, 0) + 1 _server_error_counts[server_name] = n if n >= _CIRCUIT_BREAKER_THRESHOLD: _server_breaker_opened_at[server_name] = time.monotonic() def _reset_server_error(server_name: str) -> None: """Close the breaker on any unambiguous success signal.""" _server_error_counts[server_name] = 0 _server_breaker_opened_at.pop(server_name, None) # Raw server names opted into parallel tool calls. Raw identity matters: ``foo-bar`` and # ``foo_bar`` both sanitize to ``foo_bar`` but must not share policy. _parallel_safe_servers: set = set() # registry tool name -> raw server name, captured at registration. The generated name is # lossy (punctuation -> ``_``), so never re-parse it. _mcp_tool_server_names: Dict[str, str] = {} # Dedicated event loop running in a background daemon thread. _mcp_loop: Optional[asyncio.AbstractEventLoop] = None _mcp_thread: Optional[threading.Thread] = None # Guards the loop handles, _servers, the status maps and the PID ledgers. _lock = threading.Lock() def _mcp_registry_scope() -> Optional[str]: """Registry scope for MCP registrations: under a profile multiplexer each profile's MCP tools live in its own registry overlay; single-profile processes stay global (None).""" from agent.secret_scope import is_multiplex_active if not is_multiplex_active(): return None from tools.registry import registry return registry.current_scope_key() def _server_registry_scope(name: str) -> Optional[str]: """Scope owning server *name*'s tools: recorded at connect, else current. Teardown runs on the MCP loop without the discovering profile's context, so the scope captured at adoption into ``_servers`` is authoritative.""" if name in _server_scope_keys: return _server_scope_keys[name] return _mcp_registry_scope() # Cross-process MCP discovery guard: advisory file lock so gateway + CLI + TUI don't all # run discovery at once. _LOCK_UNAVAILABLE: Any = object() # sentinel: locking broken/unavailable _MCP_DISCOVERY_LOCK_PATH: Optional[str] = None # resolved lazily # Bounded wait when another process holds the lock. _MCP_DISCOVERY_LOCK_MAX_RETRIES: int = 240 _MCP_DISCOVERY_LOCK_RETRY_DELAY_S: float = 0.5