hermes-agent/hermes_cli/mcp_config.py
Teknium 8b69ec03af
feat(mcp): Nous-approved MCP catalog with interactive picker (#30870)
* feat(mcp): Nous-approved MCP catalog with interactive picker

Adds an optional-mcps/ directory mirroring optional-skills/: curated,
Nous-approved MCP servers shipped with the repo but disabled by default.
Presence in optional-mcps/ = approval. No community tier, no trust signals.
Entries are added by merging a PR.

New surface:
  hermes mcp                       Interactive catalog picker (default)
  hermes mcp catalog               Plain-text list, scriptable
  hermes mcp install <name>        Install a catalog entry

Picker behavior:
  not installed   -> install (clone/bootstrap if needed, prompt for creds)
  installed/off   -> enable
  installed/on    -> menu (disable / uninstall / reinstall)

Manifest schema (manifest_version: 1) supports:
- transport: stdio (command/args, ${INSTALL_DIR} substitution) or http (url)
- install: optional git clone + bootstrap commands (for repos that need
  local venv setup, like the n8n bridge); omit for npx/uvx servers
- auth: api_key (prompts -> ~/.hermes/.env), oauth (provider-mediated
  or native MCP), or none

Catalog entries are never auto-updated. Users re-run `hermes mcp install`
to refresh. Credentials always go to ~/.hermes/.env (the .env-is-for-secrets
rule), never to per-server env blocks.

Ships n8n as the reference manifest (https://github.com/CyberSamuraiX/hermes-n8n-mcp).

Tests: 19 catalog tests + E2E install/uninstall round-trip via the shipped
manifest.

* feat(mcp): tool-selection checklist + Linear catalog entry

Adds install-time tool selection so users only enable the MCP tools they
actually want, and ships Linear as a second reference catalog entry to
demonstrate the http+oauth path alongside n8n's stdio+api_key+git-bootstrap.

Tool selection flow:
  install (clone/auth/credentials) ->
  probe server for available tools ->
  curses checklist with pre-checked rows ->
  write mcp_servers.<name>.tools.include

Pre-check priority:
  1. user's prior tools.include  (reinstall preserves selection)
  2. manifest's tools.default_enabled  (curated subset)
  3. all probed tools  (default)

Probe-failure fallback (server unreachable, OAuth not yet complete,
backing service offline):
  - manifest declared default_enabled -> applied directly
  - no default declared -> no filter written (all-on when reachable)
  - both cases point user at hermes mcp configure <name>

Manifest schema additions:
  tools:
    default_enabled: [list, of, tool, names]   # optional

Updates:
  - optional-mcps/linear/manifest.yaml -- new reference entry (http+oauth)
  - optional-mcps/n8n/manifest.yaml -- tools.default_enabled set to the
    8 read-mostly tools; mutating tools (activate/deactivate, container_logs)
    pruned by default
  - docs: new 'Tool selection at install time' section in features/mcp.md

Tests: 7 new tests in TestToolSelection covering probe-success / probe-fail
matrix, manifest-default filtering, reinstall-preserves-selection, and
invalid-default-enabled rejection. 26 catalog tests + 32 existing
mcp_config tests passing.

* feat(mcp): polish — picker unification, include-mode convergence, hardening

Addresses review findings on PR #30870. Lands all improvements that
belong in this PR before merge; defers separate cleanup (consolidating
two probe implementations, change-detector tests) to follow-ups.

Picker UX (mcp_picker.py)
- Unifies catalog + custom (user-added) MCPs in one view with distinct
  status badges (available / enabled / installed (disabled) /
  custom — enabled / custom — disabled)
- Adds 'Configure tools (probe server + re-pick)' action to both the
  catalog-installed and custom-row submenus — the existing
  hermes mcp configure flow was previously unreachable from the picker
- Loops until ESC/q so the user can manage several entries in one
  session instead of having to re-launch
- Uninstall message now mentions .env credentials are preserved with a
  pointer to clean them up manually if no longer needed
- Surfaces a 'requires a newer Hermes' warning per future-manifest
  entry instead of silently hiding it

Catalog (mcp_catalog.py)
- catalog_diagnostics() exposes which manifests were skipped and why
  (future_manifest vs invalid) so UIs can give actionable feedback
- _do_git_install detects SHA-shaped refs (regex /[0-9a-f]{7,40}/)
  and skips the doomed 'git clone --branch <sha>' attempt — clone --branch
  only accepts branches/tags, so SHAs always failed noisily before
  falling back to the full-clone path
- Probe-success all-tools-enabled message now mentions that new tools
  the server adds later will be auto-enabled (no-filter mode)

Convergence (tools_config.py)
- _configure_mcp_tools_interactive now writes tools.include (whitelist)
  instead of tools.exclude (blacklist), matching the catalog flow and
  hermes mcp configure. The on-disk config shape no longer depends on
  which UI the user touched last
- Two existing tests updated to assert the new include-mode contract

Discoverability
- Setup wizard final step now prints 'Browse curated MCPs: hermes mcp'
- Three tip-corpus entries pointing at the new catalog
- Docs updated with: trust model (manifests run code locally, gated by
  PR review, but read before installing), runtime ${ENV_VAR} substitution
  semantics, and the manifest_version forward-compat behavior

Tests
- 7 new tests covering future-manifest diagnostics, custom MCP picker
  rows, SHA-ref git-install path, branch-ref git-install path, and the
  tools_config include-mode write contract
- 80 MCP-related tests passing across test_mcp_catalog.py,
  test_mcp_config.py, test_mcp_tools_config.py

* fix(mcp): drop setup-wizard catalog hint to satisfy supply-chain scanner

The wizard line 'Browse curated MCPs: hermes mcp' triggered the
CI supply-chain scanner because it pattern-matches on edits to any
file named hermes_cli/setup.py — that filename matches the Python
'install-hook file' heuristic even though this setup.py is the
user-facing 'hermes setup' wizard, not a packaging install hook.

The catalog is already surfaced via three tip-corpus entries in
hermes_cli/tips.py (which the scanner doesn't flag), so dropping the
wizard mention loses no discoverability. Worth revisiting after a
scanner allowlist for this specific file lands.
2026-05-26 12:48:14 -07:00

803 lines
28 KiB
Python

"""
MCP Server Management CLI — ``hermes mcp`` subcommand.
Implements ``hermes mcp add/remove/list/test/configure`` for interactive
MCP server lifecycle management (issue #690 Phase 2).
Relies on tools/mcp_tool.py for connection/discovery and keeps
configuration in ~/.hermes/config.yaml under the ``mcp_servers`` key.
"""
import asyncio
import logging
import os
import re
import time
from typing import Any, Dict, List, Optional, Tuple
from hermes_cli.config import (
cfg_get,
load_config,
save_config,
get_env_value,
save_env_value,
get_hermes_home, # noqa: F401 — used by test mocks
)
from hermes_cli.colors import Colors, color
from hermes_constants import display_hermes_home
from tools.mcp_tool import _ENV_VAR_PATTERN
logger = logging.getLogger(__name__)
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_MCP_PRESETS: Dict[str, Dict[str, Any]] = {
"codex": {
"command": "codex",
"args": ["mcp-server"],
},
}
# ─── UI Helpers ───────────────────────────────────────────────────────────────
def _info(text: str):
print(color(f" {text}", Colors.DIM))
def _success(text: str):
print(color(f"{text}", Colors.GREEN))
def _warning(text: str):
print(color(f"{text}", Colors.YELLOW))
def _error(text: str):
print(color(f"{text}", Colors.RED))
def _confirm(question: str, default: bool = True) -> bool:
default_str = "Y/n" if default else "y/N"
try:
val = input(color(f" {question} [{default_str}]: ", Colors.YELLOW)).strip().lower()
except (KeyboardInterrupt, EOFError):
print()
return default
if not val:
return default
return val in {"y", "yes"}
def _prompt(question: str, *, password: bool = False, default: str = "") -> str:
from hermes_cli.cli_output import prompt as _shared_prompt
return _shared_prompt(question, default=default, password=password)
# ─── Config Helpers ───────────────────────────────────────────────────────────
def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]:
"""Return the ``mcp_servers`` dict from config, or empty dict."""
if config is None:
config = load_config()
servers = config.get("mcp_servers")
if not servers or not isinstance(servers, dict):
return {}
return servers
def _save_mcp_server(name: str, server_config: dict):
"""Add or update a server entry in config.yaml."""
config = load_config()
config.setdefault("mcp_servers", {})[name] = server_config
save_config(config)
def _remove_mcp_server(name: str) -> bool:
"""Remove a server from config.yaml. Returns True if it existed."""
config = load_config()
servers = config.get("mcp_servers", {})
if name not in servers:
return False
del servers[name]
if not servers:
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"
def _parse_env_assignments(raw_env: Optional[List[str]]) -> Dict[str, str]:
"""Parse ``KEY=VALUE`` strings from CLI args into an env dict."""
parsed: Dict[str, str] = {}
for item in raw_env or []:
text = str(item or "").strip()
if not text:
continue
if "=" not in text:
raise ValueError(f"Invalid --env value '{text}' (expected KEY=VALUE)")
key, value = text.split("=", 1)
key = key.strip()
if not key:
raise ValueError(f"Invalid --env value '{text}' (missing variable name)")
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid --env variable name '{key}'")
parsed[key] = value
return parsed
def _apply_mcp_preset(
name: str,
*,
preset_name: Optional[str],
url: Optional[str],
command: Optional[str],
cmd_args: List[str],
server_config: Dict[str, Any],
) -> tuple[Optional[str], Optional[str], List[str], bool]:
"""Apply a known MCP preset when transport details were omitted."""
if not preset_name:
return url, command, cmd_args, False
preset = _MCP_PRESETS.get(preset_name)
if not preset:
raise ValueError(f"Unknown MCP preset: {preset_name}")
if url or command:
return url, command, cmd_args, False
url = preset.get("url")
command = preset.get("command")
cmd_args = list(preset.get("args") or [])
if url:
server_config["url"] = url
if command:
server_config["command"] = command
if cmd_args:
server_config["args"] = cmd_args
return url, command, cmd_args, True
# ─── Discovery (temporary connect) ───────────────────────────────────────────
def _probe_single_server(
name: str, config: dict, connect_timeout: float = 30
) -> 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.
"""
from tools.mcp_tool import (
_ensure_mcp_loop,
_run_on_mcp_loop,
_connect_server,
_stop_mcp_loop,
)
_ensure_mcp_loop()
tools_found: List[Tuple[str, str]] = []
async def _probe():
server = await asyncio.wait_for(
_connect_server(name, config), timeout=connect_timeout
)
for t in server._tools:
desc = getattr(t, "description", "") or ""
# Truncate long descriptions for display
if len(desc) > 80:
desc = desc[:77] + "..."
tools_found.append((t.name, desc))
await server.shutdown()
try:
_run_on_mcp_loop(_probe(), timeout=connect_timeout + 10)
except BaseException as exc:
raise _unwrap_exception_group(exc) from None
finally:
_stop_mcp_loop()
return tools_found
def _unwrap_exception_group(exc: BaseException) -> Exception:
"""Extract the root-cause exception from anyio TaskGroup wrappers.
The MCP SDK uses anyio task groups, which wrap errors in
``BaseExceptionGroup`` / ``ExceptionGroup``. This makes error
messages opaque ("unhandled errors in a TaskGroup"). We unwrap
to surface the real cause (e.g. "401 Unauthorized").
"""
while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
exc = exc.exceptions[0]
# Return a plain Exception so callers can catch normally
if isinstance(exc, Exception):
return exc
return RuntimeError(str(exc))
# ─── hermes mcp add ──────────────────────────────────────────────────────────
def cmd_mcp_add(args):
"""Add a new MCP server with discovery-first tool selection."""
name = args.name
url = getattr(args, "url", None)
# Read from `mcp_command` (set by --command via explicit dest) — see
# mcp_add_p.add_argument("--command", dest="mcp_command", ...) in
# hermes_cli/main.py for why the dest is renamed.
command = getattr(args, "mcp_command", None)
cmd_args = getattr(args, "args", None) or []
auth_type = getattr(args, "auth", None)
preset_name = getattr(args, "preset", None)
raw_env = getattr(args, "env", None)
server_config: Dict[str, Any] = {}
try:
explicit_env = _parse_env_assignments(raw_env)
url, command, cmd_args, _preset_applied = _apply_mcp_preset(
name,
preset_name=preset_name,
url=url,
command=command,
cmd_args=list(cmd_args),
server_config=server_config,
)
except ValueError as exc:
_error(str(exc))
return
if url and explicit_env:
_error("--env is only supported for stdio MCP servers (--command or stdio presets)")
return
# Validate transport
if not url and not command:
_error("Must specify --url <endpoint>, --command <cmd>, or --preset <name>")
_info("Examples:")
_info(' hermes mcp add ink --url "https://mcp.ml.ink/mcp"')
_info(' hermes mcp add github --command npx --args @modelcontextprotocol/server-github')
_info(' hermes mcp add myserver --preset mypreset')
return
# Check if server already exists
existing = _get_mcp_servers()
if name in existing:
if not _confirm(f"Server '{name}' already exists. Overwrite?", default=False):
_info("Cancelled.")
return
# Build initial config
if url:
server_config["url"] = url
else:
server_config["command"] = command
if cmd_args:
server_config["args"] = cmd_args
if explicit_env:
server_config["env"] = explicit_env
# ── Authentication ────────────────────────────────────────────────
if url and auth_type == "oauth":
print()
_info(f"Starting OAuth flow for '{name}'...")
oauth_ok = False
try:
from tools.mcp_oauth_manager import get_manager
oauth_auth = get_manager().get_or_build_provider(name, url, None)
if oauth_auth:
server_config["auth"] = "oauth"
_success("OAuth configured (tokens will be acquired on first connection)")
oauth_ok=True
else:
_warning("OAuth setup failed — MCP SDK auth module not available")
except Exception as exc:
_warning(f"OAuth error: {exc}")
if not oauth_ok:
_info("This server may not support OAuth.")
if _confirm("Continue without authentication?", default=True):
# Don't store auth: oauth — server doesn't support it
pass
else:
_info("Cancelled.")
return
elif url:
# Prompt for API key / Bearer token for HTTP servers
print()
_info(f"Connecting to {url}")
needs_auth = _confirm("Does this server require authentication?", default=True)
if needs_auth:
if auth_type == "header" or not auth_type:
env_key = _env_key_for_server(name)
existing_key = get_env_value(env_key)
if existing_key:
_success(f"{env_key}: already configured")
api_key = existing_key
else:
api_key = _prompt("API key / Bearer token", password=True)
if api_key:
save_env_value(env_key, api_key)
_success(f"Saved to {display_hermes_home()}/.env as {env_key}")
# Set header with env var interpolation
if api_key or existing_key:
server_config["headers"] = {
"Authorization": f"Bearer ${{{env_key}}}"
}
# ── Discovery: connect and list tools ─────────────────────────────
print()
print(color(f" Connecting to '{name}'...", Colors.CYAN))
try:
tools = _probe_single_server(name, server_config)
except Exception as exc:
_error(f"Failed to connect: {exc}")
if _confirm("Save config anyway (you can test later)?", default=False):
server_config["enabled"] = False
_save_mcp_server(name, server_config)
_success(f"Saved '{name}' to config (disabled)")
_info("Fix the issue, then: hermes mcp test " + name)
return
if not tools:
_warning("Server connected but reported no tools.")
if _confirm("Save config anyway?", default=True):
_save_mcp_server(name, server_config)
_success(f"Saved '{name}' to config")
return
# ── Tool selection ────────────────────────────────────────────────
print()
_success(f"Connected! Found {len(tools)} tool(s) from '{name}':")
print()
for tool_name, desc in tools:
short = desc[:60] + "..." if len(desc) > 60 else desc
print(f" {color(tool_name, Colors.GREEN):40s} {short}")
print()
# Ask: enable all, select, or cancel
try:
choice = input(
color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW)
).strip().lower()
except (KeyboardInterrupt, EOFError):
print()
_info("Cancelled.")
return
if choice in {"n", "no"}:
_info("Cancelled — server not saved.")
return
if choice in {"s", "select"}:
# Interactive tool selection
from hermes_cli.curses_ui import curses_checklist
labels = [f"{t[0]}{t[1]}" for t in tools]
pre_selected = set(range(len(tools)))
chosen = curses_checklist(
f"Select tools for '{name}'",
labels,
pre_selected,
)
if not chosen:
_info("No tools selected — server not saved.")
return
chosen_names = [tools[i][0] for i in sorted(chosen)]
server_config.setdefault("tools", {})["include"] = chosen_names
tool_count = len(chosen_names)
total = len(tools)
else:
# Enable all (no filter needed — default behaviour)
tool_count = len(tools)
total = len(tools)
# ── Save ──────────────────────────────────────────────────────────
server_config["enabled"] = True
_save_mcp_server(name, server_config)
print()
_success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{total} tools enabled)")
_info("Start a new session to use these tools.")
# ─── hermes mcp remove ───────────────────────────────────────────────────────
def cmd_mcp_remove(args):
"""Remove an MCP server from config."""
name = args.name
existing = _get_mcp_servers()
if name not in existing:
_error(f"Server '{name}' not found in config.")
servers = list(existing.keys())
if servers:
_info(f"Available servers: {', '.join(servers)}")
return
if not _confirm(f"Remove server '{name}'?", default=True):
_info("Cancelled.")
return
_remove_mcp_server(name)
_success(f"Removed '{name}' from config")
# Clean up OAuth tokens if they exist — route through MCPOAuthManager so
# any provider instance cached in the current process (e.g. from an
# earlier `hermes mcp test` in the same session) is evicted too.
try:
from tools.mcp_oauth_manager import get_manager
get_manager().remove(name)
_success("Cleaned up OAuth tokens")
except Exception:
pass
# ─── hermes mcp list ──────────────────────────────────────────────────────────
def cmd_mcp_list(args=None):
"""List all configured MCP servers."""
servers = _get_mcp_servers()
if not servers:
print()
_info("No MCP servers configured.")
print()
_info("Add one with:")
_info(' hermes mcp add <name> --url <endpoint>')
_info(' hermes mcp add <name> --command <cmd> --args <args...>')
print()
return
print()
print(color(" MCP Servers:", Colors.CYAN + Colors.BOLD))
print()
# Table header
print(f" {'Name':<16} {'Transport':<30} {'Tools':<12} {'Status':<10}")
print(f" {'' * 16} {'' * 30} {'' * 12} {'' * 10}")
for name, cfg in servers.items():
# Transport info
if "url" in cfg:
url = cfg["url"]
# Truncate long URLs
if len(url) > 28:
url = url[:25] + "..."
transport = url
elif "command" in cfg:
cmd = cfg["command"]
cmd_args = cfg.get("args", [])
if isinstance(cmd_args, list) and cmd_args:
transport = f"{cmd} {' '.join(str(a) for a in cmd_args[:2])}"
else:
transport = cmd
if len(transport) > 28:
transport = transport[:25] + "..."
else:
transport = "?"
# Tool count
tools_cfg = cfg.get("tools", {})
if isinstance(tools_cfg, dict):
include = tools_cfg.get("include")
exclude = tools_cfg.get("exclude")
if include and isinstance(include, list):
tools_str = f"{len(include)} selected"
elif exclude and isinstance(exclude, list):
tools_str = f"-{len(exclude)} excluded"
else:
tools_str = "all"
else:
tools_str = "all"
# Enabled status
enabled = cfg.get("enabled", True)
if isinstance(enabled, str):
enabled = enabled.lower() in {"true", "1", "yes"}
status = color("✓ enabled", Colors.GREEN) if enabled else color("✗ disabled", Colors.DIM)
print(f" {name:<16} {transport:<30} {tools_str:<12} {status}")
print()
# ─── hermes mcp test ──────────────────────────────────────────────────────────
def cmd_mcp_test(args):
"""Test connection to an MCP server."""
name = args.name
servers = _get_mcp_servers()
if name not in servers:
_error(f"Server '{name}' not found in config.")
available = list(servers.keys())
if available:
_info(f"Available: {', '.join(available)}")
return
cfg = servers[name]
print()
print(color(f" Testing '{name}'...", Colors.CYAN))
# Show transport info
if "url" in cfg:
_info(f"Transport: HTTP → {cfg['url']}")
else:
cmd = cfg.get("command", "?")
_info(f"Transport: stdio → {cmd}")
# Show auth info (masked)
auth_type = cfg.get("auth", "")
headers = cfg.get("headers", {})
if auth_type == "oauth":
_info("Auth: OAuth 2.1 PKCE")
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)
if len(resolved) > 8:
masked = resolved[:4] + "***" + resolved[-4:]
else:
masked = "***"
print(f" {k}: {masked}")
else:
_info("Auth: none")
# Attempt connection
start = time.monotonic()
try:
tools = _probe_single_server(name, cfg)
elapsed_ms = (time.monotonic() - start) * 1000
except Exception as exc:
elapsed_ms = (time.monotonic() - start) * 1000
_error(f"Connection failed ({elapsed_ms:.0f}ms): {exc}")
return
_success(f"Connected ({elapsed_ms:.0f}ms)")
_success(f"Tools discovered: {len(tools)}")
if tools:
print()
for tool_name, desc in tools:
short = desc[:55] + "..." if len(desc) > 55 else desc
print(f" {color(tool_name, Colors.GREEN):36s} {short}")
print()
# ─── hermes mcp login ────────────────────────────────────────────────────────
def cmd_mcp_login(args):
"""Force re-authentication for an OAuth-based MCP server.
Deletes cached tokens (both on disk and in the running process's
MCPOAuthManager cache) and triggers a fresh OAuth flow via the
existing probe path.
Use this when:
- Tokens are stuck in a bad state (server revoked, refresh token
consumed by an external process, etc.)
- You want to re-authenticate to change scopes or account
- A tool call returned ``needs_reauth: true``
"""
name = args.name
servers = _get_mcp_servers()
if name not in servers:
_error(f"Server '{name}' not found in config.")
if servers:
_info(f"Available servers: {', '.join(servers)}")
return
server_config = servers[name]
url = server_config.get("url")
if not url:
_error(f"Server '{name}' has no URL — not an OAuth-capable server")
return
if server_config.get("auth") != "oauth":
_error(f"Server '{name}' is not configured for OAuth (auth={server_config.get('auth')})")
_info("Use `hermes mcp remove` + `hermes mcp add` to reconfigure auth.")
return
# Wipe both disk and in-memory cache so the next probe forces a fresh
# OAuth flow.
try:
from tools.mcp_oauth_manager import get_manager
mgr = get_manager()
mgr.remove(name)
except Exception as exc:
_warning(f"Could not clear existing OAuth state: {exc}")
print()
_info(f"Starting OAuth flow for '{name}'...")
# Probe triggers the OAuth flow (browser redirect + callback capture).
try:
tools = _probe_single_server(name, server_config)
if tools:
_success(f"Authenticated — {len(tools)} tool(s) available")
else:
_success("Authenticated (server reported no tools)")
except Exception as exc:
_error(f"Authentication failed: {exc}")
# ─── hermes mcp configure ────────────────────────────────────────────────────
def cmd_mcp_configure(args):
"""Reconfigure which tools are enabled for an existing MCP server."""
import sys as _sys
if not _sys.stdin.isatty():
print("Error: 'hermes mcp configure' requires an interactive terminal.", file=_sys.stderr)
_sys.exit(1)
name = args.name
servers = _get_mcp_servers()
if name not in servers:
_error(f"Server '{name}' not found in config.")
available = list(servers.keys())
if available:
_info(f"Available: {', '.join(available)}")
return
cfg = servers[name]
# Discover all available tools
print()
print(color(f" Connecting to '{name}' to discover tools...", Colors.CYAN))
try:
all_tools = _probe_single_server(name, cfg)
except Exception as exc:
_error(f"Failed to connect: {exc}")
return
if not all_tools:
_warning("Server reports no tools.")
return
# Determine which are currently enabled
tools_cfg = cfg.get("tools", {})
if isinstance(tools_cfg, dict):
include = tools_cfg.get("include")
exclude = tools_cfg.get("exclude")
else:
include = None
exclude = None
tool_names = [t[0] for t in all_tools]
if include and isinstance(include, list):
include_set = set(include)
pre_selected = {
i for i, tn in enumerate(tool_names) if tn in include_set
}
elif exclude and isinstance(exclude, list):
exclude_set = set(exclude)
pre_selected = {
i for i, tn in enumerate(tool_names) if tn not in exclude_set
}
else:
pre_selected = set(range(len(all_tools)))
currently = len(pre_selected)
total = len(all_tools)
_info(f"Currently {currently}/{total} tools enabled for '{name}'.")
print()
# Interactive checklist
from hermes_cli.curses_ui import curses_checklist
labels = [f"{t[0]}{t[1]}" for t in all_tools]
chosen = curses_checklist(
f"Select tools for '{name}'",
labels,
pre_selected,
)
if chosen == pre_selected:
_info("No changes made.")
return
# Update config
config = load_config()
server_entry = cfg_get(config, "mcp_servers", name, default={})
if len(chosen) == total:
# All selected → remove include/exclude (register all)
server_entry.pop("tools", None)
else:
chosen_names = [tool_names[i] for i in sorted(chosen)]
server_entry.setdefault("tools", {})
server_entry["tools"]["include"] = chosen_names
server_entry["tools"].pop("exclude", None)
config.setdefault("mcp_servers", {})[name] = server_entry
save_config(config)
new_count = len(chosen)
_success(f"Updated config: {new_count}/{total} tools enabled")
_info("Start a new session for changes to take effect.")
# ─── Dispatcher ───────────────────────────────────────────────────────────────
def mcp_command(args):
"""Main dispatcher for ``hermes mcp`` subcommands."""
action = getattr(args, "mcp_action", None)
if action == "serve":
from mcp_serve import run_mcp_server
run_mcp_server(verbose=getattr(args, "verbose", False))
return
# Catalog subcommands live in mcp_picker / mcp_catalog. Import lazily so
# the original `mcp_config` module stays import-cheap.
if action == "picker":
from hermes_cli.mcp_picker import run_picker
run_picker()
return
if action == "catalog":
from hermes_cli.mcp_picker import show_catalog
show_catalog()
return
if action == "install":
from hermes_cli.mcp_picker import install_by_name
import sys as _sys
rc = install_by_name(getattr(args, "identifier", "") or "")
if rc:
_sys.exit(rc)
return
handlers = {
"add": cmd_mcp_add,
"remove": cmd_mcp_remove,
"rm": cmd_mcp_remove,
"list": cmd_mcp_list,
"ls": cmd_mcp_list,
"test": cmd_mcp_test,
"configure": cmd_mcp_configure,
"config": cmd_mcp_configure,
"login": cmd_mcp_login,
}
handler = handlers.get(action)
if handler:
handler(args)
else:
# No subcommand — drop the user into the catalog picker. This is the
# "try enabling and it flows you into setup" UX matching `hermes plugin`.
from hermes_cli.mcp_picker import run_picker
run_picker()
print(color(" Commands:", Colors.CYAN))
_info("hermes mcp Open the catalog picker (default)")
_info("hermes mcp catalog List Nous-approved MCPs")
_info("hermes mcp install <name> Install a catalog MCP")
_info("hermes mcp serve Run as MCP server")
_info("hermes mcp add <name> --url <endpoint> Add a custom MCP server")
_info("hermes mcp add <name> --command <cmd> Add a stdio server")
_info("hermes mcp add <name> --preset <preset> Add from a known preset")
_info("hermes mcp remove <name> Remove a server")
_info("hermes mcp list List configured servers")
_info("hermes mcp test <name> Test connection")
_info("hermes mcp configure <name> Toggle tools")
_info("hermes mcp login <name> Re-authenticate OAuth")
print()