mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
feat(mcp): first-class MCP tab — catalog, GUI auth/probe/logs, per-tool gating
A Cursor-style MCP manager inside Capabilities, plus the backend it needs. - Server list with brand/favicon avatars + live status dot and a capability summary (N tools, M prompts, K resources); Servers | Catalog views. - Catalog: one-click install of Nous-approved servers with required-env prompts. - GUI OAuth: Authenticate opens the system browser from the TTY-less backend and verifies a token actually lands; header/API-key servers are never pushed down OAuth; a dirty mcp.json can't drop a freshly-persisted auth field. - Full-width mcp.json editor (ecosystem document format) + pinned stdio/agent LogTail; probes cached 5m and keyed by (profile, config) so revisiting never respawns the fleet or shows a stale probe. - Whole-map persistence (PUT /api/mcp/servers) so deletes/toggles actually stick (the generic /api/config deep-merge could not remove keys). - perf: MCP probe/auth no longer hold the global skills lock, so a slow stdio spawn can't stall every other request into a 15s timeout. - per-tool include/exclude gating (lib/mcp-tool-filter) mirroring the CLI loader.
This commit is contained in:
parent
7e6d60aadc
commit
16aa09aca5
12 changed files with 1959 additions and 36 deletions
|
|
@ -35,6 +35,9 @@ LOG_FILES = {
|
|||
"gateway": "gateway.log",
|
||||
"gui": "gui.log",
|
||||
"desktop": "desktop.log",
|
||||
# Every stdio MCP subprocess's stderr (tools/mcp_tool.py redirects it
|
||||
# here, with per-server session markers) — the "MCP output channel".
|
||||
"mcp": "mcp-stderr.log",
|
||||
}
|
||||
|
||||
# Log line timestamp regex — matches "2026-04-05 22:35:00,123" or
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from hermes_cli.config import (
|
|||
from hermes_cli.colors import Colors, color
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_cli.mcp_security import validate_mcp_server_entry
|
||||
from tools.mcp_tool import _ENV_VAR_PATTERN
|
||||
from tools.mcp_tool import _ENV_VAR_PATTERN, _env_ref_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -117,6 +117,39 @@ def _remove_mcp_server(name: str) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _replace_mcp_servers(servers: Dict[str, dict]) -> Tuple[bool, List[str]]:
|
||||
"""Replace the WHOLE ``mcp_servers`` map in config.yaml.
|
||||
|
||||
Unlike ``_save_mcp_server`` (per-key upsert), this sets the entire map so
|
||||
the GUI's mcp.json editor can delete servers, drop an ``enabled: false``
|
||||
flag (re-enable), or remove nested fields and have those *removals* land on
|
||||
disk. A plain ``/api/config`` deep-merge can only add/override keys, never
|
||||
delete them — which is why edits appeared to succeed but the old entry
|
||||
survived (see MCP tab persistence bug).
|
||||
|
||||
Every entry is validated up front; on any suspicious command/args the whole
|
||||
save is rejected (returns ``(False, issues)``) so a bad paste can't be
|
||||
partially applied. An empty map removes the key entirely.
|
||||
"""
|
||||
issues: List[str] = []
|
||||
for name, cfg in servers.items():
|
||||
if not isinstance(cfg, dict):
|
||||
issues.append(f"Server '{name}': expected an object")
|
||||
continue
|
||||
issues.extend(validate_mcp_server_entry(name, cfg))
|
||||
|
||||
if issues:
|
||||
return False, issues
|
||||
|
||||
config = load_config()
|
||||
if servers:
|
||||
config["mcp_servers"] = dict(servers)
|
||||
else:
|
||||
config.pop("mcp_servers", None)
|
||||
save_config(config)
|
||||
return True, []
|
||||
|
||||
|
||||
def _env_key_for_server(name: str) -> str:
|
||||
"""Convert server name to an env-var key like ``MCP_MYSERVER_API_KEY``."""
|
||||
return f"MCP_{name.upper().replace('-', '_')}_API_KEY"
|
||||
|
|
@ -214,12 +247,16 @@ def _resolve_mcp_server_config(config: dict) -> dict:
|
|||
|
||||
|
||||
def _probe_single_server(
|
||||
name: str, config: dict, connect_timeout: float = 30
|
||||
name: str, config: dict, connect_timeout: float = 30, *, details: Optional[dict] = None
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""Temporarily connect to one MCP server, list its tools, disconnect.
|
||||
|
||||
Returns list of ``(tool_name, description)`` tuples.
|
||||
Raises on connection failure.
|
||||
|
||||
``details``: optional dict the probe fills with extra capability counts
|
||||
(``prompts``, ``resources``) — an out-param so the return shape stays
|
||||
stable for existing CLI callers.
|
||||
"""
|
||||
issues = validate_mcp_server_entry(name, config)
|
||||
if issues:
|
||||
|
|
@ -249,6 +286,19 @@ def _probe_single_server(
|
|||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
tools_found.append((t.name, desc))
|
||||
if details is not None:
|
||||
# Capability probes are best-effort: servers without the
|
||||
# capability raise, which just means "0".
|
||||
try:
|
||||
result = await server.session.list_prompts()
|
||||
details["prompts"] = len(result.prompts)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = await server.session.list_resources()
|
||||
details["resources"] = len(result.resources)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await server.shutdown()
|
||||
|
||||
|
|
@ -632,8 +682,8 @@ def cmd_mcp_test(args):
|
|||
elif headers:
|
||||
for k, v in headers.items():
|
||||
if isinstance(v, str) and ("key" in k.lower() or "auth" in k.lower()):
|
||||
# Mask the value
|
||||
resolved = _ENV_VAR_PATTERN.sub(lambda m: os.getenv(m.group(1), ""), v)
|
||||
# Mask the value (accepts ${VAR} and Cursor-style ${env:VAR})
|
||||
resolved = _ENV_VAR_PATTERN.sub(lambda m: os.getenv(_env_ref_name(m.group(1)), ""), v)
|
||||
if len(resolved) > 8:
|
||||
masked = resolved[:4] + "***" + resolved[-4:]
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -8805,6 +8805,12 @@ class MCPServerCreate(BaseModel):
|
|||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class MCPServersReplace(BaseModel):
|
||||
# Whole-map replace (name → raw server config) for the GUI mcp.json editor.
|
||||
servers: Dict[str, Dict[str, Any]] = {}
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Mask secret-shaped MCP env values for read responses."""
|
||||
out: Dict[str, str] = {}
|
||||
|
|
@ -8890,6 +8896,25 @@ async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
|
|||
return _mcp_server_summary(name, server_config)
|
||||
|
||||
|
||||
@app.put("/api/mcp/servers")
|
||||
async def replace_mcp_servers(body: MCPServersReplace, profile: Optional[str] = None):
|
||||
"""Replace the entire ``mcp_servers`` map (the GUI mcp.json editor's save).
|
||||
|
||||
The generic ``/api/config`` endpoint deep-merges maps, so it can never
|
||||
delete a server key, drop an ``enabled: false`` flag, or remove a nested
|
||||
field — edits looked saved but the stale entry survived on disk. This
|
||||
endpoint sets the whole map so removals actually persist. Storage stays
|
||||
the config.yaml ``mcp_servers`` key the CLI/TUI already read.
|
||||
"""
|
||||
from hermes_cli.mcp_config import _replace_mcp_servers
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
ok, issues = _replace_mcp_servers(body.servers)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail="; ".join(issues))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/mcp/servers/{name}")
|
||||
async def remove_mcp_server(name: str, profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import _remove_mcp_server
|
||||
|
|
@ -8911,19 +8936,21 @@ async def test_mcp_server(name: str, profile: Optional[str] = None):
|
|||
if name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
|
||||
details: Dict[str, Any] = {}
|
||||
|
||||
def _probe_scoped():
|
||||
# Re-enter the scope INSIDE the worker thread so call-time
|
||||
# resolution during the probe — env-placeholder expansion in
|
||||
# _resolve_mcp_server_config reading the profile's .env — sees the
|
||||
# selected profile, matching the config the server was saved into.
|
||||
# (asyncio.to_thread copies contextvars, but entering explicitly
|
||||
# keeps the lock-protected SKILLS_DIR swap balanced per-thread.)
|
||||
# The probe's dedicated MCP event-loop thread is covered too:
|
||||
# _run_on_mcp_loop wraps scheduled coroutines with the caller's
|
||||
# HERMES_HOME override (see mcp_tool._wrap_with_home_override), so
|
||||
# OAuth token stores resolve against the selected profile as well.
|
||||
with _profile_scope(profile):
|
||||
return _probe_single_server(name, servers[name])
|
||||
# Home-only scope (contextvar), NOT _profile_scope. A probe blocks for
|
||||
# as long as the server takes to spawn/connect — a stdio `npx` cold
|
||||
# start is many seconds — and _profile_scope holds a process-global
|
||||
# skills lock for its ENTIRE body. Holding that across the probe
|
||||
# serialized every other endpoint (config/skills/toolsets all take the
|
||||
# same lock), so a slow server made unrelated requests time out at 15s.
|
||||
# The probe touches no skills globals; it only needs the HERMES_HOME
|
||||
# override for .env interpolation + OAuth token resolution, which the
|
||||
# contextvar provides (copied into this to_thread worker; and
|
||||
# _run_on_mcp_loop re-wraps it onto the MCP event-loop thread).
|
||||
with _config_profile_scope(profile):
|
||||
return _probe_single_server(name, servers[name], details=details)
|
||||
|
||||
try:
|
||||
# Probe blocks on a dedicated MCP event loop — run in a thread so the
|
||||
|
|
@ -8938,9 +8965,103 @@ async def test_mcp_server(name: str, profile: Optional[str] = None):
|
|||
return {
|
||||
"ok": True,
|
||||
"tools": [{"name": t, "description": d} for t, d in tools],
|
||||
"prompts": details.get("prompts", 0),
|
||||
"resources": details.get("resources", 0),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/mcp/servers/{name}/auth")
|
||||
async def auth_mcp_server(name: str, profile: Optional[str] = None):
|
||||
"""Run the OAuth flow for an HTTP MCP server (opens the system browser).
|
||||
|
||||
Mirrors ``hermes mcp login``: wipe cached OAuth state so the probe forces
|
||||
a fresh browser flow, connect, then verify a token actually landed on disk
|
||||
(some providers serve tools/list unauthenticated — see
|
||||
``_reauth_oauth_server``). Blocks until the browser flow completes, so it
|
||||
runs in a worker thread. ``auth: oauth`` is persisted only on success.
|
||||
"""
|
||||
from hermes_cli.mcp_config import (
|
||||
_get_mcp_servers,
|
||||
_oauth_tokens_present,
|
||||
_probe_single_server,
|
||||
_save_mcp_server,
|
||||
)
|
||||
|
||||
with _profile_scope(profile):
|
||||
servers = _get_mcp_servers()
|
||||
if name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
|
||||
cfg = dict(servers[name])
|
||||
if not cfg.get("url"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="stdio servers authenticate via env keys, not OAuth",
|
||||
)
|
||||
# A server carrying `headers` uses API-key/bearer auth; a 401 there is a bad
|
||||
# key, not an OAuth prompt. Refuse rather than rewrite it to `auth: oauth`
|
||||
# and corrupt a working header-auth config. (Explicit `auth: oauth` is fine.)
|
||||
if cfg.get("headers") and cfg.get("auth") != "oauth":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This server uses header/API-key auth, not OAuth — check its key.",
|
||||
)
|
||||
cfg["auth"] = "oauth"
|
||||
|
||||
def _run():
|
||||
from tools.mcp_oauth import force_interactive_oauth
|
||||
|
||||
# Home-only scope, not _profile_scope: this blocks on the browser flow
|
||||
# for up to minutes; holding the shared skills lock that whole time
|
||||
# would freeze every other endpoint. Config writes here (_save_mcp_server)
|
||||
# resolve HERMES_HOME via the contextvar override, which is all they need.
|
||||
with _config_profile_scope(profile), force_interactive_oauth():
|
||||
try:
|
||||
from tools.mcp_oauth_manager import get_manager
|
||||
|
||||
get_manager().remove(name)
|
||||
except Exception:
|
||||
pass # No cached state to clear — fine.
|
||||
# The default 30s connect timeout would kill the flow while the
|
||||
# user is still looking at the browser consent screen — give the
|
||||
# whole browser round-trip room (the callback itself caps at 300s).
|
||||
tools = _probe_single_server(name, cfg, connect_timeout=240)
|
||||
if not _oauth_tokens_present(name):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"The server responded, but no OAuth token was obtained — "
|
||||
"this provider may require a manually-registered OAuth "
|
||||
"client (see `hermes mcp login`)."
|
||||
),
|
||||
"tools": [],
|
||||
}
|
||||
_save_mcp_server(name, cfg)
|
||||
return {
|
||||
"ok": True,
|
||||
"tools": [{"name": t, "description": d} for t, d in tools],
|
||||
}
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_run)
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
# Providers that gate RFC 7591 registration to pre-approved clients
|
||||
# (Figma's MCP catalog, etc.) 403 the register call before any
|
||||
# authorization URL exists — surface what's actually happening
|
||||
# instead of a bare "403 Forbidden".
|
||||
lowered = msg.lower()
|
||||
if "403" in msg and ("regist" in lowered or "forbidden" in lowered):
|
||||
msg = (
|
||||
f"'{name}' only allows pre-approved OAuth clients — it rejected "
|
||||
"client registration (403), so no browser flow can start. "
|
||||
"Options: add a pre-registered client to this server's entry "
|
||||
"(oauth: {client_id: ..., client_secret: ...}), or use the "
|
||||
"provider's stdio / API-key server instead."
|
||||
)
|
||||
return {"ok": False, "error": msg, "tools": []}
|
||||
|
||||
|
||||
class MCPEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
profile: Optional[str] = None
|
||||
|
|
@ -11185,12 +11306,31 @@ class SkillToggle(BaseModel):
|
|||
async def get_skills(profile: Optional[str] = None):
|
||||
from tools.skills_tool import _find_all_skills
|
||||
from hermes_cli.skills_config import get_disabled_skills
|
||||
from tools.skill_usage import (
|
||||
_read_bundled_manifest_names,
|
||||
_read_hub_installed_names,
|
||||
activity_count,
|
||||
load_usage,
|
||||
)
|
||||
with _profile_scope(profile):
|
||||
config = load_config()
|
||||
disabled = get_disabled_skills(config)
|
||||
skills = _find_all_skills(skip_disabled=True)
|
||||
usage = load_usage()
|
||||
# Set-based provenance (same classification as skill_usage.provenance,
|
||||
# without a per-skill manifest read): hub > bundled > agent, where
|
||||
# "agent" covers agent-authored AND local hand-made skills — the ones
|
||||
# the user may edit/delete from the UI.
|
||||
bundled_names = _read_bundled_manifest_names()
|
||||
hub_names = _read_hub_installed_names()
|
||||
for s in skills:
|
||||
s["enabled"] = s["name"] not in disabled
|
||||
s["usage"] = activity_count(usage.get(s["name"], {}))
|
||||
s["provenance"] = (
|
||||
"hub" if s["name"] in hub_names
|
||||
else "bundled" if s["name"] in bundled_names
|
||||
else "agent"
|
||||
)
|
||||
return skills
|
||||
|
||||
|
||||
|
|
@ -11908,6 +12048,9 @@ async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):
|
|||
"totals": totals,
|
||||
"period_days": days,
|
||||
"skills": skills,
|
||||
# Per-tool-name call counts (already computed by InsightsEngine);
|
||||
# the desktop Capabilities page aggregates these per toolset.
|
||||
"tools": insights_report.get("tools", []),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue