`_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
181 lines
7.0 KiB
Python
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
|