The `enabled` key had four parsers. The MCP client (`_parse_boolish`) read `enabled: 0` as on; the toolset resolver and editor (`_parse_enabled_flag`) read it as off. The server list (`summarize_server`, `/api/mcp/servers`) read any non-`False` value as on, so `enabled: "false"` showed on while the agent skipped it. The catalog and `hermes mcp list` accepted only true/1/yes, so `enabled: on` showed off while the server ran. `tools/mcp_tool_common.py::mcp_server_enabled` is now the only reader, and every surface calls it. `_parse_boolish` treats YAML numbers by truthiness (0 off, other numbers on). Everything else keeps the client's semantics: the off words are off, absent / null / junk stay on, with the existing warning for junk. The desktop MCP page mirrors the rule in `serverEnabled` (`apps/desktop/src/lib/mcp-servers.ts`). One case table (`mcp-enabled-cases.json`) drives the Python invariant test and the vitest test, so the page and the runtime cannot drift apart again.
181 lines
7.7 KiB
Python
181 lines
7.7 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 = set_hermes_home_override(flow.hermes_home)
|
|
secret_token = set_secret_scope(build_profile_secret_scope(Path(flow.hermes_home)))
|
|
try:
|
|
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:
|
|
reset_secret_scope(secret_token)
|
|
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()
|