Files
hermes-agent/hermes_cli/web_server_mcp.py
beardthelion 6aaa1c480b fix(profile-scope): bind home and secret tokens inside the try that resets them
Several profile-scope binders called set_hermes_home_override and
set_secret_scope before the try/finally that releases them. A raise in
scope setup (a corrupt or removed profile home) propagated with the
foreign override still bound to the caller's context, silently re-homing
every later read — and in _reregister_orphaned_adopters it also skipped
every remaining adopter. The set calls now run inside the try with
None-guarded resets; the same shape is fixed in the routed-turn scope,
the cron external worker (which also leaked the multiplex flag), the
kanban worker scope, the MCP OAuth paths, the launch-profile policy,
and model_switch, which releases partially-bound scopes on raise.
2026-09-23 06:46:02 -07:00

185 lines
7.9 KiB
Python

"""MCP dashboard helpers: create-payload normalisation, env redaction/summary, dashboard-driven MCP OAuth worker.
Wraps the same config layer the CLI uses (hermes_cli.mcp_config); stdio ``env``
secrets are redacted on read.
"""
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional
if TYPE_CHECKING: # pragma: no cover - annotation only
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
from hermes_cli.config import redact_key
from hermes_cli.web_models import MCPServerCreate
def _normalize_mcp_server_create(body: MCPServerCreate) -> tuple[str, Dict[str, Any], Optional[str]]:
"""Validate a Dashboard MCP create request and build its safe config.
The returned config never contains the Bearer token; callers persist it via
the shared Bearer helper once inside the intended profile scope. Shared by
the MCP page and the Profile Builder so both enforce one transport/auth contract.
"""
from hermes_cli.mcp_config import _bearer_auth_headers, _strip_bearer_prefix
from hermes_cli.mcp_security import validate_mcp_server_entry
name = (body.name or "").strip()
if not name:
raise ValueError("Server name is required")
url = (body.url or "").strip()
command = (body.command or "").strip()
auth = (body.auth or "none").strip().lower()
bearer_token = body.bearer_token.get_secret_value() if body.bearer_token is not None else None
if bool(url) == bool(command):
raise ValueError("Provide exactly one of URL (HTTP/SSE) or command (stdio)")
if auth not in {"none", "header", "oauth"}:
raise ValueError(f"Unsupported auth mode: {auth}")
server_config: Dict[str, Any] = {}
if url:
if body.args:
raise ValueError("Arguments are only supported for stdio MCP servers")
if body.env:
raise ValueError("Environment variables are only supported for stdio MCP servers")
if auth == "header":
normalized = _strip_bearer_prefix(bearer_token) if bearer_token else ""
if not normalized or normalized.lower() == "bearer":
raise ValueError("Bearer token is required")
server_config["headers"] = _bearer_auth_headers(name)
elif body.bearer_token is not None:
raise ValueError("Bearer token requires header authentication")
server_config["url"] = url
if auth == "oauth":
server_config["auth"] = "oauth"
else:
if auth != "none" or body.bearer_token is not None:
raise ValueError("HTTP authentication is not supported for stdio MCP servers")
server_config["command"] = command
if body.args:
server_config["args"] = list(body.args)
if body.env:
server_config["env"] = dict(body.env)
issues = validate_mcp_server_entry(name, server_config)
if issues:
raise ValueError(f"Server '{name}' rejected: {'; '.join(issues)}")
return name, server_config, bearer_token
def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
"""Mask secret-shaped MCP env values for read responses."""
out: Dict[str, str] = {}
for k, v in (env or {}).items():
try:
out[str(k)] = redact_key(str(v)) if v else ""
except Exception:
out[str(k)] = "***"
return out
def _mcp_server_summary(name: str, cfg: Dict[str, Any], plugin: str | None = None) -> Dict[str, Any]:
from tools.mcp_tool_common import mcp_server_enabled
transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown")
auth = cfg.get("auth")
headers = cfg.get("headers") or {}
if not auth and isinstance(headers, dict) and any(str(key).lower() == "authorization" for key in headers):
auth = "header"
return {
"name": name,
"transport": transport,
"url": cfg.get("url"),
"command": cfg.get("command"),
"args": list(cfg.get("args") or []),
"env": _redact_mcp_env(cfg.get("env") or {}),
"auth": auth,
"enabled": mcp_server_enabled(cfg),
# Tool selection: list of enabled tool names, or None = all.
"tools": cfg.get("tools"),
"source": "plugin" if plugin is not None else "config",
"plugin": plugin,
}
_mcp_oauth_flows: dict[str, "DashboardOAuthFlow"] = {}
_mcp_oauth_transactions: dict[tuple[str, str], threading.Lock] = {}
_mcp_oauth_transactions_lock = threading.Lock()
def _mcp_oauth_transaction(flow) -> threading.Lock:
key = (flow.hermes_home, flow.server_name)
with _mcp_oauth_transactions_lock:
return _mcp_oauth_transactions.setdefault(key, threading.Lock())
def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None:
"""Run the normal MCP probe with dashboard redirect/callback handlers."""
from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server, _save_mcp_server
try:
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tools.mcp_dashboard_oauth import dashboard_oauth_flow
from tools.mcp_oauth import HermesTokenStorage, force_interactive_oauth, login_connect_timeout
from tools.mcp_oauth_manager import get_manager
home_token = secret_token = None
try:
home_token = set_hermes_home_override(flow.hermes_home)
secret_token = set_secret_scope(
build_profile_secret_scope(Path(flow.hermes_home)), profile_home=flow.hermes_home)
transaction = _mcp_oauth_transaction(flow)
with transaction, force_interactive_oauth(), dashboard_oauth_flow(flow):
manager = get_manager()
storage = HermesTokenStorage(flow.server_name)
backup = storage.snapshot()
previous_entry = None
try:
previous_entry = manager.remove(flow.server_name, hermes_home=flow.hermes_home)
tools = _probe_single_server(
flow.server_name, cfg, connect_timeout=login_connect_timeout(cfg)
)
if not _oauth_tokens_present(flow.server_name):
raise RuntimeError(
"The server responded, but no OAuth token was obtained — "
"this provider may require a manually-registered OAuth client."
)
_save_mcp_server(flow.server_name, cfg)
flow.tools = [{"name": t, "description": d} for t, d in tools]
flow.mark_approved()
if flow.reconnect_live:
from tools.mcp_tool_loop import reconnect_mcp_server
reconnect_mcp_server(flow.server_name)
except Exception:
storage.restore(backup, only_if_absent=True)
manager.restore_entry(flow.server_name, previous_entry, hermes_home=flow.hermes_home)
raise
finally:
if secret_token is not None:
reset_secret_scope(secret_token)
if home_token is not None:
reset_hermes_home_override(home_token)
except Exception as exc:
from tools.mcp_dashboard_oauth import exception_message
msg = exception_message(exc)
# Providers gating RFC 7591 registration to pre-approved clients 403 the
# register call before any auth URL exists; say so, not "403 Forbidden".
try:
from tools.mcp_oauth import humanize_oauth_registration_error
humanized = humanize_oauth_registration_error(
flow.server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None
)
if humanized:
msg = humanized
except Exception:
pass
flow.mark_error(msg)
finally:
flow.mark_worker_done()