refactor(tools): MCP group L docstring/comment compaction, >118-col fixes
This commit is contained in:
+11
-17
@@ -50,15 +50,13 @@ class HermesMCPOAuthProvider(HermesProviderMixin, *_SDK_BASES):
|
||||
|
||||
def __init__(self, *args: Any, server_name: str = "", preregistered: bool = False, **kwargs: Any):
|
||||
super().__init__(*args, **kwargs)
|
||||
# mcp 2.0 uses a task-owned anyio.Lock held across the yielded resource request (a
|
||||
# session-long GET blocks every POST; HTTPX may close the generator from another task).
|
||||
# A binary semaphore keeps mutual exclusion without task ownership.
|
||||
# mcp 2.0 uses a task-owned anyio.Lock held across the yielded resource request (a session-long GET blocks
|
||||
# every POST; HTTPX may close the generator from another task). A binary semaphore drops task ownership.
|
||||
import anyio
|
||||
self.context.lock = anyio.Semaphore(1, max_value=1)
|
||||
self._hermes_server_name = server_name
|
||||
self._hermes_home = ""
|
||||
# A config-supplied client_id rejected as invalid_client means the *config* is wrong —
|
||||
# re-registration can't help, so only dynamically-registered clients auto-heal.
|
||||
# A config-supplied client_id rejected as invalid_client means the *config* is wrong — only DCR clients auto-heal.
|
||||
self._hermes_preregistered = preregistered
|
||||
|
||||
def _hermes_storage(self):
|
||||
@@ -94,10 +92,9 @@ class HermesMCPOAuthProvider(HermesProviderMixin, *_SDK_BASES):
|
||||
self._log_nonfatal("pre-flight metadata discovery", exc)
|
||||
|
||||
async def _prefetch_oauth_metadata(self) -> None:
|
||||
"""Fetch PRM + ASM from the well-known endpoints before the first request, using the
|
||||
SDK's own URL builders/response handlers so we track whatever the pinned SDK expects."""
|
||||
# The SDK's httpx flavour, not Hermes' — mcp 2.0 builds on httpx2 and
|
||||
# `create_oauth_metadata_request` returns *its* Request objects.
|
||||
"""Fetch PRM + ASM from the well-known endpoints before the first request, via the SDK's own URL
|
||||
builders/response handlers so we track whatever the pinned SDK expects."""
|
||||
# The SDK's httpx flavour, not Hermes': `create_oauth_metadata_request` returns *its* (httpx2) Request objects.
|
||||
from tools.mcp_tool import sdk_httpx
|
||||
httpx = sdk_httpx()
|
||||
if httpx is None: # pragma: no cover — SDK import would have failed
|
||||
@@ -264,8 +261,8 @@ class MCPOAuthManager:
|
||||
self._inflight_tasks: set[asyncio.Task] = set()
|
||||
|
||||
def get_or_build_provider(self, server_name: str, server_url: str, oauth_config: Optional[dict]) -> Optional[Any]:
|
||||
"""Cached OAuth provider for ``server_name``, built on first use (rebuilt when
|
||||
``server_url`` changes). None if the MCP SDK's OAuth support is unavailable."""
|
||||
"""Cached OAuth provider for ``server_name``, built on first use (rebuilt when ``server_url`` changes);
|
||||
None if the MCP SDK's OAuth support is unavailable."""
|
||||
key = self._key(server_name)
|
||||
with self._entries_lock:
|
||||
entry = self._entries.get(key)
|
||||
@@ -306,8 +303,7 @@ class MCPOAuthManager:
|
||||
**build_provider_kwargs(cfg, storage, ssh_proxy_hint=False))
|
||||
|
||||
def remove(self, server_name: str, *, hermes_home: str | Path | None = None) -> _ProviderEntry | None:
|
||||
"""Evict the provider from cache AND delete tokens from disk (``hermes mcp remove``,
|
||||
and ``hermes mcp login`` during forced re-auth)."""
|
||||
"""Evict the provider from cache AND delete tokens from disk (``hermes mcp remove`` / forced re-auth)."""
|
||||
entry = self.evict(server_name, hermes_home=hermes_home)
|
||||
from tools.mcp_oauth import remove_oauth_tokens
|
||||
remove_oauth_tokens(server_name, hermes_home=hermes_home)
|
||||
@@ -327,8 +323,7 @@ class MCPOAuthManager:
|
||||
return self._entries.pop(self._key(server_name, hermes_home), None)
|
||||
|
||||
async def invalidate_if_disk_changed(self, server_name: str, *, hermes_home: str | Path | None = None) -> bool:
|
||||
"""Force the SDK provider to reload when the tokens file mtime changed; True if
|
||||
invalidated. A cron job writes fresh tokens and the next tool call picks them up."""
|
||||
"""Force the SDK provider to reload when the tokens file mtime changed (e.g. a cron refresh); True if so."""
|
||||
from tools.mcp_oauth import _get_token_dir, _safe_filename
|
||||
entry = self._entries.get(self._key(server_name, hermes_home))
|
||||
if entry is None or entry.provider is None:
|
||||
@@ -351,8 +346,7 @@ class MCPOAuthManager:
|
||||
"""Single recovery attempt behind *pending*; always clears the dedup slot."""
|
||||
can_refresh = False
|
||||
try:
|
||||
# Disk changed (external refresh)? Else: if the SDK can refresh in place, let the
|
||||
# caller retry (the httpx.Auth flow refreshes on the next request).
|
||||
# Disk changed (external refresh)? Else: if the SDK can refresh in place, let the caller retry.
|
||||
if await self.invalidate_if_disk_changed(server_name):
|
||||
can_refresh = True
|
||||
else:
|
||||
|
||||
@@ -55,8 +55,7 @@ def _write_stderr_log_header(server_name: str) -> None:
|
||||
# Env vars safe to pass to stdio subprocesses (no secrets).
|
||||
_SAFE_ENV_KEYS = frozenset({"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR"})
|
||||
|
||||
# Windows process/location vars needed by launcher-style tools (e.g. Docker Desktop's MCP plugin
|
||||
# discovery); none carry secrets.
|
||||
# Windows process/location vars needed by launcher-style tools (e.g. Docker Desktop's MCP plugin discovery).
|
||||
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
|
||||
"ALLUSERSPROFILE", "APPDATA", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)",
|
||||
"COMMONPROGRAMW6432", "COMPUTERNAME", "COMSPEC", "HOMEDRIVE", "HOMEPATH",
|
||||
@@ -109,8 +108,7 @@ def _build_safe_env(user_env: Optional[dict]) -> dict:
|
||||
|
||||
|
||||
def _which_with_config_pathext(command: str, path_arg, env: dict):
|
||||
"""``shutil.which`` retried under the config env's PATHEXT (Windows only):
|
||||
``which(path=...)`` uses the PARENT's PATHEXT, not the config env's."""
|
||||
"""``shutil.which`` retried under the config env's PATHEXT (Windows only; ``which`` uses the PARENT's)."""
|
||||
cfg_pathext = next((v for k, v in env.items() if k.upper() == "PATHEXT" and isinstance(v, str) and v.strip()), None)
|
||||
if not cfg_pathext or cfg_pathext == os.environ.get("PATHEXT"):
|
||||
return None
|
||||
@@ -126,8 +124,7 @@ def _which_with_config_pathext(command: str, path_arg, env: dict):
|
||||
|
||||
|
||||
def _node_fallback(command: str) -> str:
|
||||
"""Well-known Node install locations for bare ``npx``/``npm``/``node`` when PATH lookup
|
||||
failed; *command* unchanged when none is executable."""
|
||||
"""Well-known Node install locations for bare ``npx``/``npm``/``node``; *command* unchanged when none exists."""
|
||||
home = os.path.expanduser("~")
|
||||
hermes_home = os.path.expanduser(os.getenv("HERMES_HOME", os.path.join(home, ".hermes")))
|
||||
# /usr/local/bin: canonical Node location (from-source Linux, Hermes Docker image, Intel Homebrew),
|
||||
@@ -138,8 +135,7 @@ def _node_fallback(command: str) -> str:
|
||||
|
||||
|
||||
def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
|
||||
"""Resolve a stdio command against the exact subprocess env, mainly so bare
|
||||
``npx``/``npm``/``node`` work under a filtered PATH."""
|
||||
"""Resolve a stdio command against the exact subprocess env (bare ``npx``/``npm``/``node`` under a filtered PATH)."""
|
||||
resolved_command = os.path.expanduser(str(command).strip())
|
||||
resolved_env = dict(env or {})
|
||||
if os.sep not in resolved_command:
|
||||
@@ -189,8 +185,7 @@ def _interpolate_env_vars(value):
|
||||
return value
|
||||
|
||||
|
||||
# (server_name, dotted key path) pairs already warned about: config loads happen on every discovery
|
||||
# pass, so warn once per process.
|
||||
# (server_name, dotted key path) pairs already warned about: config loads repeat per discovery pass.
|
||||
_whitespace_warned: Set[Tuple[str, str]] = set()
|
||||
|
||||
|
||||
@@ -238,8 +233,7 @@ def _filter_suspicious_mcp_servers(servers: Dict[str, dict]) -> Dict[str, dict]:
|
||||
|
||||
|
||||
def _portable_mcp_servers(safe_servers: Dict[str, dict]) -> None:
|
||||
"""Merge plugin-provided (portable) MCP servers into *safe_servers*; native config wins
|
||||
on a name clash. Never raises."""
|
||||
"""Merge plugin-provided (portable) MCP servers into *safe_servers*; native config wins on a clash. Never raises."""
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
discover_plugins()
|
||||
@@ -254,8 +248,7 @@ def _portable_mcp_servers(safe_servers: Dict[str, dict]) -> None:
|
||||
|
||||
|
||||
def _load_mcp_config() -> Dict[str, dict]:
|
||||
"""Read ``mcp_servers`` from config.yaml as ``{name: config}`` (empty on error or in safe
|
||||
mode); ``${VAR}`` placeholders are interpolated after ``.env`` is loaded."""
|
||||
"""``mcp_servers`` from config.yaml as ``{name: config}`` (empty on error / safe mode), ``${VAR}`` interpolated."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from utils import env_var_enabled as _env_enabled
|
||||
|
||||
@@ -14,8 +14,7 @@ from tools.mcp_tool_common import _sanitize_error, _core
|
||||
|
||||
logger = logging.getLogger("tools.mcp_tool")
|
||||
|
||||
# Stateless (2026-07-28) servers reject a legacy ``initialize`` with
|
||||
# UnsupportedProtocolVersion (-32022) or plain method-not-found.
|
||||
# Stateless (2026-07-28) servers reject a legacy ``initialize`` with this or plain method-not-found.
|
||||
_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION = -32022
|
||||
|
||||
|
||||
@@ -44,8 +43,7 @@ def _is_method_not_found_error(exc: BaseException) -> bool:
|
||||
|
||||
|
||||
class InvalidMcpUrlError(ValueError):
|
||||
"""A remote MCP server's ``url`` is not parseable http(s):// — validated once at startup so we
|
||||
fail fast instead of burning the reconnect-backoff loop."""
|
||||
"""A remote MCP server's ``url`` is not parseable http(s):// — validated once at startup to fail fast."""
|
||||
|
||||
|
||||
class NonMcpEndpointError(ConnectionError):
|
||||
@@ -88,8 +86,8 @@ def _classify_mcp_failure(exc: BaseException) -> str:
|
||||
|
||||
|
||||
def _validate_remote_mcp_url(server_name: str, url: Any) -> str:
|
||||
"""The stripped URL if it is a valid http(s) URL; else InvalidMcpUrlError naming the server
|
||||
(non-string, other scheme — stdio servers use ``command`` — or empty host)."""
|
||||
"""The stripped URL if valid http(s); else InvalidMcpUrlError naming the server (non-string, other scheme —
|
||||
stdio servers use ``command`` — or empty host)."""
|
||||
def _bad(detail: str) -> InvalidMcpUrlError:
|
||||
return InvalidMcpUrlError(f"Invalid MCP URL for '{server_name}': {detail}")
|
||||
|
||||
@@ -286,8 +284,8 @@ _SESSION_EXPIRED_MARKERS: tuple = (
|
||||
"unknown session", "session terminated", "closedresourceerror", "closed resource",
|
||||
"transport is closed", "connection closed", "broken pipe", "end of file")
|
||||
|
||||
# Node budget for ``_is_session_expired_error`` (the visited set breaks cycles; this bounds acyclic
|
||||
# blow-ups). Well above ``sys.getrecursionlimit()`` so deep task-group nesting is fully scanned.
|
||||
# Node budget for ``_is_session_expired_error`` (the visited set breaks cycles; this bounds acyclic blow-ups).
|
||||
# Well above ``sys.getrecursionlimit()`` so deep task-group nesting is fully scanned.
|
||||
_EXC_TRAVERSAL_MAX_NODES = 10_000
|
||||
|
||||
|
||||
@@ -311,8 +309,7 @@ def _is_session_expired_error(exc: BaseException) -> bool:
|
||||
budget -= 1
|
||||
if isinstance(current, InterruptedError):
|
||||
return False
|
||||
# Messages vary across SDK versions/servers: a narrow allow-list of stable substrings avoids
|
||||
# false positives.
|
||||
# Messages vary across SDK versions/servers: a narrow allow-list of stable substrings avoids false positives.
|
||||
msg = str(current).lower()
|
||||
found = found or isinstance(current, transport_error_types) or any(m in msg for m in _SESSION_EXPIRED_MARKERS)
|
||||
stack.extend(getattr(current, "exceptions", ()))
|
||||
|
||||
+24
-27
@@ -21,14 +21,16 @@ from tools.mcp_tool_errors import _is_session_expired_error
|
||||
logger = logging.getLogger("tools.mcp_tool")
|
||||
_MISSING = object()
|
||||
|
||||
_NEEDS_REAUTH_MSG = ("MCP server '{s}' requires re-authentication. Run `hermes mcp login {s}` (or delete the tokens "
|
||||
"file under ~/.hermes/mcp-tokens/ and restart). Do NOT retry this tool — ask the user to re-authenticate.")
|
||||
_STDIO_NO_RESPAWN_MSG = ("MCP server '{s}' stdio subprocess had exited (this is not a timeout — the call never reached the "
|
||||
"server). A respawn was requested but no fresh session came back within {t:.0f}s. Wait a few "
|
||||
"seconds before retrying; if it keeps failing the server is not starting and needs the user.")
|
||||
_STDIO_DIED_AGAIN_MSG = ("MCP server '{s}' respawned its stdio subprocess and it exited again immediately. The server is not "
|
||||
"starting cleanly — do NOT retry this tool; ask the user to check the server's command and its "
|
||||
"stderr log.")
|
||||
_NEEDS_REAUTH_MSG = (
|
||||
"MCP server '{s}' requires re-authentication. Run `hermes mcp login {s}` (or delete the tokens file under "
|
||||
"~/.hermes/mcp-tokens/ and restart). Do NOT retry this tool — ask the user to re-authenticate.")
|
||||
_STDIO_NO_RESPAWN_MSG = (
|
||||
"MCP server '{s}' stdio subprocess had exited (this is not a timeout — the call never reached the server). A "
|
||||
"respawn was requested but no fresh session came back within {t:.0f}s. Wait a few seconds before retrying; if it "
|
||||
"keeps failing the server is not starting and needs the user.")
|
||||
_STDIO_DIED_AGAIN_MSG = (
|
||||
"MCP server '{s}' respawned its stdio subprocess and it exited again immediately. The server is not starting "
|
||||
"cleanly — do NOT retry this tool; ask the user to check the server's command and its stderr log.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- pre-call gates
|
||||
@@ -77,7 +79,8 @@ def _acquire_call_server(server_name: str, tool_timeout: float):
|
||||
server task to rebuild (probing a dead transport would re-arm the breaker forever)."""
|
||||
not_connected = tool_error(f"MCP server '{server_name}' is not connected")
|
||||
server = _core._get_connected_server_for_call(server_name)
|
||||
if server and (server.session or _core._wait_for_server_session_ready(server, timeout=min(5.0, float(tool_timeout or 5.0)))):
|
||||
wait = min(5.0, float(tool_timeout or 5.0))
|
||||
if server and (server.session or _core._wait_for_server_session_ready(server, timeout=wait)):
|
||||
return server, None
|
||||
_core._bump_server_error(server_name)
|
||||
if server and _core._signal_reconnect(server):
|
||||
@@ -151,8 +154,8 @@ def _handle_auth_error_and_retry(server_name: str, exc: BaseException, retry_cal
|
||||
recovered = False
|
||||
if recovered:
|
||||
srv = _lookup_reconnectable_server(server_name)
|
||||
# Recovery + reconnect is independent evidence of viability: close the breaker here, not
|
||||
# only on retry success (else a failing retry pins it open forever).
|
||||
# Recovery + reconnect is independent evidence of viability: close the breaker here, not only on
|
||||
# retry success (else a failing retry pins it open forever).
|
||||
if srv is not None and _core._signal_reconnect_and_wait(
|
||||
server_name, srv, op_description=f"{op_description} after OAuth recovery", timeout=15):
|
||||
_core._reset_server_error(server_name)
|
||||
@@ -298,15 +301,13 @@ async def _call_tool_racing_stdio_death(server, server_name: str, tool_name: str
|
||||
# ---------------------------------------------------------- result rendering
|
||||
|
||||
def _error_result_text(result) -> str:
|
||||
"""Concatenated text of an ``isError`` result's blocks (EmbeddedResource error payloads
|
||||
carry text under ``.resource.text``)."""
|
||||
"""Concatenated text of an ``isError`` result's blocks (EmbeddedResource payloads: ``.resource.text``)."""
|
||||
texts = (getattr(b, "text", None) or getattr(getattr(b, "resource", None), "text", None) for b in (result.content or []))
|
||||
return "".join(str(t) for t in texts if t)
|
||||
|
||||
|
||||
def _render_content_blocks(result, server_name: str) -> str:
|
||||
"""Text passes through; image/audio blocks are cached (MEDIA: tags); resource blocks are
|
||||
materialized rather than silently dropped."""
|
||||
"""Text passes through; image/audio blocks are cached (MEDIA: tags); resource blocks are materialized."""
|
||||
parts: List[str] = []
|
||||
for block in (result.content or []):
|
||||
if getattr(block, "text", None):
|
||||
@@ -316,9 +317,8 @@ def _render_content_blocks(result, server_name: str) -> str:
|
||||
if rendered:
|
||||
parts.append(rendered)
|
||||
continue
|
||||
# Benign empty renders log at debug; warn only for unknown shapes.
|
||||
block_type = getattr(block, "type", None) or type(block).__name__
|
||||
if block_type in {"text", "resource", "audio", "image"}:
|
||||
if block_type in {"text", "resource", "audio", "image"}: # benign empty render
|
||||
logger.debug("MCP %s: content block type %r rendered empty", server_name, block_type)
|
||||
else:
|
||||
logger.warning("MCP %s: dropping unsupported content block type %r", server_name, block_type)
|
||||
@@ -326,8 +326,7 @@ def _render_content_blocks(result, server_name: str) -> str:
|
||||
|
||||
|
||||
def _capped_structured_content(result):
|
||||
"""``structuredContent`` (or None); over the hard cap it degrades to the head+tail
|
||||
truncated JSON string (multi-MB JSON flood guard)."""
|
||||
"""``structuredContent`` (or None); over the hard cap it degrades to the truncated JSON string (flood guard)."""
|
||||
structured = mcp_field(result, "structured_content", "structuredContent")
|
||||
try:
|
||||
as_json = json.dumps(structured, ensure_ascii=False, default=str) if structured is not None else ""
|
||||
@@ -337,8 +336,8 @@ def _capped_structured_content(result):
|
||||
|
||||
|
||||
def _render_call_tool_result(result, server_name: str) -> str:
|
||||
"""Pure: ``CallToolResult`` -> handler JSON. ``content`` is primary; ``structuredContent``
|
||||
supplements it (or becomes ``result`` without text); ``_meta`` minus reserved keys."""
|
||||
"""Pure: ``CallToolResult`` -> handler JSON. ``content`` is primary; ``structuredContent`` supplements it (or
|
||||
becomes ``result`` without text); ``_meta`` minus reserved keys."""
|
||||
if mcp_field(result, "is_error", "isError", False):
|
||||
return tool_error(_sanitize_error(_truncate_mcp_text_result(_error_result_text(result) or "MCP tool returned an error")))
|
||||
text_result = _render_content_blocks(result, server_name)
|
||||
@@ -346,8 +345,7 @@ def _render_call_tool_result(result, server_name: str) -> str:
|
||||
meta = _strip_reserved_meta_keys(mcp_field(result, "meta", "meta"))
|
||||
if structured is None and meta is None:
|
||||
return json.dumps({"result": text_result}, ensure_ascii=False)
|
||||
# Key order is part of the output: "result" leads when there is text, otherwise "_meta"
|
||||
# precedes the (empty) "result".
|
||||
# Key order is part of the output: "result" leads when there is text, otherwise "_meta" precedes it.
|
||||
payload: Dict[str, Any] = {"result": text_result} if text_result else {}
|
||||
if structured is not None:
|
||||
payload["structuredContent" if text_result else "result"] = structured
|
||||
@@ -426,8 +424,8 @@ def _make_utility_handler(op: str, log_label: str, rpc, render, required: Option
|
||||
|
||||
|
||||
def _pick(obj, *specs) -> dict:
|
||||
"""``{out_key: value}`` for each ``(out_key, attr[, truthy])`` present on *obj* (``hasattr``
|
||||
so SDK models and stubs behave alike; ``truthy`` also skips falsy). Key order = spec order."""
|
||||
"""``{out_key: value}`` for each ``(out_key, attr[, truthy])`` present on *obj* (presence check so SDK models
|
||||
and stubs behave alike; ``truthy`` also skips falsy). Key order = spec order."""
|
||||
entry = {}
|
||||
for out_key, attr, *truthy in specs:
|
||||
value = getattr(obj, attr, _MISSING)
|
||||
@@ -497,8 +495,7 @@ _make_get_prompt_handler = _make_utility_handler(
|
||||
|
||||
|
||||
def _make_check_fn(server_name: str):
|
||||
"""Check function that verifies the MCP connection is alive. Lazy (schema-cache registered)
|
||||
servers count as available: the first real call spawns/connects them."""
|
||||
"""Connection-alive check; lazy (schema-cache registered) servers count as available."""
|
||||
def _check() -> bool:
|
||||
with _core._lock:
|
||||
server = _core._servers.get(server_name)
|
||||
|
||||
@@ -38,8 +38,7 @@ class MCPServerHealthMixin:
|
||||
self._recycled_reason = None
|
||||
|
||||
def _stdio_recycle_deadlines(self):
|
||||
"""``[(deadline, reason), ...]`` for the configured lifetime/idle limits; empty for HTTP
|
||||
servers or while an RPC holds the lock."""
|
||||
"""``[(deadline, reason), ...]`` for the lifetime/idle limits; empty for HTTP or while an RPC holds the lock."""
|
||||
if self._is_http() or self._rpc_lock.locked():
|
||||
return []
|
||||
limits = ((self._lifecycle_started_at, self._max_lifetime_seconds, "max_lifetime_seconds"),
|
||||
@@ -74,8 +73,7 @@ class MCPServerHealthMixin:
|
||||
return task
|
||||
|
||||
def _make_logging_callback(self):
|
||||
"""``logging_callback`` forwarding server ``notifications/message`` into Hermes logging
|
||||
tagged with the server name (the SDK default drops them)."""
|
||||
"""``logging_callback`` forwarding server ``notifications/message`` into Hermes logging (SDK default drops them)."""
|
||||
async def _on_log(params):
|
||||
try:
|
||||
level = _core._MCP_LOG_LEVEL_MAP.get(str(getattr(params, "level", "info")).lower(), logging.INFO)
|
||||
@@ -88,14 +86,14 @@ class MCPServerHealthMixin:
|
||||
if len(data) > 2000: # cap payloads so a chatty server can't flood agent.log
|
||||
data = data[:2000] + "... [truncated]"
|
||||
logger_name = getattr(params, "logger", None)
|
||||
logger.log(level, "MCP server log [%s]: %s", f"{self.name}/{logger_name}" if logger_name else self.name, data)
|
||||
origin = f"{self.name}/{logger_name}" if logger_name else self.name
|
||||
logger.log(level, "MCP server log [%s]: %s", origin, data)
|
||||
except Exception:
|
||||
logger.debug("Failed to handle MCP log notification from '%s'", self.name, exc_info=True)
|
||||
return _on_log
|
||||
|
||||
def _make_message_handler(self):
|
||||
"""``message_handler`` for ``ClientSession``: only ``ToolListChangedNotification`` triggers
|
||||
a refresh; prompt/resource changes are logged."""
|
||||
"""``message_handler``: only ``ToolListChangedNotification`` triggers a refresh; prompt/resource changes log."""
|
||||
async def _handler(message):
|
||||
try:
|
||||
if isinstance(message, Exception):
|
||||
@@ -121,8 +119,7 @@ class MCPServerHealthMixin:
|
||||
return _handler
|
||||
|
||||
def _deregister_owned(self, tool_names: Iterable[str]) -> None:
|
||||
"""Deregister *tool_names* this server's toolset still owns. Never removes a colliding
|
||||
name currently owned by another server."""
|
||||
"""Deregister *tool_names* this server's toolset still owns (never a colliding name owned by another server)."""
|
||||
from tools.registry import registry
|
||||
for tool_name in tool_names:
|
||||
if registry.get_toolset_for_tool(tool_name) == f"mcp-{self.name}":
|
||||
@@ -206,8 +203,7 @@ class MCPServerHealthMixin:
|
||||
self._permanent_grace_used = self._teardown_race = False
|
||||
|
||||
def mark_suspect(self, reason: str) -> None:
|
||||
"""Latch a suspicion (no I/O). The NEXT call verifies via :meth:`ensure_healthy` and
|
||||
recycles the transport if the probe fails."""
|
||||
"""Latch a suspicion (no I/O); the NEXT call verifies via :meth:`ensure_healthy` and recycles on failure."""
|
||||
if self._suspect_reason is None and reason:
|
||||
logger.warning("MCP server '%s': connection marked suspect (%s); next call will health-check it",
|
||||
self.name, reason)
|
||||
@@ -269,7 +265,6 @@ class MCPServerHealthMixin:
|
||||
return False
|
||||
|
||||
async def _watch_stdio_children(self) -> None:
|
||||
"""Poll child liveness while a stdio RPC is in flight; resolves when a tracked child dies
|
||||
so the caller cancels the RPC instead of waiting out the timeout."""
|
||||
"""Poll child liveness during a stdio RPC; resolves when a tracked child dies so the caller cancels the RPC."""
|
||||
while not self._stdio_children_dead():
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
@@ -27,20 +27,19 @@ _UTILITY_HANDLER_FACTORIES = {
|
||||
|
||||
|
||||
def _normalize_server_trust(value: Any) -> str:
|
||||
"""Config ``trust`` -> tier. None -> ``full`` (backward-compatible default); an
|
||||
unrecognized string -> ``untrusted`` so a misspelled tier fails closed."""
|
||||
"""Config ``trust`` -> tier. None -> ``full`` (compat default); unrecognized -> ``untrusted`` (fail closed)."""
|
||||
if value is None:
|
||||
return _core._TRUST_FULL
|
||||
text = str(value).strip().lower()
|
||||
if text in (_core._TRUST_FULL, _core._TRUST_UNTRUSTED):
|
||||
return text
|
||||
logger.warning("MCP trust: unrecognized trust value %r — treating as 'untrusted' (valid values: full, untrusted)", value)
|
||||
logger.warning("MCP trust: unrecognized trust value %r — treating as 'untrusted' (valid values: full, untrusted)",
|
||||
value)
|
||||
return _core._TRUST_UNTRUSTED
|
||||
|
||||
|
||||
def _annotation_read_only_hint(mcp_tool: Any) -> bool:
|
||||
"""True only when annotations (SDK object or schema-cache dict) carry ``readOnlyHint is
|
||||
True``; unknown metadata means write-capable."""
|
||||
"""True only when annotations (SDK object or cache dict) carry ``readOnlyHint is True``; unknown = write-capable."""
|
||||
annotations = getattr(mcp_tool, "annotations", None)
|
||||
hint = annotations.get("readOnlyHint") if isinstance(annotations, dict) else getattr(annotations, "readOnlyHint", None)
|
||||
return hint is True
|
||||
@@ -81,7 +80,9 @@ def _select_utility_schemas(server_name: str, server: "MCPServerTask", config: d
|
||||
if not enabled[family]:
|
||||
return f"{family} disabled"
|
||||
if advertised is not None:
|
||||
return None if getattr(advertised, family, None) is not None else f"server does not advertise '{family}' capability"
|
||||
if getattr(advertised, family, None) is None:
|
||||
return f"server does not advertise '{family}' capability"
|
||||
return None
|
||||
# Legacy gate (no initialize_result): the ClientSession method shares the handler key.
|
||||
return None if hasattr(server.session, handler_key) else f"session lacks {handler_key}"
|
||||
|
||||
@@ -96,8 +97,7 @@ def _select_utility_schemas(server_name: str, server: "MCPServerTask", config: d
|
||||
|
||||
|
||||
def _existing_tool_names() -> List[str]:
|
||||
"""Tool names for all currently connected servers plus lazy (cache-registered) servers,
|
||||
whose tools live only in the registry."""
|
||||
"""Tool names for all connected servers plus lazy (cache-registered) servers, whose tools live only in the registry."""
|
||||
names: List[str] = []
|
||||
for server in _core._servers.values():
|
||||
names.extend(server._registered_tool_names if hasattr(server, "_registered_tool_names")
|
||||
@@ -131,8 +131,7 @@ def _cached_tools(raws: Iterable[Any]) -> List[SimpleNamespace]:
|
||||
|
||||
@dataclass
|
||||
class _Candidate:
|
||||
"""One registration attempt: a native tool or a generated utility. ``origin`` is the
|
||||
provenance text used in collision diagnostics."""
|
||||
"""One registration attempt (native tool or generated utility); ``origin`` is the provenance text in diagnostics."""
|
||||
|
||||
registry_name: str
|
||||
origin: str
|
||||
@@ -155,7 +154,8 @@ def _tool_candidates(name: str, tools: Iterable[Any], should_register: Callable[
|
||||
continue
|
||||
_core._scan_mcp_description(name, t.name, t.description or "")
|
||||
schema = _core._convert_mcp_schema(name, t)
|
||||
out.append(_Candidate(schema["name"], f"tool {t.name!r}", schema, _core._make_tool_handler(name, t.name, tool_timeout)))
|
||||
handler = _core._make_tool_handler(name, t.name, tool_timeout)
|
||||
out.append(_Candidate(schema["name"], f"tool {t.name!r}", schema, handler))
|
||||
return out
|
||||
|
||||
|
||||
@@ -247,8 +247,7 @@ def _register_candidates(name: str, candidates: List[_Candidate], *, check_fn: C
|
||||
|
||||
|
||||
def _write_schema_cache(name: str, server: "MCPServerTask", config: dict, should_register) -> None:
|
||||
"""Write-through: persist the manifest so the next startup can register this server
|
||||
lazily without spawning it. Never raises."""
|
||||
"""Write-through: persist the manifest so the next startup registers this server lazily (no spawn). Never raises."""
|
||||
try:
|
||||
from tools.mcp_schema_cache import config_fingerprint, write_cache_entry
|
||||
tools_payload = [{
|
||||
|
||||
@@ -13,8 +13,7 @@ from tools.mcp_tool_common import _core
|
||||
|
||||
logger = logging.getLogger("tools.mcp_tool")
|
||||
|
||||
# JSON-RPC ``initialize`` body used by the content-type preflight POST.
|
||||
_PROBE_INITIALIZE_BODY = (
|
||||
_PROBE_INITIALIZE_BODY = ( # JSON-RPC ``initialize`` body for the content-type preflight POST
|
||||
'{"jsonrpc":"2.0","id":"_probe","method":"initialize","params":{"protocolVersion":"2025-03-26",'
|
||||
'"capabilities":{},"clientInfo":{"name":"hermes-probe","version":"0.1"}}}')
|
||||
|
||||
@@ -43,8 +42,8 @@ def _pgroup_alive(pgid: Optional[int]) -> bool:
|
||||
|
||||
|
||||
async def _osv_malware_preflight(server_name: str, command: str, args: list) -> None:
|
||||
"""OSV malware preflight, off-loop with a wall-clock bound (fail-open on timeout). Must run on
|
||||
the REAL command/args — the watchdog wrap rewrites argv to the supervisor (check becomes a no-op)."""
|
||||
"""OSV malware preflight, off-loop with a wall-clock bound (fail-open on timeout). Must run on the REAL
|
||||
command/args — the watchdog wrap rewrites argv to the supervisor (check becomes a no-op)."""
|
||||
from tools.osv_check import check_package_for_malware
|
||||
try:
|
||||
malware_error = await asyncio.wait_for(
|
||||
@@ -207,7 +206,8 @@ class MCPServerTransportMixin:
|
||||
# Subprocess stderr goes to ~/.hermes/logs/mcp-stderr.log so banners can't corrupt the TUI.
|
||||
_core._write_stderr_log_header(self.name)
|
||||
try:
|
||||
async with _core.stdio_client(server_params, errlog=_core._get_mcp_stderr_log()) as (read_stream, write_stream):
|
||||
errlog = _core._get_mcp_stderr_log()
|
||||
async with _core.stdio_client(server_params, errlog=errlog) as (read_stream, write_stream):
|
||||
# New PIDs for force-kill cleanup, minus non-MCP children (slash_worker, LSP) racing
|
||||
# into the window: they share the TUI's pgid — leaking them would killpg() the TUI.
|
||||
new_pids = _filter_mcp_children(_core._snapshot_child_pids() - pids_before)
|
||||
@@ -239,8 +239,7 @@ class MCPServerTransportMixin:
|
||||
return # No httpx → skip probe; SDK import would have failed first.
|
||||
|
||||
def _non_mcp_2xx(resp) -> bool:
|
||||
# Only judge 2xx (4xx/5xx may be an auth challenge the handshake handles); no content
|
||||
# type advertised → don't second-guess the SDK.
|
||||
# Only judge 2xx (4xx/5xx may be an auth challenge); no content type advertised → trust the SDK.
|
||||
ct = _content_type_base(resp)
|
||||
return _is_2xx(resp) and bool(ct) and ct not in self._MCP_CONTENT_TYPES
|
||||
|
||||
@@ -248,8 +247,7 @@ class MCPServerTransportMixin:
|
||||
try:
|
||||
async with _httpx.AsyncClient(verify=ssl_verify, follow_redirects=True, timeout=_httpx.Timeout(timeout),
|
||||
**_present(cert=client_cert)) as client:
|
||||
# HEAD is cheapest; fall back to GET on 405/501.
|
||||
resp = await client.head(url, headers=probe_headers)
|
||||
resp = await client.head(url, headers=probe_headers) # cheapest; GET on 405/501
|
||||
if resp.status_code in (405, 501):
|
||||
resp = await client.get(url, headers=probe_headers)
|
||||
# Non-MCP content type on HEAD/GET: try a JSON-RPC POST so POST-only servers pass.
|
||||
|
||||
Reference in New Issue
Block a user