Files
hermes-agent/tools/mcp_tool_common.py
Austin Pickett 79dbb1450e fix(tools): make managed Node authoritative in the MCP stdio PATH
`_prepend_path` inserted the resolved command's directory only when it was
absent from the child's PATH. The Hermes installer appends its managed Node
dir to the user PATH, so for anyone with a system Node (<22.12) earlier on
PATH the check no-oped and the managed dir stayed behind it. npm lifecycle
children (`node install.js`) then resolved the older system Node and failed
with ERR_REQUIRE_ESM even though Hermes had provisioned a compatible runtime.

Strip every existing case/trailing-separator variant of the directory first,
then prepend it, so the canonical entry is the one that wins and PATH does
not grow duplicates.

Fixes #82309
2026-09-27 01:05:37 -04:00

181 lines
7.0 KiB
Python

"""Small pure helpers shared by the tools.mcp_tool_* modules: SDK 1.x/2.x field access,
error-text sanitising, numeric/bool coercion, timeouts and jitter. No origin state."""
import logging
import math
import ntpath
import os
import random
import re
import sys
from typing import Any, Optional
logger = logging.getLogger("tools.mcp_tool")
class _OriginProxy:
"""Attribute proxy for ``tools.mcp_tool`` resolved at access time. The split modules read
origin state (``_servers``, ``_lock``, SDK symbols, patchable helpers) through this so
``mock.patch("tools.mcp_tool.X")`` and origin-side rebinds stay effective, and so no split
module needs the origin imported first (the origin imports them while initialising)."""
__slots__ = ()
def __getattr__(self, name: str):
from tools import mcp_tool
return getattr(mcp_tool, name)
_core = _OriginProxy()
_MISSING = object()
def mcp_field(obj, snake: str, camel: str, default=None):
"""Read an MCP model field across the 1.x -> 2.x rename to snake_case. Pydantic aliases
don't apply to attribute access, so ``getattr(result, "isError", False)`` silently returns
the default on 2.x — failed calls read as successful, schemas as empty."""
value = getattr(obj, snake, _MISSING)
if value is _MISSING:
value = getattr(obj, camel, _MISSING)
return default if value is _MISSING else value
_DEFAULT_TOOL_TIMEOUT = 300 # seconds for tool calls
def _resolve_tool_timeout(config: dict) -> float:
"""Per-server tool-call timeout. Precedence: ``mcp_servers.<name>.timeout`` >
``timeouts.mcp.tool_call`` > the 300s default; values are platform-clamped by
``resolve_timeout``."""
per_server = config.get("timeout")
if per_server is not None:
return per_server
try:
from agent.deadline import resolve_timeout
resolved = resolve_timeout("mcp.tool_call", default=_DEFAULT_TOOL_TIMEOUT)
if resolved is not None:
return resolved
except Exception:
logger.debug("mcp.tool_call timeout resolution failed", exc_info=True)
return _DEFAULT_TOOL_TIMEOUT
# Jitter on reconnect backoff so servers that lost the same backend don't retry in lockstep.
_BACKOFF_JITTER = 0.2 # +/-20%
def _jittered(seconds: float) -> float:
"""``seconds`` with +/-20% uniform jitter, floored at 0."""
return max(0.0, seconds * random.uniform(1.0 - _BACKOFF_JITTER, 1.0 + _BACKOFF_JITTER))
# Credential patterns to strip from error messages: GitHub PAT, OpenAI-style key, Bearer token,
# and ``token= / key= / API_KEY= / password= / secret=`` assignments.
_CREDENTIAL_PATTERN = re.compile(
r"(?:ghp_[A-Za-z0-9_]{1,255}|sk-[A-Za-z0-9_-](?:\.?[A-Za-z0-9_-]){0,254}|Bearer\s+\S+"
r"|(?:token|key|API_KEY|password|secret)=[^\s&,;\"']{1,255})", re.IGNORECASE)
def _env_ref_name(ref: str) -> str:
"""Bare env-var name from a ``${...}`` body; strips a Cursor-style ``env:`` prefix."""
ref = ref.strip()
if ref.startswith("env:"):
ref = ref[len("env:"):].strip()
return ref
def _sanitize_error(text: str) -> str:
"""Replace credential-like patterns with [REDACTED] before text reaches the LLM."""
return _CREDENTIAL_PATTERN.sub("[REDACTED]", text)
def _exc_str(exc: BaseException) -> str:
"""Non-empty string for *exc*: some exceptions (``anyio.ClosedResourceError``) carry no
message, so fall back to ``repr`` to keep diagnostics."""
text = str(exc).strip()
return text or repr(exc)
def _path_comparison_key(entry: str) -> str:
"""Normalize a PATH entry under the active platform's path semantics, so
``C:\\Node\\`` and ``c:\\node`` compare equal on Windows and stay distinct on POSIX."""
path_module = ntpath if sys.platform == "win32" else os.path
return path_module.normcase(path_module.normpath(entry))
def _prepend_path(env: dict, directory: str) -> dict:
"""Make *directory* the FIRST PATH entry, collapsing existing variants of it.
Prepending only when *directory* was absent left a directory that is already
on PATH — the Hermes installer appends its managed Node dir — behind an older
system Node. npm lifecycle children (`node install.js`) then resolve the
system Node through PATH and die with ERR_REQUIRE_ESM (#82309). Every
existing case/trailing-separator variant is stripped first, so this entry is
the one that wins and PATH does not grow duplicates.
"""
updated = dict(env or {})
if directory:
parts = [part for part in updated.get("PATH", "").split(os.pathsep) if part]
key = _path_comparison_key(directory)
remaining = [part for part in parts if _path_comparison_key(part) != key]
updated["PATH"] = os.pathsep.join([directory, *remaining])
return updated
def _safe_numeric(value, default, coerce=int, minimum=1):
"""Coerce a config value (YAML strings included) to a number, clamped to *minimum*;
*default* on failure or non-finite floats."""
try:
result = coerce(value)
if isinstance(result, float) and not math.isfinite(result):
return default
return max(result, minimum)
except (TypeError, ValueError, OverflowError):
return default
_TRUE_WORDS = frozenset({"true", "1", "yes", "on"})
_FALSE_WORDS = frozenset({"false", "0", "no", "off"})
def _parse_boolish(value: Any, default: bool = True) -> bool:
"""Parse a bool-like config value with safe fallback (YAML ``0``/``1`` are numbers, not words)."""
if value is None:
return default
if isinstance(value, (bool, int, float)):
return bool(value)
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in _TRUE_WORDS:
return True
if lowered in _FALSE_WORDS:
return False
logger.warning("MCP config expected a boolean-ish value, got %r; using default=%s", value, default)
return default
def mcp_server_enabled(cfg: dict) -> bool:
"""Whether ``mcp_servers.<name>`` is on. The ONE reader of the ``enabled`` key: the MCP client,
the toolset resolver, the profile editor, and every list/status surface call it, so a value
can never be on for one surface and off for another. Absent, ``null`` or unparseable = on."""
return _parse_boolish(cfg.get("enabled", True), default=True)
def _get_lifecycle_seconds(config: dict, key: str) -> Optional[float]:
"""Optional positive lifecycle timeout from top-level/nested ``lifecycle`` config (``0``
disables; negatives and non-numbers are warned about and ignored)."""
raw = config.get(key)
if raw is None and isinstance(config.get("lifecycle"), dict):
raw = config["lifecycle"].get(key)
if raw is None:
return None
try:
seconds = float(raw)
except (TypeError, ValueError):
logger.warning("MCP config %s must be a number of seconds; ignoring %r", key, raw)
return None
if seconds < 0:
logger.warning("MCP config %s must be positive; ignoring %r", key, raw)
return None
return seconds or None