diff --git a/tools/mcp_oauth_manager.py b/tools/mcp_oauth_manager.py index f5cb5f52ea..7e3dca6af3 100644 --- a/tools/mcp_oauth_manager.py +++ b/tools/mcp_oauth_manager.py @@ -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: diff --git a/tools/mcp_tool_config.py b/tools/mcp_tool_config.py index 34ae1a716b..16491814d3 100644 --- a/tools/mcp_tool_config.py +++ b/tools/mcp_tool_config.py @@ -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 diff --git a/tools/mcp_tool_errors.py b/tools/mcp_tool_errors.py index e56bd4cc88..74573d046b 100644 --- a/tools/mcp_tool_errors.py +++ b/tools/mcp_tool_errors.py @@ -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", ())) diff --git a/tools/mcp_tool_handlers.py b/tools/mcp_tool_handlers.py index 69b5c40341..51ee49393a 100644 --- a/tools/mcp_tool_handlers.py +++ b/tools/mcp_tool_handlers.py @@ -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) diff --git a/tools/mcp_tool_health.py b/tools/mcp_tool_health.py index 93d8576af3..14b7f1962f 100644 --- a/tools/mcp_tool_health.py +++ b/tools/mcp_tool_health.py @@ -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) diff --git a/tools/mcp_tool_registration.py b/tools/mcp_tool_registration.py index 1984422109..18e4c82092 100644 --- a/tools/mcp_tool_registration.py +++ b/tools/mcp_tool_registration.py @@ -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 = [{ diff --git a/tools/mcp_tool_transport.py b/tools/mcp_tool_transport.py index 1d16c998f7..b523c0fb1f 100644 --- a/tools/mcp_tool_transport.py +++ b/tools/mcp_tool_transport.py @@ -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.