383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""MCP server config loading and stdio launch environment: ${VAR}/Cursor-style
|
|
interpolation, hidden-whitespace and suspicious-entry filtering, the filtered
|
|
subprocess env, command resolution, watchdog wrapping and the shared stderr log."""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
from typing import Callable
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
from tools.mcp_tool_common import _env_ref_name, _prepend_path, _core
|
|
|
|
logger = logging.getLogger("tools.mcp_tool")
|
|
|
|
|
|
_mcp_stderr_log_fh: Optional[Any] = None
|
|
|
|
|
|
_mcp_stderr_log_lock = threading.Lock()
|
|
|
|
|
|
def _get_mcp_stderr_log() -> Any:
|
|
"""Shared append-mode handle for MCP subprocess stderr, opened once per process.
|
|
|
|
Must expose a real fd (``fileno()``) because asyncio wires the child's stderr
|
|
directly to it. Falls back to ``/dev/null``, then the real stderr.
|
|
"""
|
|
global _mcp_stderr_log_fh
|
|
with _mcp_stderr_log_lock:
|
|
if _mcp_stderr_log_fh is not None:
|
|
return _mcp_stderr_log_fh
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
log_dir = get_hermes_home() / "logs"
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
log_path = log_dir / "mcp-stderr.log"
|
|
# Line-buffered so output lands promptly; errors="replace" tolerates
|
|
# garbled binary from misbehaving servers.
|
|
fh = open(log_path, "a", encoding="utf-8", errors="replace", buffering=1)
|
|
fh.fileno() # confirm a real fd before committing
|
|
_mcp_stderr_log_fh = fh
|
|
except Exception as exc: # pragma: no cover — best-effort fallback
|
|
logger.debug("Failed to open MCP stderr log, using devnull: %s", exc)
|
|
try:
|
|
_mcp_stderr_log_fh = open(os.devnull, "w", encoding="utf-8")
|
|
except Exception:
|
|
_mcp_stderr_log_fh = sys.stderr
|
|
return _mcp_stderr_log_fh
|
|
|
|
|
|
def _write_stderr_log_header(server_name: str) -> None:
|
|
"""Write a session marker so operators can find each server's output in the
|
|
shared log without per-line prefixes (which would need a pipe + reader thread)."""
|
|
fh = _core._get_mcp_stderr_log()
|
|
try:
|
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
fh.write(f"\n===== [{ts}] starting MCP server '{server_name}' =====\n")
|
|
fh.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Env vars safe to pass to stdio subprocesses (no secrets).
|
|
_SAFE_ENV_KEYS = frozenset({
|
|
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
|
|
})
|
|
|
|
|
|
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
|
|
# Windows process/location vars needed by launcher-style tools (e.g.
|
|
# Docker Desktop's MCP plugin discovery); none carry secrets.
|
|
"ALLUSERSPROFILE",
|
|
"APPDATA",
|
|
"COMMONPROGRAMFILES",
|
|
"COMMONPROGRAMFILES(X86)",
|
|
"COMMONPROGRAMW6432",
|
|
"COMPUTERNAME",
|
|
"COMSPEC",
|
|
"HOMEDRIVE",
|
|
"HOMEPATH",
|
|
"LOCALAPPDATA",
|
|
"NUMBER_OF_PROCESSORS",
|
|
"OS",
|
|
"PATHEXT",
|
|
"PROCESSOR_ARCHITECTURE",
|
|
"PROGRAMDATA",
|
|
"PROGRAMFILES",
|
|
"PROGRAMFILES(X86)",
|
|
"PROGRAMW6432",
|
|
"PUBLIC",
|
|
"SYSTEMDRIVE",
|
|
"SYSTEMROOT",
|
|
"TEMP",
|
|
"TMP",
|
|
"USERDOMAIN",
|
|
"USERNAME",
|
|
"USERPROFILE",
|
|
"WINDIR",
|
|
})
|
|
|
|
|
|
# ${VAR_NAME} interpolation; any non-} chars allowed so MY-VAR / my.var work.
|
|
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")
|
|
|
|
|
|
def _workspace_folder() -> str:
|
|
"""Absolute workspace root for ``${workspaceFolder}``: the session's
|
|
authoritative root (terminal cwd / task override / $TERMINAL_CWD), else cwd."""
|
|
try:
|
|
from tools.file_tools import _authoritative_workspace_root
|
|
|
|
root = _authoritative_workspace_root()
|
|
if root:
|
|
return root
|
|
except Exception:
|
|
pass
|
|
return os.getcwd()
|
|
|
|
|
|
def _context_var_value(ref: str) -> Optional[str]:
|
|
"""Resolve Cursor's case-sensitive context vars (``userHome``,
|
|
``workspaceFolder``, ``workspaceFolderBasename``, ``pathSeparator``/``/``).
|
|
Returns None for anything else so it falls through to env-var lookup."""
|
|
if ref == "userHome":
|
|
return os.path.expanduser("~")
|
|
if ref == "workspaceFolder":
|
|
return _core._workspace_folder()
|
|
if ref == "workspaceFolderBasename":
|
|
root = _core._workspace_folder()
|
|
return os.path.basename(root.rstrip("/\\")) or root
|
|
if ref in ("pathSeparator", "/"):
|
|
return os.sep
|
|
return None
|
|
|
|
|
|
def _build_safe_env(user_env: Optional[dict]) -> dict:
|
|
"""Filtered env for stdio subprocesses so API keys/tokens don't leak.
|
|
|
|
Passes only the safe baseline keys, ``XDG_*``, vars injected by an external
|
|
secret source (users configured that backend precisely so subprocesses can
|
|
consume them), plus the server config's own ``env``.
|
|
"""
|
|
try:
|
|
from hermes_cli.env_loader import get_secret_source
|
|
except Exception: # pragma: no cover — early bootstrap/import fallback
|
|
get_secret_source = None
|
|
env = {}
|
|
for key, value in os.environ.items():
|
|
if (
|
|
key in _SAFE_ENV_KEYS
|
|
or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE
|
|
or key.startswith("XDG_")
|
|
or (get_secret_source is not None and get_secret_source(key))
|
|
):
|
|
env[key] = value
|
|
if user_env:
|
|
env.update(user_env)
|
|
return env
|
|
|
|
|
|
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."""
|
|
resolved_command = os.path.expanduser(str(command).strip())
|
|
resolved_env = dict(env or {})
|
|
|
|
if os.sep not in resolved_command:
|
|
path_arg = resolved_env.get("PATH")
|
|
which_hit = shutil.which(resolved_command, path=path_arg)
|
|
if which_hit is None and sys.platform == "win32" and resolved_env:
|
|
# shutil.which(path=...) uses the PARENT's PATHEXT, not the config
|
|
# env's, so retry with the config's PATHEXT (any key casing) applied.
|
|
cfg_pathext = next(
|
|
(v for k, v in resolved_env.items()
|
|
if k.upper() == "PATHEXT" and isinstance(v, str) and v.strip()),
|
|
None,
|
|
)
|
|
if cfg_pathext and cfg_pathext != os.environ.get("PATHEXT"):
|
|
_saved = os.environ.get("PATHEXT")
|
|
try:
|
|
os.environ["PATHEXT"] = cfg_pathext
|
|
which_hit = shutil.which(resolved_command, path=path_arg)
|
|
finally:
|
|
if _saved is None:
|
|
os.environ.pop("PATHEXT", None)
|
|
else:
|
|
os.environ["PATHEXT"] = _saved
|
|
if which_hit:
|
|
resolved_command = which_hit
|
|
elif resolved_command in {"npx", "npm", "node"}:
|
|
hermes_home = os.path.expanduser(
|
|
os.getenv(
|
|
"HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes")
|
|
)
|
|
)
|
|
candidates = [
|
|
os.path.join(hermes_home, "node", "bin", resolved_command),
|
|
os.path.join(os.path.expanduser("~"), ".local", "bin", resolved_command),
|
|
# Canonical Node location for from-source Linux builds, the
|
|
# Hermes Docker image and Intel Homebrew. Needed when a user's
|
|
# hand-authored env.PATH omits it: npx's shebang re-execs
|
|
# /usr/bin/env node, so a symlink workaround fails one layer deeper.
|
|
os.path.join(os.sep, "usr", "local", "bin", resolved_command),
|
|
]
|
|
for candidate in candidates:
|
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
resolved_command = candidate
|
|
break
|
|
|
|
command_dir = os.path.dirname(resolved_command)
|
|
if command_dir:
|
|
resolved_env = _prepend_path(resolved_env, command_dir)
|
|
|
|
return resolved_command, resolved_env
|
|
|
|
|
|
def _wrap_command_with_watchdog(command: str, args: list) -> tuple[str, list]:
|
|
"""Wrap a stdio command in the parent-death watchdog (POSIX only; the
|
|
watchdog polls ``getppid()`` against our PID). Unchanged on non-POSIX or
|
|
if the PID cannot be read — watchdog bookkeeping must never block a connection."""
|
|
if os.name != "posix":
|
|
# Relies on process groups (getpgid/killpg), same scope as the
|
|
# killpg-based orphan cleanup.
|
|
return command, args
|
|
try:
|
|
my_pid = os.getpid()
|
|
except Exception:
|
|
return command, args
|
|
watchdog_args = [
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp_stdio_watchdog.py"),
|
|
"--ppid", str(my_pid),
|
|
"--",
|
|
command,
|
|
*args,
|
|
]
|
|
return sys.executable, watchdog_args
|
|
|
|
|
|
def _interpolate_env_vars(value):
|
|
"""Recursively resolve ``${VAR}`` / Cursor ``${env:VAR}`` placeholders plus the
|
|
Cursor context vars (see ``_context_var_value``).
|
|
|
|
Env refs resolve from the active profile's secret scope when multiplexing
|
|
(so ``${API_KEY}`` picks up the routed profile's value, not another
|
|
profile's in ``os.environ``). Unset vars keep the literal placeholder.
|
|
"""
|
|
from agent.secret_scope import get_secret as _get_secret
|
|
|
|
if isinstance(value, str):
|
|
def _replace(m):
|
|
ctx = _context_var_value(m.group(1).strip())
|
|
if ctx is not None:
|
|
return ctx
|
|
name = _env_ref_name(m.group(1))
|
|
return _get_secret(name, m.group(0)) or m.group(0)
|
|
return _ENV_VAR_PATTERN.sub(_replace, value)
|
|
if isinstance(value, dict):
|
|
return {k: _interpolate_env_vars(v) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [_interpolate_env_vars(v) for v in value]
|
|
return value
|
|
|
|
|
|
# (server_name, dotted key path) pairs already warned about; config loads
|
|
# happen on every discovery pass, so warn once per process.
|
|
_whitespace_warned: Set[Tuple[str, str]] = set()
|
|
|
|
|
|
def _warn_hidden_whitespace(server_name: str, config: dict) -> List[str]:
|
|
"""Warn once per (server, key path) about string values with leading/trailing
|
|
whitespace — a pasted trailing newline or leading space causes opaque
|
|
auth/connect failures and is invisible in config.yaml.
|
|
|
|
Advisory only: values are never mutated (whitespace could be intentional)
|
|
and never logged (often secrets). Returns the flagged key paths.
|
|
"""
|
|
flagged: List[str] = []
|
|
|
|
def _walk(value: Any, path: str) -> None:
|
|
if isinstance(value, str):
|
|
if value != value.strip():
|
|
flagged.append(path)
|
|
elif isinstance(value, dict):
|
|
for k, v in value.items():
|
|
_walk(v, f"{path}.{k}" if path else str(k))
|
|
elif isinstance(value, list):
|
|
for i, v in enumerate(value):
|
|
_walk(v, f"{path}[{i}]")
|
|
|
|
_walk(config, "")
|
|
for key_path in flagged:
|
|
dedupe_key = (server_name, key_path)
|
|
if dedupe_key in _whitespace_warned:
|
|
continue
|
|
_whitespace_warned.add(dedupe_key)
|
|
logger.warning(
|
|
"MCP server '%s': config value '%s' has hidden leading or "
|
|
"trailing whitespace — this often causes authentication or "
|
|
"connection failures. Check for stray spaces/newlines in "
|
|
"config.yaml (or the referenced env var).",
|
|
server_name,
|
|
key_path,
|
|
)
|
|
return flagged
|
|
|
|
|
|
def _filter_suspicious_mcp_servers(servers: Dict[str, dict]) -> Dict[str, dict]:
|
|
"""Drop exfiltration-shaped MCP configs before any stdio spawn path."""
|
|
try:
|
|
from hermes_cli.mcp_security import validate_mcp_server_entry as _validate_mcp_server_entry
|
|
except Exception:
|
|
_validate_mcp_server_entry: Callable[[str, dict[str, Any]], list[str]] | None = None
|
|
|
|
if _validate_mcp_server_entry is None:
|
|
return servers
|
|
|
|
safe_servers = {}
|
|
for name, cfg in servers.items():
|
|
if not isinstance(cfg, dict):
|
|
safe_servers[name] = cfg
|
|
continue
|
|
issues = _validate_mcp_server_entry(name, cfg)
|
|
if issues:
|
|
logger.warning(
|
|
"Skipping suspicious MCP server '%s': %s",
|
|
name,
|
|
"; ".join(issues),
|
|
)
|
|
continue
|
|
safe_servers[name] = cfg
|
|
return safe_servers
|
|
|
|
|
|
def _load_mcp_config() -> Dict[str, dict]:
|
|
"""Read ``mcp_servers`` from config.yaml as ``{name: config}`` (empty on error
|
|
or in safe mode). Entries carry ``command``/``args``/``env`` (stdio) or
|
|
``url``/``headers`` (HTTP) plus optional timeout/auth keys; ``${VAR}``
|
|
placeholders are interpolated after ``.env`` is loaded."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from utils import env_var_enabled as _env_enabled
|
|
|
|
if _env_enabled("HERMES_SAFE_MODE"):
|
|
return {}
|
|
config = load_config()
|
|
servers = config.get("mcp_servers")
|
|
if not isinstance(servers, dict):
|
|
servers = {}
|
|
# Ensure .env vars are available for interpolation
|
|
try:
|
|
from hermes_cli.env_loader import load_hermes_dotenv
|
|
load_hermes_dotenv()
|
|
except Exception:
|
|
pass
|
|
safe_servers: Dict[str, dict] = {}
|
|
for name, cfg in _core._filter_suspicious_mcp_servers(servers).items():
|
|
interpolated = _interpolate_env_vars(cfg)
|
|
if isinstance(interpolated, dict):
|
|
_warn_hidden_whitespace(name, interpolated)
|
|
safe_servers[name] = interpolated
|
|
try:
|
|
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
|
|
|
discover_plugins()
|
|
portable = get_plugin_manager().get_portable_mcp_servers()
|
|
for name, cfg in _core._filter_suspicious_mcp_servers(portable).items():
|
|
if name in safe_servers:
|
|
logger.warning(
|
|
"Portable MCP server '%s' conflicts with native config; skipping",
|
|
name,
|
|
)
|
|
continue
|
|
safe_servers[name] = dict(cfg)
|
|
except Exception:
|
|
logger.debug("Failed to load portable MCP servers", exc_info=True)
|
|
return safe_servers
|
|
except Exception as exc:
|
|
logger.debug("Failed to load MCP config: %s", exc)
|
|
return {}
|