`hermes logs --since` and `--level` passed every line that had no leading timestamp. A traceback's frames are written without one, so an old error printed its frames without the header that was filtered out, and `--component` dropped the frames of a matching record. `hermes logs` now reads each unstamped line as part of the record above it: the line gets that record's verdict for every filter, in the tail read and in `-f`. Lines before the first stamp in the read window have an unknown time and level, so they are dropped when `--since` or `--level` is set. mcp-stderr.log had no parseable stamp at all: the banner started with `=====` and server output was copied raw, so `hermes logs mcp --since` printed the whole file. The stderr tee already reads each server's stderr in a thread, so it now writes one line at a time, each prefixed with the asctime-shaped local stamp the Python logs use. The banner starts with the same stamp. The stamp comes from new public `timestamp()`/`stamp_line()` in hermes_cli/stderr_timestamp.py, which stays stdlib-only. The desktop MCP log view accepts both banner shapes. A test now requires a real writer's sample line for every LOG_FILES entry to parse with `_parse_line_timestamp`. The docs no longer say the `timezone` key changes log timestamps; log lines use the machine's local time.
465 lines
22 KiB
Python
465 lines
22 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, the cached-npx binary shortcut and the shared stderr log."""
|
|
|
|
import codecs
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
from hermes_cli.stderr_timestamp import stamp_line, timestamp
|
|
from tools.mcp_tool_common import _env_ref_name, _prepend_path
|
|
|
|
logger = logging.getLogger("tools.mcp_tool")
|
|
|
|
_mcp_stderr_log_fh: Dict[str, Any] = {} # profile home key -> handle
|
|
_mcp_stderr_log_lock = threading.Lock()
|
|
|
|
|
|
def _get_mcp_stderr_log() -> Any:
|
|
"""Shared append-mode handle for MCP subprocess stderr, cached until shutdown PER PROFILE HOME (a
|
|
multiplexed gateway's secondary profile must log under ITS ``logs/``, not the launch profile's). Must
|
|
expose a real fd (asyncio wires the child's stderr to it); falls back to ``/dev/null``, then real stderr."""
|
|
from hermes_constants import get_hermes_home, hermes_home_key, mkdir_under_hermes_home
|
|
home_key = hermes_home_key()
|
|
with _mcp_stderr_log_lock:
|
|
fh = _mcp_stderr_log_fh.get(home_key)
|
|
if fh is None or fh.closed:
|
|
try:
|
|
log_dir = get_hermes_home() / "logs"
|
|
mkdir_under_hermes_home(log_dir)
|
|
# Line-buffered so output lands promptly; errors="replace" tolerates garbled binary.
|
|
fh = open(log_dir / "mcp-stderr.log", "a", encoding="utf-8", errors="replace", buffering=1)
|
|
fh.fileno() # confirm a real fd before committing
|
|
except Exception as exc: # pragma: no cover — best-effort fallback
|
|
logger.debug("Failed to open MCP stderr log, using devnull: %s", exc)
|
|
try:
|
|
fh = open(os.devnull, "w", encoding="utf-8")
|
|
except Exception:
|
|
fh = sys.stderr
|
|
_mcp_stderr_log_fh[home_key] = fh
|
|
return fh
|
|
|
|
|
|
def _close_mcp_stderr_logs(*, scope: Optional[str] = None) -> None:
|
|
"""Release cached parent handles after the selected MCP transports have stopped."""
|
|
with _mcp_stderr_log_lock:
|
|
keys = list(_mcp_stderr_log_fh) if scope is None else [scope]
|
|
for key in keys:
|
|
fh = _mcp_stderr_log_fh.pop(key, None)
|
|
# The last-resort fallback is borrowed, not owned by MCP.
|
|
if fh is None or fh is sys.stderr or fh is sys.__stderr__:
|
|
continue
|
|
try:
|
|
fh.close()
|
|
except OSError:
|
|
logger.warning("Could not close MCP stderr log for %s", key, exc_info=True)
|
|
|
|
|
|
class _StderrTee:
|
|
"""A stdio child's stderr, copied into the shared log one stamped line at a time while the last few KB
|
|
stay readable (raw), so a server that dies at startup can say why on the MCP status surfaces instead of
|
|
only in the log (#124264). ``sink`` is handed to the child; ``close()`` returns the captured tail."""
|
|
|
|
_TAIL_BYTES = 16384
|
|
|
|
def __init__(self, log_fh: Any):
|
|
read_fd, write_fd = os.pipe()
|
|
self.sink = os.fdopen(write_fd, "wb", buffering=0)
|
|
self._log, self._tail = log_fh, bytearray()
|
|
self._reader = threading.Thread(target=self._pump, args=(read_fd,), name="mcp-stderr", daemon=True)
|
|
self._reader.start()
|
|
|
|
def _pump(self, read_fd: int) -> None:
|
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
pending = ""
|
|
with os.fdopen(read_fd, "rb", buffering=0) as source:
|
|
while chunk := source.read(65536):
|
|
self._tail = (self._tail + chunk)[-self._TAIL_BYTES:]
|
|
# Stamp whole lines only; a partial line waits for its newline (or EOF).
|
|
*lines, pending = (pending + decoder.decode(chunk)).split("\n")
|
|
self._write_lines(lines)
|
|
if rest := pending + decoder.decode(b"", final=True):
|
|
self._write_lines([rest])
|
|
|
|
def _write_lines(self, lines: List[str]) -> None:
|
|
if not lines:
|
|
return
|
|
try:
|
|
self._log.write("".join(stamp_line(line) for line in lines))
|
|
self._log.flush()
|
|
except (OSError, ValueError): # log closed at shutdown: keep draining the child
|
|
pass
|
|
|
|
def close(self, timeout: float = 2.0) -> str:
|
|
"""Close our write end and give the reader *timeout* to drain (a surviving grandchild can keep
|
|
the pipe open); the tail read so far."""
|
|
self.sink.close()
|
|
self._reader.join(timeout)
|
|
return self._tail.decode("utf-8", errors="replace")
|
|
|
|
|
|
def _write_stderr_log_header(server_name: str) -> None:
|
|
"""Session marker so operators can find each server's output in the shared log; it leads with the
|
|
same stamp as every server line (``_StderrTee``) so ``hermes logs mcp --since`` can filter it."""
|
|
fh = _get_mcp_stderr_log()
|
|
try:
|
|
fh.write(f"\n{timestamp()} ===== 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"})
|
|
|
|
# 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",
|
|
"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_paths import _authoritative_workspace_root
|
|
root = _authoritative_workspace_root()
|
|
except Exception:
|
|
root = None
|
|
return root or os.getcwd()
|
|
|
|
|
|
def _workspace_basename() -> str:
|
|
root = _workspace_folder()
|
|
return os.path.basename(root.rstrip("/\\")) or root
|
|
|
|
|
|
# Cursor's case-sensitive context vars -> resolver.
|
|
_CONTEXT_VAR_RESOLVERS = {
|
|
"userHome": lambda: os.path.expanduser("~"), "workspaceFolder": lambda: _workspace_folder(),
|
|
"workspaceFolderBasename": _workspace_basename, "pathSeparator": lambda: os.sep, "/": lambda: os.sep}
|
|
|
|
|
|
def _build_safe_env(user_env: Optional[dict]) -> dict:
|
|
"""Filtered env for stdio subprocesses so API keys/tokens don't leak: 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``."""
|
|
from agent.secret_scope import get_secret
|
|
from hermes_cli.env_loader import secret_source_names
|
|
env = {
|
|
key: value 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_")}
|
|
# Source-tagged names are process-wide (any profile's hydration tags them) while os.environ
|
|
# holds only the LAUNCH profile's values, so the value must come from the active profile's
|
|
# secret scope; a profile that lacks the name gets nothing, never another profile's token.
|
|
for key in secret_source_names():
|
|
value = get_secret(key)
|
|
if value is not None:
|
|
env[key] = value
|
|
for key in ("HERMES_KANBAN_DB", "HERMES_KANBAN_BOARD"):
|
|
if key in os.environ:
|
|
env[key] = os.environ[key]
|
|
if user_env:
|
|
env.update(user_env)
|
|
from agent.delegation_context import delegated_child_subprocess_env
|
|
return delegated_child_subprocess_env(env)
|
|
|
|
|
|
def _which_with_config_pathext(command: str, path_arg, env: dict):
|
|
"""Resolve *command* under the config env's PATHEXT (Windows only; ``shutil.which`` uses the PARENT's).
|
|
|
|
The extension walk mirrors ``which`` itself (existing-suffix short-circuit, configured
|
|
extensions in order) but reads nothing from and writes nothing to ``os.environ``: swapping
|
|
the parent's PATHEXT around a ``which`` call would publish this server's per-profile value
|
|
to every other thread for the duration, and a ``finally``-restore cannot undo that window."""
|
|
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
|
|
# PATHEXT is Windows-defined: ";"-separated even when resolved off-Windows
|
|
exts = [ext for ext in cfg_pathext.split(";") if ext]
|
|
candidates = [command + ext for ext in exts]
|
|
if not candidates or any(command.lower().endswith(ext.lower()) for ext in exts):
|
|
candidates = [command]
|
|
directories = str(path_arg or "").split(os.pathsep)
|
|
if sys.platform == "win32" and os.curdir not in directories:
|
|
directories.insert(0, os.curdir) # Windows resolves from the cwd first
|
|
for raw in directories:
|
|
directory = raw or os.curdir # POSIX: an empty PATH component means the cwd
|
|
if not os.path.isdir(directory):
|
|
continue
|
|
for candidate in candidates:
|
|
resolved = os.path.join(directory, candidate)
|
|
if os.path.isfile(resolved) and os.access(resolved, os.F_OK | os.X_OK):
|
|
return resolved
|
|
return None
|
|
|
|
|
|
# Bare MCP launchers Hermes ships through PM, keyed to the package that provides them.
|
|
_MANAGED_LAUNCHERS = {"npx": "npm", "npm": "npm", "node": "npm", "uv": "uv", "uvx": "uv"}
|
|
|
|
|
|
def _managed_launcher(command: str) -> Optional[tuple[str, list[str]]]:
|
|
"""PM's executable for a bare launcher name and the toolchain dirs its children need first on
|
|
PATH (npx's ``env node``, uvx's sibling uv). Never the user's copy: a missing managed tool is
|
|
provisioned through PM, which raises naming the remedy when it may not. None only when PM
|
|
ships no build for this platform (Termux), where the platform's own copy IS the toolchain."""
|
|
import pm
|
|
|
|
package = _MANAGED_LAUNCHERS[command]
|
|
if pm.get_package(package).missing_reason(pm.current_target()) is not None:
|
|
return None
|
|
pm.ensure(package)
|
|
if package == "uv":
|
|
launcher = pm.uv_launcher(command)
|
|
dirs = [str(launcher.parent)] if launcher is not None else []
|
|
else:
|
|
from hermes_constants import with_hermes_node_path
|
|
|
|
dirs = [d for d in with_hermes_node_path({"PATH": ""})["PATH"].split(os.pathsep) if d]
|
|
executable = shutil.which(command, path=os.pathsep.join(dirs)) if dirs else None
|
|
if executable is None:
|
|
raise RuntimeError(f"Hermes-managed {command} is not installed; run `hermes pm install {package}`")
|
|
return executable, dirs
|
|
|
|
|
|
def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
|
|
"""Resolve a stdio command against the exact subprocess env (bare launchers under a filtered PATH).
|
|
|
|
Bare ``npx``/``npm``/``node``/``uv``/``uvx`` resolve to Hermes's PM-managed copies with their
|
|
toolchain dirs first on the child PATH, never the user's (an absolute ``command:`` stays the
|
|
user's choice). Anything else resolves on the child env's PATH only: ``shutil.which`` with
|
|
``path=None`` silently falls back to the PARENT's ``os.environ["PATH"]``, letting a command
|
|
"resolve" against an env the child will never be spawned with. An absent child PATH is a
|
|
miss; an explicitly empty one keeps its cwd-only meaning (same distinction the child's
|
|
``execvp`` will see); a miss stays as-written for an honest spawn failure."""
|
|
resolved_command = os.path.expanduser(str(command).strip())
|
|
resolved_env = dict(env or {})
|
|
launcher = re.sub(r"\.(cmd|exe)$", "", resolved_command, flags=re.IGNORECASE) # Windows spellings
|
|
managed = _managed_launcher(launcher) if launcher in _MANAGED_LAUNCHERS else None
|
|
if managed is not None:
|
|
resolved_command, dirs = managed
|
|
# Moved to the front even when already on PATH behind a user's copy.
|
|
keys = {os.path.normcase(d) for d in dirs}
|
|
rest = [p for p in resolved_env.get("PATH", "").split(os.pathsep) if p and os.path.normcase(p) not in keys]
|
|
resolved_env["PATH"] = os.pathsep.join([*dirs, *rest])
|
|
return resolved_command, resolved_env
|
|
if os.sep not in resolved_command:
|
|
path_arg = resolved_env.get("PATH")
|
|
which_hit = shutil.which(resolved_command, path=path_arg) if path_arg is not None else None
|
|
if which_hit is None and sys.platform == "win32" and resolved_env:
|
|
which_hit = _which_with_config_pathext(resolved_command, path_arg, resolved_env)
|
|
if which_hit:
|
|
resolved_command = which_hit
|
|
command_dir = os.path.dirname(resolved_command)
|
|
if command_dir:
|
|
resolved_env = _prepend_path(resolved_env, command_dir)
|
|
return resolved_command, resolved_env
|
|
|
|
|
|
def _npx_bin_candidates(bin_dir: str, name: str, *, windows: Optional[bool] = None) -> list:
|
|
"""Launcher paths to try for *name* inside an npx cache's ``.bin``, in order. On Windows that
|
|
directory holds the extensionless sh script plus ``<name>.cmd``/``<name>.ps1``; the sh one
|
|
cannot be spawned there and ``os.access(X_OK)`` is only an existence check, so select by
|
|
extension (same precedence as ``hermes_constants._candidate_node_command_names``). ``windows``
|
|
is injectable so the branch is testable without patching ``os.name`` process-wide."""
|
|
is_windows = os.name == "nt" if windows is None else windows
|
|
if is_windows:
|
|
return [os.path.join(bin_dir, name + ext) for ext in (".cmd", ".exe")]
|
|
return [os.path.join(bin_dir, name)]
|
|
|
|
|
|
def _npx_cached_bin(args: list) -> Optional[tuple]:
|
|
"""Resolve ``npx -y <pkg>`` to the already-installed binary, or None.
|
|
|
|
``npx`` resolves the package and then FORKS, staying resident as the real server's parent
|
|
for nothing (~48 MB private memory per MCP server, measured); Hermes already supervises the
|
|
child (shared death supervisor). When the package is in npx's cache we spawn its binary
|
|
directly. Deliberately conservative — None (caller keeps plain ``npx``, so a cold machine
|
|
still installs) for a cache miss, a version pin (``pkg@1.2.3``), extra npx flags, a manifest
|
|
without one obvious bin, or any unreadable cache entry. Returns ``(binary_path, remaining_args)``."""
|
|
if not isinstance(args, list) or not args:
|
|
return None
|
|
|
|
rest = list(args)
|
|
while rest and rest[0] in ("-y", "--yes"):
|
|
rest.pop(0)
|
|
if not rest:
|
|
return None
|
|
# `npx pkg -y` (flag AFTER the spec) would hand the server a flag npx would have eaten.
|
|
if any(str(a) in ("-y", "--yes") for a in rest[1:]):
|
|
return None
|
|
|
|
spec = str(rest[0])
|
|
# Scoped names keep their leading '@', so only an '@' AFTER the scope is a version separator.
|
|
if "@" in (spec[1:] if spec.startswith("@") else spec):
|
|
return None
|
|
if not spec or spec.startswith("-"):
|
|
return None
|
|
|
|
cache_root = os.environ.get("npm_config_cache") or os.path.join(os.path.expanduser("~"), ".npm")
|
|
npx_root = os.path.join(cache_root, "_npx")
|
|
if not os.path.isdir(npx_root):
|
|
return None
|
|
try:
|
|
entries = os.listdir(npx_root)
|
|
except OSError:
|
|
return None
|
|
|
|
for entry in entries:
|
|
manifest = os.path.join(npx_root, entry, "package.json")
|
|
try:
|
|
with open(manifest, "r", encoding="utf-8-sig") as fh:
|
|
deps = (json.load(fh) or {}).get("dependencies") or {}
|
|
except (OSError, ValueError, TypeError):
|
|
continue
|
|
if spec not in deps:
|
|
continue
|
|
pkg_json = os.path.join(npx_root, entry, "node_modules", spec, "package.json")
|
|
try:
|
|
with open(pkg_json, "r", encoding="utf-8-sig") as fh:
|
|
bin_field = (json.load(fh) or {}).get("bin")
|
|
except (OSError, ValueError, TypeError):
|
|
continue
|
|
if isinstance(bin_field, str):
|
|
names = [os.path.basename(spec)]
|
|
elif isinstance(bin_field, dict) and len(bin_field) == 1:
|
|
names = list(bin_field.keys())
|
|
else:
|
|
continue # zero or several bins: which one npx would pick is not ours to guess
|
|
bin_dir = os.path.join(npx_root, entry, "node_modules", ".bin")
|
|
for candidate in _npx_bin_candidates(bin_dir, names[0]):
|
|
if os.path.exists(candidate) and os.access(candidate, os.X_OK):
|
|
return candidate, rest[1:]
|
|
return None
|
|
|
|
|
|
def _interpolate_env_vars(value):
|
|
"""Recursively resolve ``${VAR}`` / Cursor ``${env:VAR}`` placeholders and context vars. Env
|
|
refs resolve from the active profile's secret scope when multiplexing (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):
|
|
resolver = _CONTEXT_VAR_RESOLVERS.get(m.group(1).strip())
|
|
return resolver() if resolver is not None else (_get_secret(_env_ref_name(m.group(1)), 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
|
|
|
|
|
|
def _require_rendered_remote(server_name: str, config: dict) -> dict:
|
|
"""*config* back, unless it is a remote server whose ``url`` / ``headers`` still carry a literal
|
|
``${VAR}`` after rendering: sending that is a guaranteed 401 that reads as a bad credential
|
|
(#119092), so fail closed naming the variable instead."""
|
|
if "url" not in config:
|
|
return config
|
|
values = [config.get("url") or "", *(config.get("headers") or {}).values()]
|
|
unresolved = sorted({m.group(1) for value in values for m in _ENV_VAR_PATTERN.finditer(str(value))})
|
|
if unresolved:
|
|
refs = ", ".join(f"${{{ref}}}" for ref in unresolved)
|
|
raise ValueError(f"MCP server '{server_name}': {refs} in url/headers is not set in this profile's "
|
|
".env or secret source")
|
|
return config
|
|
|
|
|
|
# (server_name, dotted key path) pairs already warned about: config loads repeat per discovery pass.
|
|
_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 newline causes opaque auth failures). Advisory only: values are never mutated (could be
|
|
intentional) nor logged (often secrets). Returns flagged paths."""
|
|
flagged: List[str] = []
|
|
|
|
def _walk(value: Any, path: str) -> None:
|
|
if isinstance(value, str) and 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:
|
|
if (server_name, key_path) not in _whitespace_warned:
|
|
_whitespace_warned.add((server_name, key_path))
|
|
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
|
|
except Exception:
|
|
return servers
|
|
safe_servers = {}
|
|
for name, cfg in servers.items():
|
|
issues = validate_mcp_server_entry(name, cfg) if isinstance(cfg, dict) else None
|
|
if issues:
|
|
logger.warning("Skipping suspicious MCP server '%s': %s", name, "; ".join(issues))
|
|
else:
|
|
safe_servers[name] = cfg
|
|
return safe_servers
|
|
|
|
|
|
def _portable_mcp_servers(safe_servers: Dict[str, dict]) -> None:
|
|
"""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()
|
|
portable = get_plugin_manager().get_portable_mcp_servers()
|
|
for name, cfg in _filter_suspicious_mcp_servers(portable).items():
|
|
if name in safe_servers:
|
|
logger.warning("Portable MCP server '%s' conflicts with native config; skipping", name)
|
|
else:
|
|
safe_servers[name] = dict(cfg)
|
|
except Exception:
|
|
logger.debug("Failed to load portable MCP servers", exc_info=True)
|
|
|
|
|
|
def _load_mcp_config() -> Dict[str, dict]:
|
|
"""``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
|
|
if _env_enabled("HERMES_SAFE_MODE"):
|
|
return {}
|
|
servers = load_config().get("mcp_servers")
|
|
try: # ensure .env vars are available for interpolation
|
|
from hermes_cli.env_loader import load_hermes_dotenv
|
|
load_hermes_dotenv()
|
|
except Exception:
|
|
pass
|
|
safe_servers: Dict[str, dict] = {}
|
|
for name, cfg in _filter_suspicious_mcp_servers(servers if isinstance(servers, dict) else {}).items():
|
|
interpolated = _interpolate_env_vars(cfg)
|
|
if isinstance(interpolated, dict):
|
|
_warn_hidden_whitespace(name, interpolated)
|
|
safe_servers[name] = interpolated
|
|
_portable_mcp_servers(safe_servers)
|
|
return safe_servers
|
|
except Exception as exc:
|
|
logger.debug("Failed to load MCP config: %s", exc)
|
|
return {}
|