#!/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 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 # ---- 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. _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 # The client MUST ping faster than the server's idle-session TTL (short-TTL servers need a # smaller configured ``keepalive_interval``); 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=. try: import mcp.types as _types # late: keeps the SDK import lazy _params_cls = getattr(_types, "PaginatedRequestParams", None) result = await (list_method(params=_params_cls(cursor=cursor)) if _params_cls is not None else list_method(cursor=cursor)) except TypeError: 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") 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. self._session_proven: bool = False # Never cleared (unlike _ready): separates first-connect from reconnect failures. self._ever_connected: bool = False # True from park until proven healthy again; logs the revival once. self._was_parked: bool = False # 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. 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. 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. 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. 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``) ---- _servers: Dict[str, 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[str, Optional[str]] = {} _server_connecting: set[str] = set() _server_connect_errors: Dict[str, str] = {} # Lazy startup: servers registered from the schema cache without connecting; popped on # first real connection. _lazy_server_configs: Dict[str, dict] = {} _lazy_server_fingerprints: Dict[str, str] = {} _lazy_server_tool_names: Dict[str, 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. _server_connect_retry_after: Dict[str, float] = {} # name -> monotonic deadline _server_connect_failures: Dict[str, int] = {} # name -> 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. _server_error_counts: Dict[str, int] = {} _server_breaker_opened_at: Dict[str, float] = {} _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: Dict[str, str] = {} _tool_read_only_hints: Dict[str, Dict[str, bool]] = {} _TRUST_FULL, _TRUST_UNTRUSTED = "full", "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 (``foo-bar``/``foo_bar`` sanitize alike but # must not share policy). _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() def _mcp_registry_scope() -> Optional[str]: """Registry scope for MCP registrations: a profile overlay under a multiplexer, else 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 *name*'s tools: the one captured at adoption (teardown runs on the MCP loop without the discovering profile's context), else the current one.""" if name in _server_scope_keys: return _server_scope_keys[name] return _mcp_registry_scope() # Cross-process discovery guard: advisory file lock so gateway + CLI + TUI don't all discover. _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, _MCP_DISCOVERY_LOCK_RETRY_DELAY_S = 240, 0.5