The MCP config reconciler was appended to the gateway/run.py facade; it moves to gateway/run_profile_reconcile.py, which already owns post-boot MCP discovery, and run.py keeps only the chore-table entry. reconcile_mcp_servers_with_config() also drops a schema-cache (lazy) registration whose entry is gone (its cached tools would otherwise stay callable and spawn the server on first use) and reports a dropped server still mid-connect as "pending"; the chore retries on the next tick without waiting for another config edit. test_cron_delivery_housekeeping neutralizes the chore: it pins the exact scope/drain sequence of the housekeeping loop and the new chore enters each profile's scope once per tick.
618 lines
30 KiB
Python
618 lines
30 KiB
Python
"""Connecting and discovery for tools.mcp_tool: per-server connect cooldown, connect /
|
|
lazy-start / recycled-stdio wake-up, ``register_mcp_servers`` / ``discover_mcp_tools`` and
|
|
the status / probe public API. Origin state (``_servers``, ``_lock``, the loop, patchable
|
|
helpers) is read through ``_core`` so ``mock.patch("tools.mcp_tool.X")`` keeps working."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import Dict, List, Optional, Tuple
|
|
from tools.mcp_tool_common import _core, _parse_boolish
|
|
from tools import mcp_tool_config as _config
|
|
from tools import mcp_tool_errors as _errors
|
|
from tools import mcp_tool_lifecycle as _lifecycle
|
|
from tools import mcp_tool_loop as _loop
|
|
from tools import mcp_tool_registration as _registration
|
|
from tools.mcp_tool_schema import MCP_TOOL_NAME_PREFIX
|
|
from tools.mcp_tool_scope import _key_name, _key_scope, _resolve_server_key, _server_key
|
|
|
|
logger = logging.getLogger("tools.mcp_tool")
|
|
|
|
|
|
def _record_connect_failure(server_name: str) -> None:
|
|
"""Stamp a geometric, capped retry cooldown after a failed connect (under ``_lock``)."""
|
|
key = _server_key(server_name)
|
|
n = _core._server_connect_failures.get(key, 0) + 1
|
|
_core._server_connect_failures[key] = n
|
|
backoff = min(_core._CONNECT_RETRY_BASE_BACKOFF_SEC * (2 ** (n - 1)), _core._CONNECT_RETRY_MAX_BACKOFF_SEC)
|
|
_core._server_connect_retry_after[key] = time.monotonic() + backoff
|
|
|
|
|
|
def _clear_connect_failure(server_name: str) -> None:
|
|
"""Clear the connect-cooldown state after a successful connection."""
|
|
key = _server_key(server_name)
|
|
_core._server_connect_failures.pop(key, None)
|
|
_core._server_connect_retry_after.pop(key, None)
|
|
|
|
|
|
def _connect_cooldown_active(server_name: str) -> bool:
|
|
"""True if ``server_name`` is still within its retry cooldown (this scope's connection: one
|
|
profile's failing ``x`` must not shadow another profile's healthy ``x``)."""
|
|
deadline = _core._server_connect_retry_after.get(_server_key(server_name))
|
|
return deadline is not None and time.monotonic() < deadline
|
|
|
|
|
|
def _enabled(cfg: dict) -> bool:
|
|
return _parse_boolish(cfg.get("enabled", True), default=True)
|
|
|
|
|
|
async def _connect_server(name: str, config: dict) -> _core.MCPServerTask:
|
|
"""Create an MCPServerTask, start it, return once ready (tear down with ``server.shutdown()``
|
|
on the same loop). Raises on bad config, missing HTTP support or connect failure."""
|
|
server = _core.MCPServerTask(name)
|
|
claim = _core._connect_server_claim.get()
|
|
if claim is not None:
|
|
claim(server)
|
|
# The run task copies this context: don't retain the discovery closure for its life.
|
|
claim_token = _core._connect_server_claim.set(None) if claim is not None else None
|
|
try:
|
|
await server.start(config)
|
|
except asyncio.CancelledError:
|
|
raise # start() already reaps server._task; shutdown() here could swallow the cancel
|
|
except BaseException:
|
|
# Discovery owns claimed tasks (recoverable park); standalone probes must reap locally.
|
|
if claim is None:
|
|
try:
|
|
await server.shutdown()
|
|
except Exception as shutdown_exc: # noqa: BLE001 -- best-effort reap, don't mask the real error
|
|
logger.debug("MCP server '%s' shutdown during orphan-reap failed: %s", name, shutdown_exc)
|
|
raise
|
|
finally:
|
|
if claim_token is not None:
|
|
_core._connect_server_claim.reset(claim_token)
|
|
return server
|
|
|
|
|
|
def _request_lazy_reconnect(server_name: str, server: _core.MCPServerTask) -> bool:
|
|
"""Wake a recycled stdio server and wait briefly for a fresh session."""
|
|
loop = _loop._running_loop() if server._is_recycled_stdio() else None
|
|
if loop is None:
|
|
return False
|
|
|
|
def _wake() -> None:
|
|
server._ready.clear()
|
|
server._reconnect_event.set()
|
|
|
|
loop.call_soon_threadsafe(_wake)
|
|
|
|
async def _await_ready() -> bool:
|
|
deadline = time.monotonic() + _core._RECYCLED_RECONNECT_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
if server.session is not None and server._ready.is_set():
|
|
return True
|
|
await asyncio.sleep(0.05)
|
|
return False
|
|
|
|
try:
|
|
return bool(_loop._run_on_mcp_loop(_await_ready, timeout=_core._RECYCLED_RECONNECT_TIMEOUT))
|
|
except Exception as exc:
|
|
logger.warning("MCP server '%s': lazy reconnect after stdio recycle failed: %s", server_name, exc)
|
|
return False
|
|
|
|
|
|
def _resolve_server_lazy(name: str, config: dict) -> bool:
|
|
"""True when ``mcp_servers.<name>.lazy`` defers connect to first tool use (default off).
|
|
|
|
Gated per-server by ``mcp_servers.<name>.lazy`` in config (default OFF), following the same per-server
|
|
key pattern as ``idle_timeout_seconds``. Design from #56832 (Vansh5632).
|
|
"""
|
|
return _parse_boolish(config.get("lazy", False), default=False)
|
|
|
|
|
|
def _note_connect_failure(name: str, exc: BaseException) -> str:
|
|
"""Record a failed connect (under ``_lock``): error text for status, cooldown stamp."""
|
|
message = _errors._format_connect_error(exc)
|
|
with _core._lock:
|
|
key = _server_key(name)
|
|
_core._server_connecting.discard(key)
|
|
_core._server_connect_errors[key] = message
|
|
_record_connect_failure(name)
|
|
return message
|
|
|
|
|
|
def _note_connect_success(name: str) -> None:
|
|
"""Clear connecting/error/cooldown state after a successful connect (under ``_lock``)."""
|
|
with _core._lock:
|
|
key = _server_key(name)
|
|
_core._server_connecting.discard(key)
|
|
_core._server_connect_errors.pop(key, None)
|
|
_clear_connect_failure(name)
|
|
|
|
|
|
def _adopt_server(name: str, server: _core.MCPServerTask) -> None:
|
|
"""Publish *server* into ``_servers`` under the connecting scope's key (under ``_lock``)."""
|
|
with _core._lock:
|
|
key = _server_key(name)
|
|
_core._servers[key] = server
|
|
_core._server_scope_keys[key] = _core._mcp_registry_scope()
|
|
|
|
|
|
def _ensure_lazy_server_connected(server_name: str) -> bool:
|
|
"""Connect a lazily-registered server on demand (sync; blocks). Honours the cooldown and the
|
|
``_server_connecting`` dedup set; routes through ``_discover_and_register_server`` so
|
|
park/recycle/cooldown bookkeeping stays in one place. True when a live session exists.
|
|
|
|
See #50394.
|
|
"""
|
|
with _core._lock:
|
|
key = _resolve_server_key(server_name)
|
|
server = _core._servers.get(key)
|
|
if server is not None and server.session is not None:
|
|
return True
|
|
config = _core._lazy_server_configs.get(key)
|
|
if (not config or _connect_cooldown_active(server_name)
|
|
or key in _core._server_connecting):
|
|
return False
|
|
_core._server_connecting.add(key)
|
|
_core._server_connect_errors.pop(key, None)
|
|
logger.info("MCP server '%s': lazy start on first use", server_name)
|
|
_loop._ensure_mcp_loop()
|
|
connect_timeout = config.get("connect_timeout", _core._DEFAULT_CONNECT_TIMEOUT)
|
|
try:
|
|
_loop._run_on_mcp_loop(lambda: _discover_and_register_server(server_name, config),
|
|
timeout=float(connect_timeout) + 30.0)
|
|
except BaseException as exc:
|
|
logger.warning("Lazy MCP connect failed for '%s': %s", server_name, _note_connect_failure(server_name, exc))
|
|
return False
|
|
_note_connect_success(server_name)
|
|
with _core._lock:
|
|
_core._lazy_server_configs.pop(key, None)
|
|
stale_fingerprint = _core._lazy_server_fingerprints.pop(key, None)
|
|
cached_names = _core._lazy_server_tool_names.pop(key, None) or []
|
|
server = _core._servers.get(key)
|
|
live_names = set(getattr(server, "_registered_tool_names", []) or [])
|
|
# The cached manifest may advertise tools the live server no longer serves.
|
|
phantom_names = [n for n in cached_names if n not in live_names]
|
|
if phantom_names:
|
|
for tool_name in phantom_names:
|
|
_registration._deregister_mcp_tool_all_scopes(key, tool_name)
|
|
logger.info("MCP server '%s': deregistered %d phantom cached tool(s) not served live (stale schema-cache "
|
|
"fingerprint %s): %s", server_name, len(phantom_names), stale_fingerprint, ", ".join(phantom_names))
|
|
return server is not None and server.session is not None
|
|
|
|
|
|
def _get_connected_server_for_call(server_name: str) -> Optional[_core.MCPServerTask]:
|
|
"""Return a connected server; the single first-use connect point for lazy servers and
|
|
the wake-up point for recycled stdio ones.
|
|
|
|
Also the single first-use connect point for lazy (schema-cache registered) servers, so raw tool calls
|
|
AND the resource/prompt utility handlers all trigger the deferred spawn (#56832).
|
|
"""
|
|
with _core._lock:
|
|
key = _resolve_server_key(server_name)
|
|
server = _core._servers.get(key)
|
|
is_lazy = key in _core._lazy_server_configs
|
|
if is_lazy and (server is None or server.session is None):
|
|
_ensure_lazy_server_connected(server_name)
|
|
elif server is not None and server.session is None and server._is_recycled_stdio():
|
|
_request_lazy_reconnect(server_name, server)
|
|
else:
|
|
return server
|
|
with _core._lock:
|
|
return _core._servers.get(key)
|
|
|
|
|
|
async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
|
"""Connect one server, register its tools; return the registered names."""
|
|
# The claim fires inside _connect_server while this frame is suspended (list, not nonlocal).
|
|
claimed: List[_core.MCPServerTask] = []
|
|
claim_token = _core._connect_server_claim.set(claimed.append)
|
|
try:
|
|
server = await asyncio.wait_for(_connect_server(name, config),
|
|
timeout=config.get("connect_timeout", _core._DEFAULT_CONNECT_TIMEOUT))
|
|
except BaseException:
|
|
server = claimed[0] if claimed else None
|
|
task = server._task if server is not None else None
|
|
task_cancelling = task.cancelling() if task is not None and hasattr(task, "cancelling") else 0
|
|
if (server is not None and server._error is not None and task is not None
|
|
and not task.done() and not task_cancelling):
|
|
# Recoverable park: the run task self-probes, so adopt it for shutdown/revival.
|
|
_adopt_server(name, server)
|
|
elif server is not None:
|
|
await server.shutdown()
|
|
raise
|
|
finally:
|
|
_core._connect_server_claim.reset(claim_token)
|
|
with _core._lock:
|
|
key = _server_key(name)
|
|
_core._server_connecting.discard(key)
|
|
_core._server_connect_errors.pop(key, None)
|
|
_adopt_server(name, server)
|
|
registered_names = _registration._register_server_tools(name, server, config)
|
|
server._registered_tool_names = list(registered_names)
|
|
logger.info("MCP server '%s' (%s): registered %d tool(s): %s", name,
|
|
"HTTP" if "url" in config else "stdio", len(registered_names), ", ".join(registered_names))
|
|
return registered_names
|
|
|
|
|
|
def _select_new_servers(servers: Dict[str, dict]) -> Dict[str, dict]:
|
|
"""Pick connect candidates (enabled, not connected/connecting/lazy, not in backoff) and
|
|
refresh per-server bookkeeping. Known servers without a live session are parked or
|
|
mid-reconnect with tools deregistered, so nothing else can nudge them: signal a reconnect."""
|
|
with _core._lock:
|
|
current_scope = _core._mcp_registry_scope()
|
|
# This scope's own connections OR shared ones it adopted (``register_connected_into_current_scope``
|
|
# ran first): a same-named server owned by ANOTHER profile with other credentials is not
|
|
# "connected" for us and must be a candidate, or this profile ends up silently tool-less.
|
|
keys = {k: _resolve_server_key(k, current_scope, current=False) for k in servers}
|
|
# Only attempt servers that aren't already connected (or currently connecting) and are enabled.
|
|
# Checking ``_server_connecting`` prevents duplicate subprocess spawns when ``discover_mcp_tools()``
|
|
# is called from multiple entry-points before the first batch finishes (#58862).
|
|
new_servers = {
|
|
k: v for k, v in servers.items()
|
|
if keys[k] not in _core._servers and keys[k] not in _core._server_connecting
|
|
and keys[k] not in _core._lazy_server_configs
|
|
and _enabled(v) and not _connect_cooldown_active(k)}
|
|
stale_cached = [_core._servers[keys[k]] for k, v in servers.items()
|
|
if keys[k] in _core._servers and _enabled(v)
|
|
and getattr(_core._servers[keys[k]], "session", None) is None]
|
|
for srv_name in new_servers:
|
|
_core._server_connecting.add(keys[srv_name])
|
|
_core._server_scope_keys[keys[srv_name]] = current_scope
|
|
_core._server_connect_errors.pop(keys[srv_name], None)
|
|
# Track which servers opt-in to parallel tool calls (idempotent).
|
|
for srv_name, srv_cfg in servers.items():
|
|
if _parse_boolish(srv_cfg.get("supports_parallel_tool_calls", False), default=False):
|
|
_core._parallel_safe_servers.add(srv_name)
|
|
else:
|
|
_core._parallel_safe_servers.discard(srv_name)
|
|
for srv in stale_cached:
|
|
_loop._signal_reconnect(srv)
|
|
return new_servers
|
|
|
|
|
|
def _register_lazy_from_cache(new_servers: Dict[str, dict]) -> Tuple[Dict[str, dict], int, int]:
|
|
"""Register ``lazy: true`` servers from a valid schema-cache entry without connecting
|
|
(missing/stale entry or failed registration -> eager). Returns (eager servers, lazy tool
|
|
count, lazy server count)."""
|
|
# A missing or stale cache entry falls back to the normal eager connect below (which write-through
|
|
# refreshes the cache for next time). See #56832.
|
|
eager_servers: Dict[str, dict] = dict(new_servers)
|
|
lazy_registered = 0
|
|
lazy_server_count = 0
|
|
try:
|
|
from tools.mcp_schema_cache import config_fingerprint, get_cached_entry
|
|
except Exception: # pragma: no cover - cache module missing
|
|
return eager_servers, 0, 0
|
|
for name, cfg in new_servers.items():
|
|
if not _resolve_server_lazy(name, cfg):
|
|
continue
|
|
entry = get_cached_entry(name, config_fingerprint(cfg))
|
|
if not entry:
|
|
continue
|
|
with _core._lock:
|
|
_core._server_connecting.discard(_server_key(name))
|
|
try:
|
|
names = _registration._register_from_cache_sync(name, cfg, entry)
|
|
except Exception as exc:
|
|
logger.warning("Failed lazy MCP registration for '%s': %s", name, exc)
|
|
with _core._lock:
|
|
_core._server_connecting.add(_server_key(name))
|
|
continue
|
|
eager_servers.pop(name, None)
|
|
lazy_registered += len(names)
|
|
lazy_server_count += 1
|
|
return eager_servers, lazy_registered, lazy_server_count
|
|
|
|
|
|
async def _discover_all(new_servers: Dict[str, dict]) -> None:
|
|
"""Connect every candidate concurrently; record per-server outcome."""
|
|
results = await asyncio.gather(
|
|
*(_discover_and_register_server(name, cfg) for name, cfg in new_servers.items()),
|
|
return_exceptions=True)
|
|
for name, result in zip(new_servers, results):
|
|
if isinstance(result, BaseException):
|
|
command = new_servers.get(name, {}).get("command")
|
|
message = _note_connect_failure(name, result)
|
|
logger.warning("Failed to connect to MCP server '%s'%s: %s",
|
|
name, f" (command={command})" if command else "", message)
|
|
else:
|
|
_note_connect_success(name)
|
|
|
|
|
|
def _run_discovery_pass(new_servers: Dict[str, dict]) -> None:
|
|
"""Run ``_discover_all`` on the MCP loop with the interrupt flag parked; clean up
|
|
``_server_connecting`` when the pass dies early."""
|
|
# Executor threads are reused: a prior session's stale interrupt must not cancel this pass.
|
|
from tools.interrupt import is_interrupted as _is_interrupted, set_interrupt as _set_interrupt
|
|
_was_interrupted = _is_interrupted()
|
|
if _was_interrupted:
|
|
_set_interrupt(False)
|
|
try:
|
|
_loop._run_on_mcp_loop(lambda: _discover_all(new_servers), timeout=120)
|
|
except (TimeoutError, InterruptedError) as _e:
|
|
# Stranded _server_connecting entries would block future reconnects.
|
|
how = "timed out" if isinstance(_e, TimeoutError) else "interrupted"
|
|
with _core._lock:
|
|
stale = [n for n in new_servers if _server_key(n) in _core._server_connecting]
|
|
if stale:
|
|
logger.warning("MCP discovery %s while %d server(s) were still connecting; clearing stale "
|
|
"connecting set: %s", how, len(stale), ", ".join(stale))
|
|
for _sn in stale:
|
|
_core._server_connecting.discard(_server_key(_sn))
|
|
_core._server_connect_errors.setdefault(
|
|
_server_key(_sn), f"Connection attempt {how} during discovery")
|
|
raise
|
|
finally:
|
|
if _was_interrupted:
|
|
_set_interrupt(True)
|
|
|
|
|
|
def _connected_summary(names, *, lazy_tools: int = 0, lazy_servers: int = 0) -> Tuple[int, int, int]:
|
|
"""(tool count, connected count, failed count) for candidate names, plus lazy servers."""
|
|
with _core._lock:
|
|
keys = {n: _server_key(n) for n in names}
|
|
connected = [n for n in names
|
|
if keys[n] in _core._servers and keys[n] not in _core._server_connect_errors]
|
|
tool_count = sum(len(getattr(_core._servers[keys[n]], "_registered_tool_names", [])) for n in connected)
|
|
failed = len(names) - len(connected)
|
|
return tool_count + lazy_tools, len(connected) + lazy_servers, failed
|
|
|
|
|
|
def _log_summary(prefix: str, names, **lazy) -> None:
|
|
"""Log ``<prefix> N tool(s) from M server(s) (K failed)`` when anything happened."""
|
|
new_tool_count, connected_count, failed = _connected_summary(names, **lazy)
|
|
if new_tool_count or failed:
|
|
summary = f"{prefix} {new_tool_count} tool(s) from {connected_count} server(s)"
|
|
if failed:
|
|
summary += f" ({failed} failed)"
|
|
logger.info(summary)
|
|
|
|
|
|
def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
|
"""Connect ``{name: config}`` servers and register their tools; idempotent for connected
|
|
names, ``enabled: false`` skipped without disconnecting. Returns every MCP tool name."""
|
|
if not _core._ensure_mcp_sdk():
|
|
logger.debug("MCP SDK not available -- skipping explicit MCP registration")
|
|
return []
|
|
servers = _config._filter_suspicious_mcp_servers(servers)
|
|
try:
|
|
return _register_mcp_servers(servers)
|
|
finally:
|
|
# An owner's scoped reload orphaned adopters of its shared connections: now that this
|
|
# pass (its rediscovery) is done, give them their tools back under their own scope.
|
|
_lifecycle._reregister_orphaned_adopters()
|
|
|
|
|
|
def _register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
|
scoped_healed = _registration.register_connected_into_current_scope(servers)
|
|
if not servers:
|
|
logger.debug("No explicit MCP servers provided")
|
|
return _registration._existing_tool_names() if _core._mcp_registry_scope() is not None else []
|
|
new_servers = _select_new_servers(servers)
|
|
if scoped_healed:
|
|
logger.info("MCP: registered %d already-connected server(s) into this profile scope", scoped_healed)
|
|
if not new_servers:
|
|
return _registration._existing_tool_names()
|
|
new_servers, lazy_registered, lazy_server_count = _register_lazy_from_cache(new_servers)
|
|
if not new_servers:
|
|
if lazy_registered:
|
|
logger.info("MCP: registered %d lazy tool(s) from schema cache (no processes spawned)",
|
|
lazy_registered)
|
|
return _registration._existing_tool_names()
|
|
_loop._ensure_mcp_loop()
|
|
_run_discovery_pass(new_servers)
|
|
_log_summary("MCP: registered", new_servers, lazy_tools=lazy_registered, lazy_servers=lazy_server_count)
|
|
return _registration._existing_tool_names()
|
|
|
|
|
|
def _acquire_discovery_lock_with_retry():
|
|
"""Cross-process guard: a lock loser waits for the holder then discovers itself; unavailable
|
|
locking or an expired wait runs unguarded (fail-soft). None / _LOCK_UNAVAILABLE = unguarded."""
|
|
cookie = _loop._try_acquire_mcp_discovery_lock()
|
|
if cookie is not None:
|
|
return cookie
|
|
logger.debug("Another process holds MCP discovery lock -- retrying with backoff")
|
|
for _ in range(_core._MCP_DISCOVERY_LOCK_MAX_RETRIES):
|
|
time.sleep(_core._MCP_DISCOVERY_LOCK_RETRY_DELAY_S)
|
|
cookie = _loop._try_acquire_mcp_discovery_lock()
|
|
if cookie is not None:
|
|
break
|
|
# Cross-process discovery guard (#62771). A lock loser waits for the holder, then performs its own
|
|
# process-local discovery. If locking is unavailable or the bounded wait expires, preserve the previous
|
|
# fail-soft behavior by running discovery unguarded.
|
|
if cookie is None:
|
|
logger.warning("MCP discovery lock still held after %d retries -- running discovery unguarded",
|
|
_core._MCP_DISCOVERY_LOCK_MAX_RETRIES)
|
|
elif cookie is not _core._LOCK_UNAVAILABLE:
|
|
logger.debug("Retry succeeded -- acquired MCP discovery lock")
|
|
return cookie
|
|
|
|
|
|
def discover_mcp_tools(allowed_mcp_names: Optional[List[str]] = None) -> List[str]:
|
|
"""Entry point: load config, connect servers, register tools. [] without the ``mcp``
|
|
package; idempotent (only servers missing from a previous call are retried).
|
|
|
|
``allowed_mcp_names``: spawn only the MCP servers named in it (built-in toolset names in the
|
|
list simply don't match); ``None`` spawns every configured server. Used by
|
|
``hermes -z -t <toolsets>`` to skip cold-starting servers the caller doesn't need (10-60s
|
|
each); it only affects which servers start, not which names ``-t`` validation can see."""
|
|
servers = _config._load_mcp_config()
|
|
if not servers:
|
|
logger.debug("No MCP servers configured")
|
|
return []
|
|
if allowed_mcp_names is not None:
|
|
allowed_set = {str(n) for n in allowed_mcp_names}
|
|
filtered = {name: cfg for name, cfg in servers.items() if name in allowed_set}
|
|
if len(filtered) != len(servers):
|
|
logger.debug("MCP discovery filter: spawning %d/%d configured server(s) per --toolsets filter "
|
|
"(skipped: %s)", len(filtered), len(servers), ",".join(sorted(set(servers) - set(filtered))))
|
|
servers = filtered
|
|
if not servers:
|
|
logger.debug("No MCP servers in --toolsets filter; skipping MCP load entirely")
|
|
return []
|
|
# SDK import deferred to here so a config without servers — or a -t filter that keeps
|
|
# none — never pays it.
|
|
if not _core._ensure_mcp_sdk():
|
|
logger.debug("MCP SDK not available -- skipping MCP tool discovery")
|
|
return []
|
|
cookie = _acquire_discovery_lock_with_retry()
|
|
try:
|
|
with _core._lock:
|
|
keys = {name: _resolve_server_key(name) for name in servers}
|
|
new_server_names = [name for name, cfg in servers.items()
|
|
if keys[name] not in _core._servers and keys[name] not in _core._server_connecting
|
|
and _enabled(cfg)]
|
|
tool_names = register_mcp_servers(servers)
|
|
if new_server_names:
|
|
_log_summary(" MCP:", new_server_names)
|
|
return tool_names
|
|
finally:
|
|
if cookie not in (None, _core._LOCK_UNAVAILABLE):
|
|
cookie.release()
|
|
|
|
|
|
def reconcile_mcp_servers_with_config() -> Dict[str, List[str]]:
|
|
"""Bring the live server set in step with ``mcp_servers`` as it is on disk NOW: tear down
|
|
servers that were removed from config or set ``enabled: false`` (a parked server keeps
|
|
self-probing forever otherwise — for hours after the user deleted its entry), then connect
|
|
anything newly configured via :func:`discover_mcp_tools`. Scoped to the current registry
|
|
scope (one multiplexed profile's config prunes only its own connections). A lazily registered
|
|
(schema-cache) server loses its cached tools; one still mid-connect cannot be torn down yet and
|
|
is reported under ``"pending"`` so the caller retries. Returns
|
|
``{"removed": [...], "added": [...], "pending": [...]}``; a no-op when nothing changed."""
|
|
servers = _config._load_mcp_config()
|
|
wanted = {name for name, cfg in servers.items() if _enabled(cfg)}
|
|
scope = _core._mcp_registry_scope()
|
|
with _core._lock:
|
|
owned = [key for key, owner in _core._server_scope_keys.items() if owner == scope]
|
|
live = {_key_name(key) for key in owned if key in _core._servers}
|
|
connecting = {_key_name(key) for key in owned if key in _core._server_connecting}
|
|
lazy = {key for key in _core._lazy_server_configs
|
|
if _key_scope(key) == scope and _key_name(key) not in wanted}
|
|
stale = sorted(live - wanted)
|
|
if stale:
|
|
logger.info("MCP server(s) %s no longer in config (or disabled); disconnecting", ", ".join(stale))
|
|
_lifecycle.shutdown_mcp_servers(scope=scope, names=set(stale))
|
|
for key in lazy:
|
|
_forget_lazy_server(key)
|
|
with _core._lock:
|
|
known = {_key_name(key) for key, owner in _core._server_scope_keys.items()
|
|
if owner == scope and (key in _core._servers or key in _core._server_connecting)}
|
|
known |= {_key_name(key) for key in _core._lazy_server_configs}
|
|
added = sorted(wanted - known)
|
|
if added:
|
|
discover_mcp_tools()
|
|
return {"removed": stale + sorted(_key_name(k) for k in lazy), "added": added,
|
|
"pending": sorted(connecting - wanted)}
|
|
|
|
|
|
def _forget_lazy_server(key) -> None:
|
|
"""Drop a schema-cache (lazy) registration whose config entry is gone: its cached tools would
|
|
otherwise stay callable and spawn the server on first use."""
|
|
with _core._lock:
|
|
_core._lazy_server_configs.pop(key, None)
|
|
_core._lazy_server_fingerprints.pop(key, None)
|
|
cached_names = _core._lazy_server_tool_names.pop(key, None) or []
|
|
for tool_name in cached_names:
|
|
_registration._deregister_mcp_tool_all_scopes(key, tool_name)
|
|
|
|
|
|
def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
|
"""True when the tool's server opted into ``supports_parallel_tool_calls`` (provenance
|
|
captured at registration, never the ambiguous ``mcp__{server}__{tool}`` shape)."""
|
|
if not tool_name.startswith(MCP_TOOL_NAME_PREFIX):
|
|
return False
|
|
with _core._lock:
|
|
server_name = _core._mcp_tool_server_names.get(tool_name)
|
|
return bool(server_name and server_name in _core._parallel_safe_servers)
|
|
|
|
|
|
def get_mcp_status(configured: Optional[Dict[str, dict]] = None, *, include_runtime: bool = True) -> List[dict]:
|
|
"""Per-server status dicts for banner/TUI: name, transport, tools, connected, disabled,
|
|
status (connected / disabled / connecting / failed / configured) and error for failed.
|
|
Reads cached runtime state only; never connects."""
|
|
configured = _config._load_mcp_config() if configured is None else dict(configured)
|
|
if not configured:
|
|
return []
|
|
current_scope = _core._mcp_registry_scope()
|
|
with _core._lock:
|
|
def visible(key) -> bool:
|
|
# Runtime state belongs to the profile that adopted it; under a multiplexer only that
|
|
# profile's view may show it, and ``include_runtime=False`` hides the launch profile's
|
|
# servers from a status read scoped to a different profile.
|
|
return include_runtime and _core._server_visible_in_scope(key, current_scope)
|
|
|
|
active_servers = {_key_name(k): s for k, s in _core._servers.items() if visible(k)}
|
|
connecting = {_key_name(k) for k in _core._server_connecting if visible(k)}
|
|
connect_errors = {_key_name(k): e for k, e in _core._server_connect_errors.items() if visible(k)}
|
|
|
|
result: List[dict] = []
|
|
for name, cfg in configured.items():
|
|
enabled = _enabled(cfg) # evaluated unconditionally: malformed values warn even when connected
|
|
server = active_servers.get(name)
|
|
live = server is not None and server.session is not None
|
|
status = ("connected" if live else "disabled" if not enabled else "connecting" if name in connecting
|
|
else "failed" if name in connect_errors else "configured")
|
|
entry = {"name": name, "transport": cfg.get("transport", "http") if "url" in cfg else "stdio",
|
|
"tools": 0, "connected": False, "disabled": status == "disabled", "status": status}
|
|
if live:
|
|
entry["connected"] = True
|
|
entry["tools"] = (len(server._registered_tool_names) if hasattr(server, "_registered_tool_names")
|
|
else len(server._tools))
|
|
if server._sampling:
|
|
entry["sampling"] = dict(server._sampling.metrics)
|
|
elif status == "failed":
|
|
entry["error"] = connect_errors[name]
|
|
result.append(entry)
|
|
return result
|
|
|
|
|
|
def probe_mcp_server_tools() -> Dict[str, List[tuple]]:
|
|
"""Connect each enabled server, list ``(tool_name, description)``, disconnect; nothing is
|
|
registered and failed servers are omitted."""
|
|
if not _core._ensure_mcp_sdk():
|
|
return {}
|
|
enabled = {k: v for k, v in (_config._load_mcp_config() or {}).items() if _enabled(v)}
|
|
if not enabled:
|
|
return {}
|
|
_loop._ensure_mcp_loop()
|
|
result: Dict[str, List[tuple]] = {}
|
|
probed_servers: List[_core.MCPServerTask] = []
|
|
|
|
async def _probe_all():
|
|
coros = [asyncio.wait_for(_connect_server(name, cfg),
|
|
timeout=cfg.get("connect_timeout", _core._DEFAULT_CONNECT_TIMEOUT))
|
|
for name, cfg in enabled.items()]
|
|
outcomes = await asyncio.gather(*coros, return_exceptions=True)
|
|
for name, outcome in zip(enabled, outcomes):
|
|
if isinstance(outcome, Exception):
|
|
logger.debug("Probe: failed to connect to '%s': %s", name, outcome)
|
|
continue
|
|
probed_servers.append(outcome)
|
|
result[name] = [(t.name, getattr(t, "description", "") or "") for t in outcome._tools]
|
|
await asyncio.gather(*(s.shutdown() for s in probed_servers), return_exceptions=True)
|
|
|
|
try:
|
|
_loop._run_on_mcp_loop(_probe_all, timeout=120)
|
|
except Exception as exc:
|
|
logger.debug("MCP probe failed: %s", exc)
|
|
finally:
|
|
_lifecycle._stop_mcp_loop_if_idle()
|
|
return result
|
|
|
|
|
|
def has_registered_mcp_tools() -> bool:
|
|
"""True if any MCP server has registered TOOLS (not merely connected), so the per-turn
|
|
refresh hook stays idle for zero-tool servers."""
|
|
with _core._lock:
|
|
return bool(_core._mcp_tool_server_names)
|
|
|
|
|
|
def get_registered_mcp_server_names() -> set:
|
|
"""Server names that registered at least one tool (live, filtered — not config.yaml)."""
|
|
with _core._lock:
|
|
return set(_core._mcp_tool_server_names.values())
|