Files
hermes-agent/hermes_cli/mcp_catalog.py
T

843 lines
33 KiB
Python

"""MCP catalog — curated, Nous-approved MCP servers shipped with the repo.
Catalog policy: - Entries are added only by merging a PR into hermes-agent. Presence in the
``optional-mcps/`` directory = Nous approval. No community tier, no trust signals beyond "it's in
the catalog". - Manifests pin transport details (commands, args, refs).
"""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from hermes_constants import get_hermes_home, get_optional_mcps_dir
from hermes_cli._subprocess_compat import noninteractive_git_env
from hermes_cli.colors import Colors, color
from hermes_cli.config import (
load_config,
save_config,
get_env_value,
save_env_value,
)
from hermes_cli.cli_output import prompt as _prompt_input
_MANIFEST_VERSION = 1
# Substituted at install time inside `transport.command` / `transport.args`.
_INSTALL_DIR_VAR = "${INSTALL_DIR}"
# ─── Data classes ────────────────────────────────────────────────────────────
@dataclass
class EnvVarSpec:
name: str
prompt: str
required: bool = True
secret: bool = True
default: str = ""
@dataclass
class AuthSpec:
type: str # "api_key" | "oauth" | "none"
env: List[EnvVarSpec] = field(default_factory=list)
# OAuth-specific (case 2: third-party provider like Google)
provider: Optional[str] = None
scopes: List[str] = field(default_factory=list)
env_var: Optional[str] = None
@dataclass
class TransportSpec:
type: str # "stdio" | "http"
command: Optional[str] = None
args: List[str] = field(default_factory=list)
url: Optional[str] = None
version: Optional[str] = None # informational, pinned
# Static environment variables for the stdio subprocess (e.g. telemetry
# opt-outs, mode flags). NOT for secrets — credentials go through
# auth.env so they are prompted for and land in ~/.hermes/.env.
env: Dict[str, str] = field(default_factory=dict)
@dataclass
class InstallSpec:
"""Optional bootstrap step (git clone + dep install)."""
type: str # "git"
url: str
ref: str # commit/tag/branch — pinned, never floats
bootstrap: List[str] = field(default_factory=list)
@dataclass
class ToolsSpec:
"""Manifest-side tool-selection hints.
Drives the pre-checked state of the install-time tool checklist, and acts as the fallback
selection when probe fails. See install_entry() flow.
"""
# If declared, these tool names are pre-checked in the checklist (or
# applied directly when probe fails). If None, all probed tools are
# pre-checked (or no filter is written when probe fails).
default_enabled: Optional[List[str]] = None
# Exclude-mode counterpart: tool names/glob patterns written to
# ``mcp_servers.<name>.tools.exclude`` at install time. Everything NOT
# matching stays enabled — including tools the server adds later. Use for
# huge auto-generated surfaces (OpenAPI-derived MCPs) where an include
# list would be thousands of lines and freeze out new endpoints.
# Mutually exclusive with ``default_enabled``.
default_excluded: Optional[List[str]] = None
@dataclass
class SuggestSpec:
"""Composer-suggestion metadata (desktop "brand pill" triggers).
NOTE: GitHub is intentionally NOT in the catalog and must not be suggested here: its hosted MCP
requires a per-host OAuth app (generic DCR 404s), and the bundled github/* skills (gh CLI) are
the far more capable integration. Point users at the skills instead.
"""
# Lowercase whole-word/phrase triggers matched against the draft.
keywords: List[str] = field(default_factory=list)
# Hostname suffixes ("atlassian.net") matched against pasted links.
hosts: List[str] = field(default_factory=list)
@dataclass
class CatalogEntry:
name: str
description: str
source: str
transport: TransportSpec
auth: AuthSpec
tools: ToolsSpec = field(default_factory=ToolsSpec)
install: Optional[InstallSpec] = None
post_install: str = ""
suggest: Optional[SuggestSpec] = None
manifest_path: Path = field(default_factory=Path)
# ─── Manifest loader ─────────────────────────────────────────────────────────
class CatalogError(Exception):
"""Manifest parse/validation failure or install error."""
def _catalog_root() -> Path:
"""Return the optional-mcps/ directory shipped with this Hermes install."""
# Prefer the env-var override / packaged location; fall back to the repo's
# optional-mcps/ next to the package (source checkout).
return get_optional_mcps_dir(Path(__file__).parent.parent / "optional-mcps")
def _parse_env_spec(raw: Any) -> EnvVarSpec:
if not isinstance(raw, dict):
raise CatalogError(f"env entry must be a mapping, got {type(raw).__name__}")
name = raw.get("name") or ""
if not name or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
raise CatalogError(f"invalid env var name: {name!r}")
return EnvVarSpec(
name=name,
prompt=raw.get("prompt") or name,
required=bool(raw.get("required", True)),
secret=bool(raw.get("secret", True)),
default=str(raw.get("default") or ""),
)
def _require_mapping(path: Path, key: str, raw: Any) -> dict:
if not isinstance(raw, dict):
raise CatalogError(f"{path}: '{key}' must be a mapping")
return raw
def _require_list(path: Path, field: str, raw: Any) -> list:
if not isinstance(raw, list):
raise CatalogError(f"{path}: {field} must be a list")
return raw
def _require_str_list(path: Path, field: str, raw: Any, *, non_empty: bool = False) -> None:
ok = isinstance(raw, list) and all(
isinstance(t, str) and (t.strip() if non_empty else True) for t in raw
)
if not ok:
kind = "non-empty strings" if non_empty else "strings"
raise CatalogError(f"{path}: {field} must be a list of {kind}")
def _parse_manifest(path: Path) -> CatalogEntry:
"""Read and validate a manifest.yaml. Raise CatalogError on any problem."""
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except Exception as exc:
raise CatalogError(f"failed to read {path}: {exc}") from exc
if not isinstance(data, dict):
raise CatalogError(f"{path}: manifest must be a mapping")
mv = data.get("manifest_version")
if mv != _MANIFEST_VERSION:
raise CatalogError(
f"{path}: manifest_version {mv!r} unsupported "
f"(this Hermes understands version {_MANIFEST_VERSION})"
)
name = data.get("name") or ""
if not name or not re.match(r"^[A-Za-z0-9_-]+$", name):
raise CatalogError(f"{path}: invalid or missing 'name'")
description = str(data.get("description") or "").strip()
if not description:
raise CatalogError(f"{path}: 'description' required")
source = str(data.get("source") or "").strip()
transport_raw = _require_mapping(path, "transport", data.get("transport") or {})
t_type = transport_raw.get("type")
if t_type not in ("stdio", "http"):
raise CatalogError(f"{path}: transport.type must be 'stdio' or 'http'")
args = _require_list(path, "transport.args", transport_raw.get("args") or [])
env_raw = transport_raw.get("env") or {}
if not isinstance(env_raw, dict) or not all(
isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()
):
raise CatalogError(
f"{path}: transport.env must be a mapping of string to string"
)
transport = TransportSpec(
type=t_type,
command=transport_raw.get("command"),
args=[str(a) for a in args],
url=transport_raw.get("url"),
version=transport_raw.get("version"),
env=dict(env_raw),
)
if t_type == "stdio" and not transport.command:
raise CatalogError(f"{path}: stdio transport requires 'command'")
if t_type == "http" and not transport.url:
raise CatalogError(f"{path}: http transport requires 'url'")
auth_raw = _require_mapping(path, "auth", data.get("auth") or {"type": "none"})
a_type = auth_raw.get("type") or "none"
if a_type not in ("api_key", "oauth", "none"):
raise CatalogError(f"{path}: auth.type must be 'api_key'|'oauth'|'none'")
env_list = [
_parse_env_spec(e)
for e in _require_list(path, "auth.env", auth_raw.get("env") or [])
]
auth = AuthSpec(
type=a_type,
env=env_list,
provider=auth_raw.get("provider"),
scopes=list(auth_raw.get("scopes") or []),
env_var=auth_raw.get("env_var"),
)
if t_type == "http" and a_type == "api_key":
# _build_server_config emits an Authorization header referencing
# ${MCP_<NAME>_API_KEY} (via _bearer_auth_headers), but install_entry
# only persists the env vars DECLARED in auth.env. Enforce the naming
# contract at parse time, or a manifest declaring e.g. N8N_API_KEY
# would install cleanly yet send a literal-placeholder header (401)
# at connect time.
from hermes_cli.mcp_config import _env_key_for_server
_required_key = _env_key_for_server(name)
if all(spec.name != _required_key for spec in env_list):
raise CatalogError(
f"{path}: http + api_key auth requires auth.env to declare "
f"'{_required_key}' (the key the Authorization header references)"
)
tools_raw = _require_mapping(path, "tools", data.get("tools") or {})
default_enabled = tools_raw.get("default_enabled")
default_excluded = tools_raw.get("default_excluded")
for key, val in (("default_enabled", default_enabled), ("default_excluded", default_excluded)):
if val is not None:
_require_str_list(path, f"tools.{key}", val)
if default_enabled is not None and default_excluded is not None:
raise CatalogError(
f"{path}: tools.default_enabled and tools.default_excluded are "
"mutually exclusive"
)
tools_spec = ToolsSpec(default_enabled=default_enabled, default_excluded=default_excluded)
suggest: Optional[SuggestSpec] = None
suggest_raw = data.get("suggest")
if suggest_raw is not None:
_require_mapping(path, "suggest", suggest_raw)
kw_raw = suggest_raw.get("keywords") or []
hosts_raw = suggest_raw.get("hosts") or []
_require_str_list(path, "suggest.keywords", kw_raw, non_empty=True)
_require_str_list(path, "suggest.hosts", hosts_raw, non_empty=True)
if not kw_raw and not hosts_raw:
raise CatalogError(
f"{path}: 'suggest' requires at least one keyword or host"
)
# Normalize: matching is case-insensitive whole-word / host-suffix,
# so store lowercase and let UIs match without re-normalizing.
suggest = SuggestSpec(
keywords=[k.strip().lower() for k in kw_raw],
hosts=[h.strip().lower().lstrip(".") for h in hosts_raw],
)
install: Optional[InstallSpec] = None
install_raw = data.get("install")
if install_raw is not None:
_require_mapping(path, "install", install_raw)
i_type = install_raw.get("type")
if i_type != "git":
raise CatalogError(f"{path}: install.type must be 'git' (got {i_type!r})")
url, ref = install_raw.get("url") or "", install_raw.get("ref") or ""
if not url or not ref:
raise CatalogError(f"{path}: install.url and install.ref are required")
bootstrap = _require_list(path, "install.bootstrap", install_raw.get("bootstrap") or [])
install = InstallSpec(type=i_type, url=url, ref=ref, bootstrap=[str(c) for c in bootstrap])
return CatalogEntry(
name=name,
description=description,
source=source,
transport=transport,
auth=auth,
tools=tools_spec,
install=install,
post_install=str(data.get("post_install") or ""),
suggest=suggest,
manifest_path=path,
)
def list_catalog() -> List[CatalogEntry]:
"""Return all valid catalog entries, sorted by name.
Invalid manifests are skipped silently (CI catches them). Manifests with a future
``manifest_version`` are also skipped but surfaced via :func:`catalog_diagnostics` so UIs can
tell the user their Hermes is out of date.
"""
root = _catalog_root()
if not root.exists():
return []
entries: List[CatalogEntry] = []
_CATALOG_DIAGNOSTICS.clear()
for child in sorted(root.iterdir()):
manifest = child / "manifest.yaml"
if not manifest.is_file():
continue
try:
entries.append(_parse_manifest(manifest))
except CatalogError as exc:
msg = str(exc)
# Recognize the future-manifest error specifically so the UI can
# surface a more actionable nudge than "broken manifest".
future = "manifest_version" in msg and "unsupported" in msg
_CATALOG_DIAGNOSTICS.append(
(child.name, "future_manifest" if future else "invalid", msg)
)
return entries
# Populated by list_catalog(). Inspected by the picker / catalog UIs so the
# user gets actionable feedback instead of a silently-shorter list.
_CATALOG_DIAGNOSTICS: List[tuple] = []
def catalog_diagnostics() -> List[tuple]:
"""Diagnostics from the most recent :func:`list_catalog` call.
Returns ``(entry_name, kind, message)`` tuples; ``kind`` is ``future_manifest`` (newer than
this Hermes understands, update to install) or ``invalid`` (malformed, e.g. user-edited).
"""
return list(_CATALOG_DIAGNOSTICS)
def get_entry(name: str) -> Optional[CatalogEntry]:
"""Look up a single entry by name. ``official/<name>`` prefix accepted."""
if name.startswith("official/"):
name = name[len("official/"):]
return next((e for e in list_catalog() if e.name == name), None)
# ─── Status helpers ──────────────────────────────────────────────────────────
def installed_servers() -> Dict[str, dict]:
"""Return current ``mcp_servers`` block from config.yaml."""
cfg = load_config()
servers = cfg.get("mcp_servers") or {}
return servers if isinstance(servers, dict) else {}
def is_installed(name: str) -> bool:
return name in installed_servers()
def server_enabled(cfg: dict) -> bool:
"""Interpret a server block's ``enabled`` flag (bools, and yes/true/1 strings)."""
enabled = cfg.get("enabled", True)
if isinstance(enabled, str):
return enabled.lower() in {"true", "1", "yes"}
return bool(enabled)
def is_enabled(name: str) -> bool:
cfg = installed_servers().get(name)
return bool(cfg) and server_enabled(cfg)
def remove_server(name: str) -> bool:
"""Drop ``mcp_servers.<name>`` from config.yaml (pruning an empty block). True if it existed."""
cfg = load_config()
servers = cfg.get("mcp_servers") or {}
if name not in servers:
return False
del servers[name]
if not servers:
cfg.pop("mcp_servers", None)
else:
cfg["mcp_servers"] = servers
save_config(cfg)
return True
# ─── Install ─────────────────────────────────────────────────────────────────
def _say(msg: str, colour: str = Colors.GREEN) -> None:
print(color(msg, colour))
def _install_root() -> Path:
"""Where git-bootstrapped MCPs are cloned. Per-user, profile-aware."""
root = get_hermes_home() / "mcp-installs"
root.mkdir(parents=True, exist_ok=True)
return root
def _run_bootstrap(cwd: Path, commands: List[str]) -> None:
"""Execute bootstrap commands in *cwd*. Raise CatalogError on first failure."""
for cmd in commands:
_say(f" $ {cmd}", Colors.DIM)
rc = subprocess.run(cmd, cwd=str(cwd), shell=True).returncode
if rc != 0:
raise CatalogError(f"bootstrap step failed (exit {rc}): {cmd}")
def _do_git_install(entry: CatalogEntry) -> Path:
"""Clone the entry's repo into ``~/.hermes/mcp-installs/<name>`` and run
bootstrap commands. Returns the install directory."""
assert entry.install is not None and entry.install.type == "git"
install = entry.install
dest = _install_root() / entry.name
git = shutil.which("git")
if not git:
raise CatalogError("git is required to install this MCP but was not found on PATH")
if dest.exists():
# Fresh checkout each install — manifest version is the source of truth,
# so wipe + re-clone for determinism.
_say(f" Removing existing install at {dest}", Colors.DIM)
shutil.rmtree(dest)
_say(f" Cloning {install.url} ({install.ref}) → {dest}", Colors.CYAN)
# `git clone --branch` only accepts branches and tags, NOT commit SHAs.
# Detecting SHA-shaped refs upfront avoids a guaranteed stderr leak on
# the fast path (the --branch attempt would always fail noisily for a
# SHA ref before we fall back to full-clone-then-checkout).
is_sha_ref = bool(re.fullmatch(r"[0-9a-f]{7,40}", install.ref))
# Never let an install hang on a credential prompt: catalog installs run
# from CLI commands and dashboard flows where nobody can answer git's
# username/password prompt (private repo, bad remote, auth required).
_git_env = noninteractive_git_env()
def _git(*args: str) -> int:
return subprocess.run(
[git, *args], stdin=subprocess.DEVNULL, env=_git_env
).returncode
if not is_sha_ref and _git(
"clone", "--depth", "1", "--branch", install.ref, install.url, str(dest)
) != 0:
# Branch/tag form failed (unlikely for valid manifests; possible if
# the ref was deleted upstream). Fall through to the full-clone path.
if dest.exists():
shutil.rmtree(dest)
is_sha_ref = True # treat the same as a SHA ref from here
if is_sha_ref:
if _git("clone", install.url, str(dest)) != 0:
raise CatalogError(f"git clone failed for {install.url}")
if _git("-C", str(dest), "checkout", install.ref) != 0:
raise CatalogError(f"git checkout {install.ref} failed")
if install.bootstrap:
_run_bootstrap(dest, install.bootstrap)
return dest
def _expand_install_dir(value: str, install_dir: Optional[Path]) -> str:
if _INSTALL_DIR_VAR not in value:
return value
if install_dir is None:
raise CatalogError(
f"manifest references {_INSTALL_DIR_VAR} but no install block exists"
)
return value.replace(_INSTALL_DIR_VAR, str(install_dir))
def _prompt_env_vars(specs: List[EnvVarSpec]) -> Dict[str, str]:
"""Walk the env spec list, prompting the user for each. Writes secrets and
non-secrets alike to ~/.hermes/.env via save_env_value()."""
collected: Dict[str, str] = {}
for spec in specs:
existing = get_env_value(spec.name)
if existing:
_say(f" ✓ {spec.name} already set in .env")
collected[spec.name] = existing
continue
value = _prompt_input(
spec.prompt,
default=spec.default or None,
password=spec.secret,
)
if value:
save_env_value(spec.name, value)
collected[spec.name] = value
elif spec.required:
raise CatalogError(f"{spec.name} is required but no value was provided")
return collected
def _build_server_config(
entry: CatalogEntry, install_dir: Optional[Path]
) -> dict:
"""Translate a manifest into the ``mcp_servers.<name>`` block format used by hermes_cli/mcp_config.py."""
cfg: dict = {}
t = entry.transport
if t.type == "stdio":
cfg["command"] = _expand_install_dir(t.command or "", install_dir)
if t.args:
cfg["args"] = [_expand_install_dir(a, install_dir) for a in t.args]
if t.env:
cfg["env"] = dict(t.env)
elif t.type == "http":
cfg["url"] = t.url
if entry.auth.type == "oauth":
cfg["auth"] = "oauth"
elif entry.auth.type == "api_key":
from hermes_cli.mcp_config import _bearer_auth_headers
cfg["headers"] = _bearer_auth_headers(entry.name)
return cfg
def _read_prior_tool_list(name: str, key: str) -> Optional[List[str]]:
"""Return the user's prior ``tools.<key>`` (``include``/``exclude``) for *name*, if well-formed.
Read BEFORE a reinstall overwrites the server entry: a prior include list pre-checks the
checklist (tools no longer on the server are dropped at display time), and a user-edited
exclude list survives reinstall instead of being clobbered by the manifest's ``default_excluded``.
"""
tools_cfg = (installed_servers().get(name) or {}).get("tools") or {}
if not isinstance(tools_cfg, dict):
return None
value = tools_cfg.get(key)
ok = isinstance(value, list) and all(isinstance(t, str) for t in value)
return list(value) if ok else None
def _probe_tools(name: str) -> Optional[List[tuple]]:
"""Connect to a freshly-configured MCP and list its tools.
Returns a list of ``(tool_name, description)`` tuples on success, or ``None`` on any failure
(server unreachable, OAuth not yet completed, backing service offline, etc.). Failures are
intentionally swallowed here — the fallback path in :func:`_apply_tool_selection` handles them.
"""
server_cfg = installed_servers().get(name)
if not server_cfg:
return None
try:
# Import lazily so the catalog module stays cheap to load.
from hermes_cli.mcp_config import _probe_single_server
tools = _probe_single_server(name, server_cfg)
return list(tools) if tools is not None else []
except Exception as exc:
# Display the cause but never raise from the install path.
_say(f" Probe failed: {exc}", Colors.YELLOW)
return None
def _write_tools_filter(name: str, mode: str, values: Optional[List[str]]) -> None:
"""Persist ``mcp_servers.<name>.tools.<mode>`` (``include``/``exclude``), clearing the other
mode; ``values=None`` drops the whole tools block (no filter)."""
cfg = load_config()
servers = cfg.setdefault("mcp_servers", {})
server_entry = servers.get(name) or {}
if values is None:
server_entry.pop("tools", None)
else:
tools_block = server_entry.get("tools") or {}
if not isinstance(tools_block, dict):
tools_block = {}
tools_block[mode] = list(values)
tools_block.pop("exclude" if mode == "include" else "include", None)
server_entry["tools"] = tools_block
servers[name] = server_entry
cfg["mcp_servers"] = servers
save_config(cfg)
def _apply_tool_selection(
entry: CatalogEntry,
*,
prior_selection: Optional[List[str]],
prior_exclude: Optional[List[str]] = None,
) -> None:
"""Probe the server and let the user pick which tools to enable.
Probe-success path: - Curses checklist of all probed tools. - Pre-check uses (in priority
order): 1. *prior_selection* (reinstall: preserve what the user had) 2. manifest's
``tools.default_enabled`` 3. all tools (default) - All-on selection clears any filter (no
``tools.include`` written).
Probe-fail path: - If manifest declares ``tools.default_enabled`` → apply directly. - Otherwise
→ leave config with no filter (all on when reachable). - Either way, point the user at ``hermes
mcp configure <name>``.
"""
print()
name = entry.name
configure_hint = f"`hermes mcp configure {name}`"
# Exclude-mode manifests short-circuit the checklist entirely: the curated
# exclude list (names or glob patterns) is written as-is, everything else
# stays enabled — including tools the server adds later. Reinstalls
# preserve the user's own prior filter in EITHER mode: a prior include
# selection falls through to the checklist below, and a prior user-edited
# exclude list is re-written verbatim instead of being clobbered by the
# manifest defaults.
# (No probe announcement here — this path deliberately never probes.)
if entry.tools.default_excluded and prior_selection is None:
edit_hint = (
f"Edit mcp_servers.{name}.tools.exclude in config.yaml or run "
f"{configure_hint} to change."
)
if prior_exclude is not None:
_write_tools_filter(name, "exclude", prior_exclude)
_say(f" Kept your existing exclude list ({len(prior_exclude)} entries). {edit_hint}")
return
_write_tools_filter(name, "exclude", entry.tools.default_excluded)
_say(
f" Applied manifest exclude list ({len(entry.tools.default_excluded)} entries); "
f"everything else stays enabled. {edit_hint}"
)
return
_say(f" Probing '{name}' for available tools...", Colors.CYAN)
probed = _probe_tools(name)
# Probe failure path. Order matters: a reinstall must come out of a
# failed probe with the user's previous filter intact (common for OAuth
# entries — the entry rewrite precedes first auth, so the server is
# regularly unreachable right here), not with the filter reset or wiped.
if probed is None:
manifest_default = entry.tools.default_enabled
refine_hint = f"Run {configure_hint} after the server is reachable to refine."
if prior_selection is not None:
_write_tools_filter(name, "include", prior_selection)
msg = (
f" Couldn't probe server. Kept your previous tool selection "
f"({len(prior_selection)} tools). {refine_hint}"
)
elif prior_exclude is not None:
_write_tools_filter(name, "exclude", prior_exclude)
msg = f" Couldn't probe server. Kept your existing exclude list ({len(prior_exclude)} entries)."
elif manifest_default:
_write_tools_filter(name, "include", manifest_default)
msg = (
f" Couldn't probe server. Applied manifest default "
f"({len(manifest_default)} tools). {refine_hint}"
)
else:
_write_tools_filter(name, "include", None)
msg = (
" Couldn't probe server; installed with no tool filter (all tools enabled when "
f"reachable). Run {configure_hint} after first connect to prune."
)
_say(msg, Colors.YELLOW)
return
if not probed:
# Probe succeeded but server reported zero tools. Nothing to filter.
_write_tools_filter(name, "include", None)
_say(" Server reported no tools.", Colors.YELLOW)
return
tool_names = [t[0] for t in probed]
# Non-TTY: skip the checklist. Priority matches the interactive
# pre-check priority: prior user selection > manifest default > all-on.
import sys as _sys
if not _sys.stdin.isatty():
preferred = (
prior_selection if prior_selection is not None
else (entry.tools.default_enabled or None)
)
_write_tools_filter(
name, "include", None if preferred is None else [n for n in preferred if n in tool_names]
)
return
# Build the pre-checked set in priority order
pre_set = {
n for n in (prior_selection or entry.tools.default_enabled or tool_names)
if n in tool_names
}
pre_indices = {i for i, n in enumerate(tool_names) if n in pre_set}
_say(f" Found {len(probed)} tool(s). Pre-checked: {len(pre_indices)}.")
from hermes_cli.curses_ui import curses_checklist
labels = [
f"{n} — {(d[:60] + '...') if len(d) > 60 else d}"
for n, d in probed
]
chosen_indices = curses_checklist(
f"Select tools for '{name}' (SPACE toggle, ENTER confirm)",
labels,
pre_indices,
)
if not chosen_indices:
# User unchecked everything; treat as "no tools" — write empty include
# so the server is installed but contributes nothing until reconfigured.
_write_tools_filter(name, "include", [])
_say(f" No tools selected. Run {configure_hint} to change.", Colors.YELLOW)
return
if len(chosen_indices) == len(probed):
# Everything selected — clear filter for the cleanest config shape.
# NOTE: this means any tools the server adds later (e.g. a future MCP
# version) will also be auto-enabled. To pin to the current set,
# the user can re-run `hermes mcp configure <name>` and unselect a
# tool to switch back to include-mode.
_write_tools_filter(name, "include", None)
_say(
f" ✓ All {len(probed)} tools enabled (no filter — new tools "
"the server adds later will be auto-enabled)."
)
return
chosen_names = [tool_names[i] for i in sorted(chosen_indices)]
_write_tools_filter(name, "include", chosen_names)
_say(f" ✓ {len(chosen_names)}/{len(probed)} tools enabled.")
def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
"""Install a catalog entry end-to-end.
Order: git clone + bootstrap (if ``install.type == git``); API-key prompt to .env or the
``auth: oauth`` marker; translate the manifest into ``mcp_servers.<name>`` in config.yaml;
probe the server and offer a tool checklist (falling back to ``tools.default_enabled`` or
all-on when the probe fails); print post_install notes.
"""
print()
_say(f" Installing MCP '{entry.name}'", Colors.CYAN + Colors.BOLD)
if entry.description:
_say(f" {entry.description}", Colors.DIM)
if entry.source:
_say(f" Source: {entry.source}", Colors.DIM)
print()
install_dir = _do_git_install(entry) if entry.install is not None else None
# Auth
if entry.auth.type == "api_key":
print()
_say(" Configure credentials:", Colors.CYAN)
_prompt_env_vars(entry.auth.env)
elif entry.auth.type == "oauth" and entry.auth.provider:
# Case 2: provider-mediated (Google, GitHub, etc.). We rely on
# the existing `hermes auth <provider>` flow. Surface guidance
# here rather than auto-running it — keeps the catalog install
# decoupled from provider-auth lifecycle.
_say(
f" This MCP uses {entry.auth.provider} OAuth. Run "
f"`hermes auth {entry.auth.provider}` if you have not "
"already authenticated.",
Colors.YELLOW,
)
elif entry.auth.type == "oauth":
_say(
" This MCP uses native OAuth 2.1; tokens will be acquired "
"on first connection (browser flow).",
Colors.DIM,
)
# auth.type == "none": nothing to do.
# ── Preserve any prior user tool selection across reinstalls ────────
# Reading BEFORE we overwrite the entry below so a reinstall pre-checks
# whatever the user picked last time (include mode) or keeps the user's
# edited exclude list (exclude mode).
prior_selection = _read_prior_tool_list(entry.name, "include")
prior_exclude = _read_prior_tool_list(entry.name, "exclude")
# Build and write the mcp_servers entry (without tools filter yet;
# _apply_tool_selection() finalizes it below).
server_cfg = _build_server_config(entry, install_dir)
server_cfg["enabled"] = enable
from hermes_cli.mcp_config import _save_mcp_server
if not _save_mcp_server(entry.name, server_cfg):
raise CatalogError(
f"catalog entry '{entry.name}' rejected: suspicious command/args configuration"
)
# ── Probe + tool selection ──────────────────────────────────────────
_apply_tool_selection(
entry, prior_selection=prior_selection, prior_exclude=prior_exclude
)
print()
_say(
f" ✓ Installed '{entry.name}' "
f"({'enabled' if enable else 'disabled'}). "
f"Start a new Hermes session to load its tools."
)
if entry.post_install:
print()
for line in entry.post_install.strip().splitlines():
_say(f" {line}", Colors.DIM)
print()
def uninstall_entry(name: str, *, purge_install_dir: bool = True) -> bool:
"""Remove a catalog-installed MCP from config and (optionally) wipe its
clone directory. Returns True if anything was removed."""
removed = remove_server(name)
if purge_install_dir:
clone = _install_root() / name
if clone.exists():
shutil.rmtree(clone)
removed = True
return removed