From 8b69ec03af50de892ae0bca1f7e2384a8f6eb5a8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 26 May 2026 12:48:14 -0700 Subject: [PATCH] feat(mcp): Nous-approved MCP catalog with interactive picker (#30870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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..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 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 ' 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. --- hermes_cli/main.py | 18 + hermes_cli/mcp_catalog.py | 776 +++++++++++++++++++++ hermes_cli/mcp_config.py | 31 +- hermes_cli/mcp_picker.py | 322 +++++++++ hermes_cli/tips.py | 3 + hermes_cli/tools_config.py | 21 +- hermes_constants.py | 19 + optional-mcps/linear/manifest.yaml | 38 ++ optional-mcps/n8n/manifest.yaml | 77 +++ tests/hermes_cli/test_mcp_catalog.py | 794 ++++++++++++++++++++++ tests/hermes_cli/test_mcp_tools_config.py | 25 +- website/docs/reference/cli-commands.md | 5 +- website/docs/user-guide/features/mcp.md | 120 ++++ 13 files changed, 2226 insertions(+), 23 deletions(-) create mode 100644 hermes_cli/mcp_catalog.py create mode 100644 hermes_cli/mcp_picker.py create mode 100644 optional-mcps/linear/manifest.yaml create mode 100644 optional-mcps/n8n/manifest.yaml create mode 100644 tests/hermes_cli/test_mcp_catalog.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a4578b16d1a..82df98f395d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13018,6 +13018,24 @@ Examples: ) mcp_login_p.add_argument("name", help="Server name to re-authenticate") + # ── Catalog (Nous-approved MCPs shipped with the repo) ───────────────── + mcp_sub.add_parser( + "picker", + help="Interactive catalog picker (also the default for `hermes mcp`)", + ) + mcp_sub.add_parser( + "catalog", + help="List Nous-approved MCPs available for one-click install", + ) + mcp_install_p = mcp_sub.add_parser( + "install", + help="Install a catalog MCP by name (e.g. `hermes mcp install n8n`)", + ) + mcp_install_p.add_argument( + "identifier", + help="Catalog entry name (or `official/`)", + ) + _add_accept_hooks_flag(mcp_parser) def cmd_mcp(args): diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py new file mode 100644 index 00000000000..18214767590 --- /dev/null +++ b/hermes_cli/mcp_catalog.py @@ -0,0 +1,776 @@ +"""MCP catalog — curated, Nous-approved MCP servers shipped with the repo. + +Mirrors the optional-skills/ pattern: each catalog entry lives under +``optional-mcps//manifest.yaml`` and ships disabled. Users discover +entries via ``hermes mcp catalog`` or the interactive ``hermes mcp picker``, +and install them with ``hermes mcp install `` (or by toggling in the +picker, which flows them through any required env/OAuth setup). + +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). MCPs are never + auto-updated; users explicitly re-run ``hermes mcp install `` to + pull a new manifest version after a repo update. +- Secrets prompted at install time go to ``~/.hermes/.env`` (the + .env-is-for-secrets rule). Non-secret env vars also go to .env to keep + one credential store. + +See website/docs/user-guide/mcp-catalog.md for user docs. +See references/mcp-catalog.md (this repo's skill) for the manifest schema. +""" + +from __future__ import annotations + +import os +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.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, prompt_yes_no + +_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 + + +@dataclass +class InstallSpec: + """Optional bootstrap step (git clone + dep install). + + Omit for one-shot launchable servers (npx, uvx). + """ + 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 + + +@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 = "" + 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 _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 = data.get("transport") or {} + if not isinstance(transport_raw, dict): + raise CatalogError(f"{path}: 'transport' must be a mapping") + 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 = transport_raw.get("args") or [] + if not isinstance(args, list): + raise CatalogError(f"{path}: transport.args must be a list") + 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"), + ) + 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 = data.get("auth") or {"type": "none"} + if not isinstance(auth_raw, dict): + raise CatalogError(f"{path}: 'auth' must be a mapping") + 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_raw = auth_raw.get("env") or [] + if not isinstance(env_list_raw, list): + raise CatalogError(f"{path}: auth.env must be a list") + env_list = [_parse_env_spec(e) for e in env_list_raw] + 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"), + ) + + tools_raw = data.get("tools") or {} + if not isinstance(tools_raw, dict): + raise CatalogError(f"{path}: 'tools' must be a mapping") + default_enabled = tools_raw.get("default_enabled") + if default_enabled is not None: + if not isinstance(default_enabled, list) or not all( + isinstance(t, str) for t in default_enabled + ): + raise CatalogError( + f"{path}: tools.default_enabled must be a list of strings" + ) + tools_spec = ToolsSpec(default_enabled=default_enabled) + + install: Optional[InstallSpec] = None + install_raw = data.get("install") + if install_raw is not None: + if not isinstance(install_raw, dict): + raise CatalogError(f"{path}: 'install' must be a mapping") + i_type = install_raw.get("type") + if i_type != "git": + raise CatalogError(f"{path}: install.type must be 'git' (got {i_type!r})") + url = install_raw.get("url") or "" + ref = install_raw.get("ref") or "" + if not url or not ref: + raise CatalogError(f"{path}: install.url and install.ref are required") + bootstrap = install_raw.get("bootstrap") or [] + if not isinstance(bootstrap, list): + raise CatalogError(f"{path}: install.bootstrap must be a list") + 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 ""), + manifest_path=path, + ) + + +def list_catalog() -> List[CatalogEntry]: + """Return all valid catalog entries, sorted by name. + + Invalid manifests are skipped silently (CI tests catch them at PR time). + Manifests with a future ``manifest_version`` are also skipped, but the + skip is surfaced via :func:`catalog_diagnostics` so the picker / catalog + 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". + if "manifest_version" in msg and "unsupported" in msg: + _CATALOG_DIAGNOSTICS.append((child.name, "future_manifest", msg)) + else: + _CATALOG_DIAGNOSTICS.append((child.name, "invalid", msg)) + continue + 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 a list of ``(entry_name, kind, message)`` tuples where ``kind`` + is one of: + - ``future_manifest`` — manifest_version is newer than this Hermes + understands. Update Hermes to install this entry. + - ``invalid`` — manifest is malformed in some other way (caught by + CI for shipped manifests; user-modified manifests can hit this). + """ + return list(_CATALOG_DIAGNOSTICS) + + +def get_entry(name: str) -> Optional[CatalogEntry]: + """Look up a single entry by name. ``official/`` prefix accepted.""" + if name.startswith("official/"): + name = name[len("official/"):] + for entry in list_catalog(): + if entry.name == name: + return entry + return 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 is_enabled(name: str) -> bool: + servers = installed_servers() + cfg = servers.get(name) + if not cfg: + return False + enabled = cfg.get("enabled", True) + if isinstance(enabled, str): + return enabled.lower() in {"true", "1", "yes"} + return bool(enabled) + + +# ─── Install ───────────────────────────────────────────────────────────────── + + +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. + + Each command runs through the shell (so `&&` etc. work). The output is + streamed to the user's terminal for visibility. + """ + for cmd in commands: + print(color(f" $ {cmd}", Colors.DIM)) + proc = subprocess.run(cmd, cwd=str(cwd), shell=True) + if proc.returncode != 0: + raise CatalogError( + f"bootstrap step failed (exit {proc.returncode}): {cmd}" + ) + + +def _do_git_install(entry: CatalogEntry) -> Path: + """Clone the entry's repo into ``~/.hermes/mcp-installs/`` 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. + print(color(f" Removing existing install at {dest}", Colors.DIM)) + shutil.rmtree(dest) + + print(color(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)) + + if not is_sha_ref: + proc = subprocess.run( + [git, "clone", "--depth", "1", "--branch", install.ref, install.url, str(dest)], + ) + if proc.returncode == 0: + pass + else: + # 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: + proc = subprocess.run([git, "clone", install.url, str(dest)]) + if proc.returncode != 0: + raise CatalogError(f"git clone failed for {install.url}") + proc = subprocess.run([git, "-C", str(dest), "checkout", install.ref]) + if proc.returncode != 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: + print(color(f" ✓ {spec.name} already set in .env", Colors.GREEN)) + collected[spec.name] = existing + continue + value = _prompt_input( + spec.prompt, + default=spec.default or None, + password=spec.secret, + ) + if not value: + if spec.required: + raise CatalogError(f"{spec.name} is required but no value was provided") + continue + save_env_value(spec.name, value) + collected[spec.name] = value + return collected + + +def _build_server_config( + entry: CatalogEntry, install_dir: Optional[Path] +) -> dict: + """Translate a manifest into the ``mcp_servers.`` 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] + elif t.type == "http": + cfg["url"] = t.url + if entry.auth.type == "oauth": + cfg["auth"] = "oauth" + return cfg + + +def _read_prior_tool_selection(name: str) -> Optional[List[str]]: + """Return the user's prior `tools.include` for *name*, if any. + + Used during reinstalls so the install-time checklist starts pre-checked + with whatever the user already had. Tools no longer on the server are + silently dropped at checklist-display time. + """ + servers = installed_servers() + cfg = servers.get(name) or {} + tools_cfg = cfg.get("tools") or {} + if not isinstance(tools_cfg, dict): + return None + include = tools_cfg.get("include") + if isinstance(include, list) and all(isinstance(t, str) for t in include): + return list(include) + return 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. + """ + servers = installed_servers() + server_cfg = 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. + print(color(f" Probe failed: {exc}", Colors.YELLOW)) + return None + + +def _write_tools_include(name: str, include: Optional[List[str]]) -> None: + """Persist or clear ``mcp_servers..tools.include``.""" + cfg = load_config() + servers = cfg.setdefault("mcp_servers", {}) + server_entry = servers.get(name) or {} + if include is None: + # No filter — drop any existing tools block. + server_entry.pop("tools", None) + else: + tools_block = server_entry.get("tools") or {} + if not isinstance(tools_block, dict): + tools_block = {} + tools_block["include"] = list(include) + tools_block.pop("exclude", 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]] +) -> 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). + - Sub-selection writes ``tools.include``. + + 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 ``. + """ + print() + print(color(f" Probing '{entry.name}' for available tools...", Colors.CYAN)) + probed = _probe_tools(entry.name) + + # Probe failure path + if probed is None: + manifest_default = entry.tools.default_enabled + if manifest_default: + _write_tools_include(entry.name, manifest_default) + print(color( + f" Couldn\'t probe server. Applied manifest default " + f"({len(manifest_default)} tools). " + f"Run `hermes mcp configure {entry.name}` after the server " + "is reachable to refine.", + Colors.YELLOW, + )) + else: + _write_tools_include(entry.name, None) + print(color( + f" Couldn\'t probe server; installed with no tool filter " + "(all tools enabled when reachable). " + f"Run `hermes mcp configure {entry.name}` after first " + "connect to prune.", + Colors.YELLOW, + )) + return + + if not probed: + # Probe succeeded but server reported zero tools. Nothing to filter. + _write_tools_include(entry.name, None) + print(color(" Server reported no tools.", Colors.YELLOW)) + return + + tool_names = [t[0] for t in probed] + + # Build the pre-checked set in priority order + if prior_selection: + pre_set = {n for n in prior_selection if n in tool_names} + elif entry.tools.default_enabled: + pre_set = {n for n in entry.tools.default_enabled if n in tool_names} + else: + pre_set = set(tool_names) + + pre_indices = {i for i, n in enumerate(tool_names) if n in pre_set} + + # 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(): + if prior_selection is not None: + include = [n for n in prior_selection if n in tool_names] + _write_tools_include(entry.name, include) + elif entry.tools.default_enabled: + include = [n for n in entry.tools.default_enabled if n in tool_names] + _write_tools_include(entry.name, include) + else: + _write_tools_include(entry.name, None) + return + + print(color( + f" Found {len(probed)} tool(s). " + f"Pre-checked: {len(pre_indices)}.", + Colors.GREEN, + )) + + 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 '{entry.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_include(entry.name, []) + print(color( + f" No tools selected. Run `hermes mcp configure {entry.name}` " + "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 ` and unselect a + # tool to switch back to include-mode. + _write_tools_include(entry.name, None) + print(color( + f" ✓ All {len(probed)} tools enabled (no filter — new tools " + "the server adds later will be auto-enabled).", + Colors.GREEN, + )) + return + + chosen_names = [tool_names[i] for i in sorted(chosen_indices)] + _write_tools_include(entry.name, chosen_names) + print(color( + f" ✓ {len(chosen_names)}/{len(probed)} tools enabled.", + Colors.GREEN, + )) + + +def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: + """Install a catalog entry end-to-end. + + Steps: + 1. If ``install.type == git``, clone + run bootstrap commands. + 2. If ``auth.type == api_key``, prompt for env vars, save to .env. + 3. If ``auth.type == oauth`` (remote MCP / case 1), write the + ``auth: oauth`` marker (MCP client handles browser on first connect + in the non-pre-authenticated case). + 4. Translate the manifest into an ``mcp_servers.`` block and + save into config.yaml. + 5. Probe the server, present a curses checklist for tool selection, + write ``tools.include`` (or no filter, depending on choice). + If probe fails, fall back to the manifest's + ``tools.default_enabled`` or all-on. + 6. Print post_install notes. + """ + print() + print(color(f" Installing MCP '{entry.name}'", Colors.CYAN + Colors.BOLD)) + if entry.description: + print(color(f" {entry.description}", Colors.DIM)) + if entry.source: + print(color(f" Source: {entry.source}", Colors.DIM)) + print() + + install_dir: Optional[Path] = None + if entry.install is not None: + install_dir = _do_git_install(entry) + + # Auth + if entry.auth.type == "api_key": + print() + print(color(" Configure credentials:", Colors.CYAN)) + _prompt_env_vars(entry.auth.env) + elif entry.auth.type == "oauth": + if entry.auth.provider: + # Case 2: provider-mediated (Google, GitHub, etc.). We rely on + # the existing `hermes auth ` flow. Surface guidance + # here rather than auto-running it — keeps the catalog install + # decoupled from provider-auth lifecycle. + print(color( + f" This MCP uses {entry.auth.provider} OAuth. Run " + f"`hermes auth {entry.auth.provider}` if you have not " + "already authenticated.", + Colors.YELLOW, + )) + else: + print(color( + " 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. + prior_selection = _read_prior_tool_selection(entry.name) + + # 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 + + cfg = load_config() + cfg.setdefault("mcp_servers", {})[entry.name] = server_cfg + save_config(cfg) + + # ── Probe + tool selection ────────────────────────────────────────── + _apply_tool_selection(entry, prior_selection=prior_selection) + + print() + print(color( + f" ✓ Installed '{entry.name}' " + f"({'enabled' if enable else 'disabled'}). " + f"Start a new Hermes session to load its tools.", + Colors.GREEN, + )) + if entry.post_install: + print() + for line in entry.post_install.strip().splitlines(): + print(color(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.""" + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + removed = False + if name in servers: + del servers[name] + if not servers: + cfg.pop("mcp_servers", None) + else: + cfg["mcp_servers"] = servers + save_config(cfg) + removed = True + + if purge_install_dir: + clone = _install_root() / name + if clone.exists(): + shutil.rmtree(clone) + removed = True + + return removed diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index ed9d7b5f6db..0a1ca336193 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -749,6 +749,24 @@ def mcp_command(args): 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, @@ -765,15 +783,20 @@ def mcp_command(args): if handler: handler(args) else: - # No subcommand — show list - cmd_mcp_list() + # 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 Install a catalog MCP") _info("hermes mcp serve Run as MCP server") - _info("hermes mcp add --url Add an MCP server") + _info("hermes mcp add --url Add a custom MCP server") _info("hermes mcp add --command Add a stdio server") _info("hermes mcp add --preset Add from a known preset") _info("hermes mcp remove Remove a server") - _info("hermes mcp list List servers") + _info("hermes mcp list List configured servers") _info("hermes mcp test Test connection") _info("hermes mcp configure Toggle tools") _info("hermes mcp login Re-authenticate OAuth") diff --git a/hermes_cli/mcp_picker.py b/hermes_cli/mcp_picker.py new file mode 100644 index 00000000000..8bf2beffaf9 --- /dev/null +++ b/hermes_cli/mcp_picker.py @@ -0,0 +1,322 @@ +"""MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`). + +Lists every catalog entry plus any custom MCP servers the user has added via +``hermes mcp add``, lets them pick one, and routes to install / enable / +disable / uninstall / configure-tools flows. + +Mirrors the `hermes plugin` picker UX: arrow keys to navigate, ENTER on a row +to act on it. The action depends on current status: + + not installed (catalog) → install (clone/bootstrap if needed, prompt for creds) + installed / disabled → enable + installed / enabled → submenu: configure tools / disable / uninstall / reinstall + custom (non-catalog) → submenu: configure tools / enable / disable / remove + +The picker loops until the user hits ESC/q so they can manage multiple +entries in one session. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import List, Optional + +from hermes_cli.colors import Colors, color +from hermes_cli.cli_output import prompt_yes_no +from hermes_cli.curses_ui import curses_single_select +from hermes_cli.mcp_catalog import ( + CatalogEntry, + CatalogError, + catalog_diagnostics, + install_entry, + is_enabled, + is_installed, + list_catalog, + installed_servers, + uninstall_entry, +) +from hermes_cli.config import load_config, save_config + + +# ─── Status badges ──────────────────────────────────────────────────────────── + +_STATUS_NOT_INSTALLED = "available" +_STATUS_DISABLED = "installed (disabled)" +_STATUS_ENABLED = "enabled" +_STATUS_CUSTOM_ENABLED = "custom — enabled" +_STATUS_CUSTOM_DISABLED = "custom — disabled" + + +# ─── Row model — unifies catalog and custom entries ────────────────────────── + + +@dataclass +class _Row: + """A row in the picker. ``entry`` is set for catalog rows; for custom + user-added MCPs only ``name`` + ``description`` + status are populated.""" + + name: str + description: str + status: str + entry: Optional[CatalogEntry] = None # None for non-catalog (custom) rows + + @property + def is_custom(self) -> bool: + return self.entry is None + + +def _build_rows() -> List[_Row]: + """Return catalog rows + any custom (non-catalog) MCPs found in config.""" + catalog_entries = list_catalog() + catalog_names = {e.name for e in catalog_entries} + + rows: List[_Row] = [] + for entry in catalog_entries: + if not is_installed(entry.name): + status = _STATUS_NOT_INSTALLED + elif is_enabled(entry.name): + status = _STATUS_ENABLED + else: + status = _STATUS_DISABLED + rows.append( + _Row( + name=entry.name, + description=entry.description, + status=status, + entry=entry, + ) + ) + + # Custom MCPs the user added directly (not in the catalog) + for name, cfg in sorted(installed_servers().items()): + if name in catalog_names: + continue + enabled = cfg.get("enabled", True) + if isinstance(enabled, str): + enabled = enabled.lower() in {"true", "1", "yes"} + status = _STATUS_CUSTOM_ENABLED if enabled else _STATUS_CUSTOM_DISABLED + # Use the transport URL/command as the "description" for custom rows + desc = cfg.get("url") or cfg.get("command") or "(no transport)" + rows.append(_Row(name=name, description=str(desc), status=status)) + + return rows + + +def _format_row(row: _Row) -> str: + return f"{row.name:<18} {row.status:<24} {row.description}" + + +# ─── Actions ────────────────────────────────────────────────────────────────── + + +def _enable_disable(name: str, *, enable: bool) -> None: + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + server = servers.get(name) + if not server: + print(color(f" '{name}' is not installed.", Colors.RED)) + return + server["enabled"] = enable + cfg["mcp_servers"] = servers + save_config(cfg) + print(color( + f" ✓ '{name}' {'enabled' if enable else 'disabled'}. " + "Start a new Hermes session for changes to take effect.", + Colors.GREEN, + )) + + +def _configure_tools(name: str) -> None: + """Open the tool selection checklist for an already-installed MCP. + + Delegates to the existing ``cmd_mcp_configure`` flow which probes the + server, displays a checklist, and writes ``tools.include``. + """ + import argparse + from hermes_cli.mcp_config import cmd_mcp_configure + + cmd_mcp_configure(argparse.Namespace(name=name)) + + +def _remove_custom(name: str) -> None: + """Remove a non-catalog MCP entry from config.yaml.""" + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + if name not in servers: + print(color(f" '{name}' is not configured.", Colors.RED)) + return + if not prompt_yes_no(f"Remove '{name}' from mcp_servers?", default=False): + return + del servers[name] + if not servers: + cfg.pop("mcp_servers", None) + else: + cfg["mcp_servers"] = servers + save_config(cfg) + print(color(f" ✓ Removed '{name}'", Colors.GREEN)) + + +def _handle_row(row: _Row) -> None: + """Act on the picked row based on its current status.""" + # === Catalog row, not yet installed === + if row.entry and not is_installed(row.name): + try: + install_entry(row.entry, enable=True) + except CatalogError as exc: + print(color(f" ✗ install failed: {exc}", Colors.RED)) + return + + # === Catalog row, installed but disabled === + if row.entry and not is_enabled(row.name): + _enable_disable(row.name, enable=True) + return + + # === Catalog row, installed + enabled OR custom row === + if row.is_custom: + # Custom (non-catalog) row submenu + actions = [ + "Configure tools (probe server + re-pick)", + "Enable" if not is_enabled(row.name) else "Disable", + "Remove from config", + ] + choice = curses_single_select(f"Action for '{row.name}' (custom)", actions) + if choice is None: + return + if choice == 0: + _configure_tools(row.name) + elif choice == 1: + _enable_disable(row.name, enable=not is_enabled(row.name)) + elif choice == 2: + _remove_custom(row.name) + return + + # Catalog row, installed + enabled + print() + print(color(f" '{row.name}' is already enabled.", Colors.DIM)) + actions = [ + "Configure tools (probe server + re-pick)", + "Disable (keep config, stop loading on next session)", + "Uninstall (remove config and any cloned files)", + "Reinstall (re-clone, re-prompt for credentials)", + ] + choice = curses_single_select(f"Action for '{row.name}'", actions) + if choice is None: + return + if choice == 0: + _configure_tools(row.name) + elif choice == 1: + _enable_disable(row.name, enable=False) + elif choice == 2: + if prompt_yes_no(f"Uninstall '{row.name}'?", default=False): + if uninstall_entry(row.name): + print(color( + f" ✓ Uninstalled '{row.name}'. " + "Credentials in .env preserved — delete manually if no longer needed.", + Colors.GREEN, + )) + else: + print(color(f" '{row.name}' was not installed", Colors.DIM)) + elif choice == 3: + try: + assert row.entry is not None + install_entry(row.entry, enable=True) + except CatalogError as exc: + print(color(f" ✗ reinstall failed: {exc}", Colors.RED)) + + +# ─── Output / entry points ──────────────────────────────────────────────────── + + +def _print_rows_text(rows: List[_Row]) -> None: + """Plain-text catalog dump used as a fallback when curses can't run, and + as the default output of `hermes mcp catalog`.""" + if not rows: + print() + print(color(" No MCPs in the catalog or configured.", Colors.DIM)) + print() + return + + print() + print(color(" MCP Catalog + configured servers:", Colors.CYAN + Colors.BOLD)) + print() + print(f" {'Name':<18} {'Status':<24} Description") + print(f" {'-' * 18} {'-' * 24} {'-' * 11}") + for row in rows: + print(f" {_format_row(row)}") + print() + print(color( + " Install: hermes mcp install Picker: hermes mcp", + Colors.DIM, + )) + + # Surface manifest-version warnings so users know when their Hermes is + # too old to install everything in the catalog. + diags = catalog_diagnostics() + future = [d for d in diags if d[1] == "future_manifest"] + if future: + print() + for name, _, msg in future: + print(color( + f" ⚠ '{name}' requires a newer Hermes — run `hermes update` " + "to install this entry.", + Colors.YELLOW, + )) + print() + print() + + +def show_catalog() -> None: + """`hermes mcp catalog` — print the curated list + custom servers, no interaction.""" + _print_rows_text(_build_rows()) + + +def run_picker() -> None: + """`hermes mcp picker` (and default `hermes mcp`) — interactive selector. + + Loops until the user hits ESC/q. After each action the picker re-renders + so the user can manage several entries in one session. + """ + if not sys.stdin.isatty(): + # Non-interactive shell: degrade to the text dump rather than failing. + _print_rows_text(_build_rows()) + return + + while True: + rows = _build_rows() + if not rows: + _print_rows_text(rows) + return + + labels = [_format_row(r) for r in rows] + idx = curses_single_select( + "MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit", + labels, + ) + if idx is None: + return + _handle_row(rows[idx]) + + +def install_by_name(identifier: str) -> int: + """`hermes mcp install ` — non-interactive entry-point. + + Returns 0 on success, non-zero on failure (so the CLI can propagate + exit codes). + """ + from hermes_cli.mcp_catalog import get_entry + + entry = get_entry(identifier) + if entry is None: + print(color( + f" ✗ '{identifier}' is not in the catalog. " + "Run `hermes mcp catalog` to see available entries.", + Colors.RED, + )) + return 1 + try: + install_entry(entry, enable=True) + except CatalogError as exc: + print(color(f" ✗ install failed: {exc}", Colors.RED)) + return 1 + return 0 diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 2871cc4af8f..786631fa215 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -227,6 +227,9 @@ TIPS = [ "browser_vision with annotate=true overlays numbered labels on interactive elements.", # --- MCP --- + "hermes mcp opens an interactive picker of Nous-approved MCPs you can install in one keystroke.", + "hermes mcp catalog lists Nous-approved MCP servers shipped with the repo.", + "hermes mcp install installs a catalog entry, prompts for credentials, and lets you pick which of its tools to enable.", "MCP servers are configured in config.yaml — both stdio and HTTP transports supported.", "Per-server tool filtering: tools.include whitelists and tools.exclude blacklists specific tools.", "MCP servers auto-generate toolsets at runtime — hermes tools can toggle them per platform.", diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index cbe8a449d27..1306dcfca56 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -3190,21 +3190,26 @@ def _configure_mcp_tools_interactive(config: dict): _print_info(f" {server_name}: no changes") continue - # Compute new exclude list based on unchecked tools - new_exclude = [tool_names[i] for i in range(len(tool_names)) if i not in chosen] + # Compute new include list (the chosen tools). We standardize on + # tools.include across the codebase (catalog installs, hermes mcp + # configure, and this UI) so a server\'s on-disk config shape doesn\'t + # depend on which UI the user touched last. + chosen_names = [tool_names[i] for i in sorted(chosen)] # Update config srv_cfg = mcp_servers.setdefault(server_name, {}) tools_cfg = srv_cfg.setdefault("tools", {}) - if new_exclude: - tools_cfg["exclude"] = new_exclude - # Remove include if present — we're switching to exclude mode - tools_cfg.pop("include", None) - else: - # All tools enabled — clear filters + if len(chosen) == len(tools): + # All tools enabled — clear filters (cleanest config shape; the + # server\'s native tool set is the active set, and any tools the + # server adds later are auto-enabled). tools_cfg.pop("exclude", None) tools_cfg.pop("include", None) + else: + tools_cfg["include"] = chosen_names + # Drop any legacy exclude block — we\'re include-mode now. + tools_cfg.pop("exclude", None) enabled_count = len(chosen) disabled_count = len(tools) - enabled_count diff --git a/hermes_constants.py b/hermes_constants.py index b54adf01786..0b295b2ce48 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -174,6 +174,25 @@ def get_optional_skills_dir(default: Path | None = None) -> Path: return get_hermes_home() / "optional-skills" +def get_optional_mcps_dir(default: Path | None = None) -> Path: + """Return the optional-mcps directory, honoring package-manager wrappers. + + Mirrors :func:`get_optional_skills_dir` for the MCP catalog (Nous-approved + Model Context Protocol servers shipped with the repo but disabled by + default). Packaged installs may ship ``optional-mcps`` outside the Python + package tree and expose it via ``HERMES_OPTIONAL_MCPS``. + """ + override = os.getenv("HERMES_OPTIONAL_MCPS", "").strip() + if override: + return Path(override) + packaged = _get_packaged_data_dir("optional-mcps") + if packaged is not None: + return packaged + if default is not None: + return default + return get_hermes_home() / "optional-mcps" + + def get_bundled_skills_dir(default: Path | None = None) -> Path: """Return the bundled skills directory for source and packaged installs. diff --git a/optional-mcps/linear/manifest.yaml b/optional-mcps/linear/manifest.yaml new file mode 100644 index 00000000000..849ebec888a --- /dev/null +++ b/optional-mcps/linear/manifest.yaml @@ -0,0 +1,38 @@ +# Nous-approved MCP catalog entry. +# Presence in this directory = approval. Merged via PR review. +manifest_version: 1 + +name: linear +description: Find, create, and update Linear issues, projects, and comments. +source: https://linear.app/docs/mcp + +# Linear ships a remote MCP server with native OAuth 2.1 + Dynamic Client +# Registration over Streamable HTTP. Hermes's MCP client + mcp_oauth_manager +# handle discovery, PKCE, token exchange, and refresh — nothing to install +# locally. +transport: + type: http + url: https://mcp.linear.app/mcp + +auth: + type: oauth + # No `provider:` — this is native MCP OAuth (case 1), not a third-party + # provider like Google. The MCP client triggers the browser flow on the + # first probe / first connect. + +# Tool selection at install time: +# Linear's MCP server exposes a moderate-sized tool surface (find/get/list + +# create/update across issues/projects/comments). We leave `default_enabled` +# unset so the install-time checklist starts with everything pre-checked — +# users prune what they don't want. +# +# If you want to encode a curated subset here once it stabilizes, list the +# tool names under `tools.default_enabled`. Probe failure would then apply +# that list directly. + +post_install: | + On first connection, Hermes will open a browser to authenticate with Linear. + After auth, restart your Hermes session so the Linear tools are loaded. + + You can re-run the tool checklist any time with: + hermes mcp configure linear diff --git a/optional-mcps/n8n/manifest.yaml b/optional-mcps/n8n/manifest.yaml new file mode 100644 index 00000000000..468efd1ddaf --- /dev/null +++ b/optional-mcps/n8n/manifest.yaml @@ -0,0 +1,77 @@ +# Nous-approved MCP catalog entry. +# Presence in this directory = approval. Merged via PR review. +# +# Schema version 1. +manifest_version: 1 + +name: n8n +description: Manage and inspect n8n workflows from Hermes (stdio bridge, no public port). +source: https://github.com/CyberSamuraiX/hermes-n8n-mcp + +# How to launch the server once installed. The keys here map 1:1 to the +# `mcp_servers.` block written into ~/.hermes/config.yaml by the +# existing `_save_mcp_server()` helper in hermes_cli/mcp_config.py. +transport: + type: stdio + # For git-installed servers, ${INSTALL_DIR} is substituted at install time + # with the path the catalog cloned the repo into. The catalog never + # auto-updates: the user re-runs `hermes mcp install official/n8n` to + # refresh. + command: "${INSTALL_DIR}/.venv/bin/python" + args: + - "${INSTALL_DIR}/server.py" + +# Optional install step. Omit for npm/uvx servers where transport.command +# is the install (`npx -y package`). Use for repos that need a local clone +# + dependency install. +install: + type: git + url: https://github.com/CyberSamuraiX/hermes-n8n-mcp.git + # Pin to a commit/tag. Required — manifests do not float HEAD. + ref: main + # Bootstrap commands run inside the cloned directory after clone. + bootstrap: + - "python3 -m venv .venv" + - ".venv/bin/pip install -r requirements.txt" + +# Authentication. Three shapes: +# type: api_key — prompt for env vars, write to ~/.hermes/.env +# type: oauth — provider-mediated or remote MCP native OAuth (case 1/2) +# type: none — no credentials needed +auth: + type: api_key + env: + - name: N8N_BASE_URL + prompt: "n8n instance URL" + default: "http://127.0.0.1:5678" + required: true + secret: false + - name: N8N_API_KEY + prompt: "n8n API key (generate under Settings → API)" + required: true + secret: true + +# Tool selection at install time: +# n8n's bridge exposes 11 tools. Mutating ones (activate/deactivate, docker +# container_logs) are pruned from the default so a user who installs casually +# gets a read-mostly safe surface. Users see the full list in the install-time +# checklist and can opt into the mutating tools per their threat model. +tools: + default_enabled: + - health + - list_workflows + - get_workflow + - find_workflows + - list_executions + - get_execution + - recent_failures + - export_workflow + +post_install: | + The n8n bridge expects to talk to a running n8n instance over the URL you + provided. Generate an API key in n8n under Settings → API. + + Workflow activate/deactivate calls are real mutations against your live n8n. + Treat them carefully. + + Start a new Hermes session to load the n8n tools. diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py new file mode 100644 index 00000000000..13dcf50653b --- /dev/null +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -0,0 +1,794 @@ +"""Tests for hermes_cli.mcp_catalog and hermes_cli.mcp_picker. + +Manifest parsing, install/uninstall config writes, and picker plumbing +are exercised here. Anything that would actually clone a repo or +launch an MCP is mocked. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _default_mock_probe(monkeypatch): + """By default tests run the probe-fails path so install_entry() doesn\'t + try to talk to a real MCP server. + + Individual tests that exercise probe-success behaviour patch + ``hermes_cli.mcp_catalog._probe_tools`` themselves. + """ + # Patch the catalog\'s probe wrapper, not the underlying + # mcp_config._probe_single_server (so tests stay decoupled from that + # module\'s plumbing). + import hermes_cli.mcp_catalog as mc + + monkeypatch.setattr(mc, "_probe_tools", lambda name: None) + + +@pytest.fixture +def catalog_dir(tmp_path, monkeypatch): + """Provide an isolated optional-mcps/ directory.""" + cat = tmp_path / "optional-mcps" + cat.mkdir() + monkeypatch.setenv("HERMES_OPTIONAL_MCPS", str(cat)) + return cat + + +@pytest.fixture(autouse=True) +def _isolate_hermes_home(tmp_path, monkeypatch): + """Redirect all config I/O to a temp HERMES_HOME.""" + hh = tmp_path / "hermes-home" + hh.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hh)) + monkeypatch.setattr( + "hermes_cli.config.get_hermes_home", lambda: hh + ) + monkeypatch.setattr( + "hermes_cli.config.get_config_path", lambda: hh / "config.yaml" + ) + monkeypatch.setattr( + "hermes_cli.config.get_env_path", lambda: hh / ".env" + ) + # mcp_catalog grabs get_hermes_home() lazily through hermes_constants + monkeypatch.setattr( + "hermes_constants.get_hermes_home", lambda: hh + ) + return hh + + +def _write_manifest(catalog_dir: Path, name: str, body: dict) -> Path: + entry_dir = catalog_dir / name + entry_dir.mkdir(exist_ok=True) + path = entry_dir / "manifest.yaml" + with open(path, "w") as f: + yaml.safe_dump(body, f) + return path + + +def _basic_manifest(name: str = "demo", **overrides) -> dict: + body = { + "manifest_version": 1, + "name": name, + "description": "Demo MCP", + "source": "https://example.com", + "transport": { + "type": "stdio", + "command": "npx", + "args": ["-y", "demo-mcp"], + }, + "auth": {"type": "none"}, + } + body.update(overrides) + return body + + +def _entry(name: str): + """Wrapper that asserts entry exists (satisfies type-checker + nicer failure msg).""" + from hermes_cli.mcp_catalog import get_entry + + e = get_entry(name) + assert e is not None, f"catalog entry {name!r} missing" + return e + + + +# --------------------------------------------------------------------------- +# Manifest parsing +# --------------------------------------------------------------------------- + + +class TestManifestParsing: + def test_minimal_valid(self, catalog_dir): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_catalog import list_catalog + + entries = list_catalog() + assert len(entries) == 1 + e = entries[0] + assert e.name == "demo" + assert e.transport.type == "stdio" + assert e.transport.command == "npx" + assert e.transport.args == ["-y", "demo-mcp"] + assert e.auth.type == "none" + assert e.install is None + + def test_api_key_auth(self, catalog_dir): + body = _basic_manifest( + auth={ + "type": "api_key", + "env": [ + {"name": "DEMO_KEY", "prompt": "API key", "secret": True}, + {"name": "DEMO_URL", "prompt": "Base URL", "secret": False, "required": False}, + ], + } + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + e = list_catalog()[0] + assert e.auth.type == "api_key" + assert len(e.auth.env) == 2 + assert e.auth.env[0].name == "DEMO_KEY" + assert e.auth.env[0].secret is True + assert e.auth.env[1].required is False + assert e.auth.env[1].secret is False + + def test_install_block(self, catalog_dir): + body = _basic_manifest( + install={ + "type": "git", + "url": "https://example.com/demo.git", + "ref": "v1.0.0", + "bootstrap": ["pip install -r requirements.txt"], + }, + transport={ + "type": "stdio", + "command": "${INSTALL_DIR}/.venv/bin/python", + "args": ["${INSTALL_DIR}/server.py"], + }, + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + e = list_catalog()[0] + assert e.install is not None + assert e.install.url == "https://example.com/demo.git" + assert e.install.ref == "v1.0.0" + assert e.install.bootstrap == ["pip install -r requirements.txt"] + + def test_invalid_manifest_skipped(self, catalog_dir): + # Broken: wrong manifest_version + _write_manifest(catalog_dir, "bad", { + "manifest_version": 99, + "name": "bad", + "description": "x", + "transport": {"type": "stdio", "command": "x"}, + }) + # Good + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_catalog import list_catalog + + entries = list_catalog() + assert [e.name for e in entries] == ["demo"] + + def test_missing_transport_command_rejected(self, catalog_dir): + body = _basic_manifest() + body["transport"] = {"type": "stdio"} # no command + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + assert list_catalog() == [] + + def test_get_entry_strips_official_prefix(self, catalog_dir): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_catalog import get_entry + + assert get_entry("demo") is not None + assert get_entry("official/demo") is not None + assert get_entry("missing") is None + + +# --------------------------------------------------------------------------- +# Install flow +# --------------------------------------------------------------------------- + + +class TestInstall: + def test_install_simple_stdio_writes_config(self, catalog_dir): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + + cfg = load_config() + servers = cfg["mcp_servers"] + assert "demo" in servers + assert servers["demo"]["command"] == "npx" + assert servers["demo"]["args"] == ["-y", "demo-mcp"] + assert servers["demo"]["enabled"] is True + + def test_install_with_install_dir_substitution(self, catalog_dir, tmp_path): + body = _basic_manifest( + install={ + "type": "git", + "url": "https://example.com/demo.git", + "ref": "main", + "bootstrap": [], + }, + transport={ + "type": "stdio", + "command": "${INSTALL_DIR}/run.sh", + "args": ["${INSTALL_DIR}/cfg.json"], + }, + ) + _write_manifest(catalog_dir, "demo", body) + + # Mock the git clone — return a known directory + fake_clone = tmp_path / "fake-clone" + fake_clone.mkdir() + + from hermes_cli import mcp_catalog + from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.config import load_config + + with patch.object(mcp_catalog, "_do_git_install", return_value=fake_clone): + install_entry(_entry("demo"), enable=True) + + servers = load_config()["mcp_servers"] + assert servers["demo"]["command"] == f"{fake_clone}/run.sh" + assert servers["demo"]["args"] == [f"{fake_clone}/cfg.json"] + + def test_install_with_api_key_prompts_and_saves(self, catalog_dir, monkeypatch): + body = _basic_manifest( + auth={ + "type": "api_key", + "env": [{"name": "DEMO_KEY", "prompt": "key", "secret": True}], + } + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli import mcp_catalog + + monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "secret-val") + + from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.config import get_env_value, load_config + + install_entry(_entry("demo"), enable=True) + + assert get_env_value("DEMO_KEY") == "secret-val" + assert "demo" in load_config()["mcp_servers"] + + def test_install_http_oauth_writes_auth_marker(self, catalog_dir): + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={"type": "oauth"}, + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + + server = load_config()["mcp_servers"]["demo"] + assert server["url"] == "https://mcp.example.com/sse" + assert server["auth"] == "oauth" + + def test_install_required_env_missing_raises(self, catalog_dir, monkeypatch): + body = _basic_manifest( + auth={ + "type": "api_key", + "env": [{"name": "MUST", "prompt": "x", "required": True, "secret": False}], + } + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli import mcp_catalog + from hermes_cli.mcp_catalog import install_entry, get_entry, CatalogError + + # User hits enter — empty input, no default + monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "") + + with pytest.raises(CatalogError): + install_entry(_entry("demo"), enable=True) + + +# --------------------------------------------------------------------------- +# Uninstall +# --------------------------------------------------------------------------- + + +class TestUninstall: + def test_uninstall_removes_server_block(self, catalog_dir): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_catalog import install_entry, get_entry, uninstall_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + assert "demo" in load_config().get("mcp_servers", {}) + + assert uninstall_entry("demo") is True + assert "demo" not in load_config().get("mcp_servers", {}) + + def test_uninstall_missing_returns_false(self): + from hermes_cli.mcp_catalog import uninstall_entry + + assert uninstall_entry("nonexistent") is False + + +# --------------------------------------------------------------------------- +# Picker (non-TTY paths only — interactive curses is integration-tested) +# --------------------------------------------------------------------------- + + +class TestPicker: + def test_show_catalog_empty(self, catalog_dir, capsys): + from hermes_cli.mcp_picker import show_catalog + + show_catalog() + out = capsys.readouterr().out + assert "No MCPs in the catalog or configured" in out + + def test_show_catalog_lists_entry(self, catalog_dir, capsys): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_picker import show_catalog + + show_catalog() + out = capsys.readouterr().out + assert "demo" in out + assert "available" in out + + def test_install_by_name_unknown(self, catalog_dir, capsys): + from hermes_cli.mcp_picker import install_by_name + + rc = install_by_name("nope") + assert rc == 1 + assert "not in the catalog" in capsys.readouterr().out + + def test_install_by_name_success(self, catalog_dir): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_picker import install_by_name + from hermes_cli.config import load_config + + rc = install_by_name("demo") + assert rc == 0 + assert "demo" in load_config().get("mcp_servers", {}) + + def test_run_picker_non_tty_falls_back(self, catalog_dir, capsys, monkeypatch): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + # Force isatty false + import sys as _sys + monkeypatch.setattr(_sys.stdin, "isatty", lambda: False) + from hermes_cli.mcp_picker import run_picker + + run_picker() + out = capsys.readouterr().out + assert "MCP Catalog + configured servers" in out + + +# --------------------------------------------------------------------------- +# Shipped catalog (sanity: every manifest in the repo's optional-mcps/ parses) +# --------------------------------------------------------------------------- + + +class TestToolSelection: + def _make_probed(self, *names): + """Return a list of (tool_name, description) tuples for mocking.""" + return [(n, f"description of {n}") for n in names] + + def test_probe_fail_no_default_writes_no_filter(self, catalog_dir): + body = _basic_manifest() + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + server = load_config()["mcp_servers"]["demo"] + # No tools.include => all tools active when reachable + assert "tools" not in server, server + + def test_probe_fail_with_default_applies_directly(self, catalog_dir): + body = _basic_manifest( + tools={"default_enabled": ["a", "b", "c"]}, + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + server = load_config()["mcp_servers"]["demo"] + assert server["tools"]["include"] == ["a", "b", "c"] + + def test_probe_success_non_tty_with_default_filters_to_default( + self, catalog_dir, monkeypatch + ): + body = _basic_manifest( + tools={"default_enabled": ["alpha", "gamma"]}, + ) + _write_manifest(catalog_dir, "demo", body) + import hermes_cli.mcp_catalog as mc + + probed = self._make_probed("alpha", "beta", "gamma", "delta") + monkeypatch.setattr(mc, "_probe_tools", lambda name: probed) + import sys as _sys + monkeypatch.setattr(_sys.stdin, "isatty", lambda: False) + + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + server = load_config()["mcp_servers"]["demo"] + # Only the manifest defaults that actually exist on the server + assert server["tools"]["include"] == ["alpha", "gamma"] + + def test_probe_success_non_tty_no_default_clears_filter( + self, catalog_dir, monkeypatch + ): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + import hermes_cli.mcp_catalog as mc + + probed = self._make_probed("x", "y") + monkeypatch.setattr(mc, "_probe_tools", lambda name: probed) + import sys as _sys + monkeypatch.setattr(_sys.stdin, "isatty", lambda: False) + + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + server = load_config()["mcp_servers"]["demo"] + assert "tools" not in server + + def test_default_enabled_filters_out_unknown_tool_names( + self, catalog_dir, monkeypatch + ): + """If manifest names a tool the server doesn\'t actually expose, it + silently drops out — never written into tools.include.""" + body = _basic_manifest( + tools={"default_enabled": ["real", "ghost"]}, + ) + _write_manifest(catalog_dir, "demo", body) + import hermes_cli.mcp_catalog as mc + + probed = self._make_probed("real", "other") + monkeypatch.setattr(mc, "_probe_tools", lambda name: probed) + import sys as _sys + monkeypatch.setattr(_sys.stdin, "isatty", lambda: False) + + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + server = load_config()["mcp_servers"]["demo"] + assert server["tools"]["include"] == ["real"] + + def test_reinstall_preserves_prior_user_selection( + self, catalog_dir, monkeypatch + ): + """Second install of the same entry uses the user\'s prior + tools.include as the pre-check, NOT the manifest default.""" + body = _basic_manifest( + tools={"default_enabled": ["alpha"]}, + ) + _write_manifest(catalog_dir, "demo", body) + + import hermes_cli.mcp_catalog as mc + probed = self._make_probed("alpha", "beta", "gamma") + monkeypatch.setattr(mc, "_probe_tools", lambda name: probed) + import sys as _sys + monkeypatch.setattr(_sys.stdin, "isatty", lambda: False) + + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config, save_config + + # First install + install_entry(_entry("demo"), enable=True) + # Simulate user opening configure and choosing beta+gamma + cfg = load_config() + cfg["mcp_servers"]["demo"]["tools"]["include"] = ["beta", "gamma"] + save_config(cfg) + + # Reinstall (non-TTY honors prior_selection over manifest default) + install_entry(_entry("demo"), enable=True) + server = load_config()["mcp_servers"]["demo"] + assert server["tools"]["include"] == ["beta", "gamma"], server + + def test_manifest_invalid_default_enabled_rejected(self, catalog_dir): + body = _basic_manifest() + body["tools"] = {"default_enabled": "not a list"} + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + # Invalid manifests are silently skipped at list_catalog level + assert list_catalog() == [] + + + + +# --------------------------------------------------------------------------- +# Forward-compat / diagnostics +# --------------------------------------------------------------------------- + + +class TestCatalogDiagnostics: + def test_future_manifest_version_skipped_with_diagnostic(self, catalog_dir): + """A manifest with a newer manifest_version is skipped, but the skip + is reported via catalog_diagnostics so the UI can tell the user.""" + body = _basic_manifest() + body["manifest_version"] = 999 # Future version + _write_manifest(catalog_dir, "futuristic", body) + # Plus one valid entry + _write_manifest(catalog_dir, "demo", _basic_manifest()) + + from hermes_cli.mcp_catalog import list_catalog, catalog_diagnostics + + entries = list_catalog() + assert [e.name for e in entries] == ["demo"] + + diags = catalog_diagnostics() + # At least one future_manifest diagnostic for the futuristic entry + future = [d for d in diags if d[1] == "future_manifest"] + assert len(future) == 1 + assert future[0][0] == "futuristic" + + def test_invalid_manifest_diagnostic(self, catalog_dir): + body = _basic_manifest() + body["transport"] = {"type": "unsupported"} + _write_manifest(catalog_dir, "broken", body) + + from hermes_cli.mcp_catalog import list_catalog, catalog_diagnostics + + entries = list_catalog() + assert entries == [] + diags = catalog_diagnostics() + invalid = [d for d in diags if d[1] == "invalid"] + assert len(invalid) == 1 + + def test_picker_surfaces_future_manifest_warning(self, catalog_dir, capsys, monkeypatch): + """The text-dump path should print a warning line for future-manifest + entries so users running headless or after `hermes setup` know to update.""" + body = _basic_manifest() + body["manifest_version"] = 999 + _write_manifest(catalog_dir, "futuristic", body) + _write_manifest(catalog_dir, "demo", _basic_manifest()) + + import sys as _sys + monkeypatch.setattr(_sys.stdin, "isatty", lambda: False) + from hermes_cli.mcp_picker import show_catalog + + show_catalog() + out = capsys.readouterr().out + assert "futuristic" in out + assert "requires a newer Hermes" in out + + +# --------------------------------------------------------------------------- +# Picker — custom (non-catalog) MCP rows +# --------------------------------------------------------------------------- + + +class TestCustomMcpRows: + def test_custom_mcp_shown_alongside_catalog(self, catalog_dir, capsys): + """Servers in mcp_servers that aren't in the catalog show up in the + picker text dump with a 'custom' status.""" + _write_manifest(catalog_dir, "demo", _basic_manifest()) + + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("mcp_servers", {})["my-custom"] = { + "command": "npx", + "args": ["-y", "my-custom-mcp"], + "enabled": True, + } + save_config(cfg) + + from hermes_cli.mcp_picker import show_catalog + show_catalog() + out = capsys.readouterr().out + assert "demo" in out + assert "my-custom" in out + assert "custom" in out # The status badge + + def test_custom_mcp_only_no_catalog(self, catalog_dir, capsys): + """If the catalog is empty but the user has custom MCPs, they\'re + still visible — the picker is the unified surface.""" + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("mcp_servers", {})["my-custom"] = { + "url": "https://mcp.example.com", + "enabled": False, + } + save_config(cfg) + + from hermes_cli.mcp_picker import show_catalog + show_catalog() + out = capsys.readouterr().out + assert "my-custom" in out + + +# --------------------------------------------------------------------------- +# Git install — SHA ref detection +# --------------------------------------------------------------------------- + + +class TestGitInstallShaRef: + def test_sha_ref_skips_branch_attempt(self, catalog_dir, monkeypatch, tmp_path): + """When install.ref is a SHA-shaped hex string, _do_git_install + skips the `git clone --branch ` attempt (which would always fail + noisily for SHAs) and goes straight to clone + checkout.""" + body = _basic_manifest( + install={ + "type": "git", + "url": "https://example.com/x.git", + "ref": "abc1234567890abcdef1234567890abcdef12345", # 40-char SHA + "bootstrap": [], + }, + transport={ + "type": "stdio", + "command": "${INSTALL_DIR}/run.sh", + "args": [], + }, + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli import mcp_catalog + from hermes_cli.mcp_catalog import _do_git_install + + calls = [] + + class _FakeProc: + def __init__(self, returncode): + self.returncode = returncode + + def fake_run(argv, *args, **kwargs): + calls.append(list(argv)) + # Make every command succeed + return _FakeProc(returncode=0) + + monkeypatch.setattr(mcp_catalog.subprocess, "run", fake_run) + monkeypatch.setattr(mcp_catalog.shutil, "which", lambda x: "/usr/bin/git") + + from hermes_cli.mcp_catalog import get_entry + entry = get_entry("demo") + assert entry is not None + _do_git_install(entry) + + # Should have called clone (no --branch) then checkout — NOT clone --branch + branch_attempts = [c for c in calls if "--branch" in c] + assert branch_attempts == [], ( + "SHA refs must NOT trigger a --branch clone attempt — that would " + "always fail noisily before falling back. Calls were: " + repr(calls) + ) + # Confirm we DID do plain clone + checkout + clone_calls = [c for c in calls if "clone" in c and "--branch" not in c] + checkout_calls = [c for c in calls if "checkout" in c] + assert len(clone_calls) == 1, calls + assert len(checkout_calls) == 1, calls + + def test_branch_ref_uses_branch_clone(self, catalog_dir, monkeypatch): + """When install.ref is a branch/tag (not SHA-shaped), the fast + `git clone --depth 1 --branch ` path is used.""" + body = _basic_manifest( + install={ + "type": "git", + "url": "https://example.com/x.git", + "ref": "v1.0.0", # Tag-shaped + "bootstrap": [], + }, + transport={ + "type": "stdio", + "command": "${INSTALL_DIR}/run.sh", + "args": [], + }, + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli import mcp_catalog + from hermes_cli.mcp_catalog import _do_git_install, get_entry + + calls = [] + + class _FakeProc: + def __init__(self, returncode): + self.returncode = returncode + + def fake_run(argv, *args, **kwargs): + calls.append(list(argv)) + return _FakeProc(returncode=0) + + monkeypatch.setattr(mcp_catalog.subprocess, "run", fake_run) + monkeypatch.setattr(mcp_catalog.shutil, "which", lambda x: "/usr/bin/git") + + _do_git_install(get_entry("demo")) + branch_attempts = [c for c in calls if "--branch" in c] + assert len(branch_attempts) == 1, calls + + +# --------------------------------------------------------------------------- +# Existing tools_config converged to tools.include +# --------------------------------------------------------------------------- + + +class TestToolsConfigIncludeMode: + def test_configure_mcp_writes_include_not_exclude(self, monkeypatch, tmp_path): + """`_configure_mcp_tools_interactive` in tools_config.py must write + `tools.include` (whitelist), matching the rest of the codebase. The + old behavior wrote `tools.exclude`, which produced inconsistent + on-disk shapes depending on which UI the user used last.""" + # Build a minimal mcp_servers config + mock probe + checklist + cfg = { + "_config_version": 23, + "mcp_servers": { + "demo": { + "command": "npx", + "args": ["-y", "demo-mcp"], + "enabled": True, + } + }, + } + + import hermes_cli.tools_config as tc + # Mock the probe to return three tools + monkeypatch.setattr( + "tools.mcp_tool.probe_mcp_server_tools", + lambda: {"demo": [("a", "desc"), ("b", "desc"), ("c", "desc")]}, + ) + # Mock the checklist to return just the first tool + monkeypatch.setattr( + "hermes_cli.curses_ui.curses_checklist", + lambda title, labels, pre_selected, **kw: {0}, + ) + # Mock save_config so we can inspect the write + saved = {} + + def fake_save(config): + saved.update(config) + + monkeypatch.setattr(tc, "save_config", fake_save) + + tc._configure_mcp_tools_interactive(cfg) + + # Must have written include, not exclude + srv = saved["mcp_servers"]["demo"]["tools"] + assert srv.get("include") == ["a"], srv + assert "exclude" not in srv, srv + + +class TestShippedCatalog: + def test_all_shipped_manifests_parse(self, monkeypatch): + """Every manifest in optional-mcps/ must parse cleanly. + + This is a contract test — CI will fail if a PR adds a malformed + manifest. Intentionally NOT a snapshot of catalog names (those are + expected to change as PRs land). + """ + # Use the actual repo's optional-mcps directory (no HERMES_OPTIONAL_MCPS + # override) so this test catches real manifests. + monkeypatch.delenv("HERMES_OPTIONAL_MCPS", raising=False) + from hermes_cli.mcp_catalog import _catalog_root, _parse_manifest + + root = _catalog_root() + if not root.exists(): + pytest.skip("optional-mcps/ not present in this checkout") + + manifests = list(root.glob("*/manifest.yaml")) + # Don't assert minimum count — change-detector test rule. Just parse + # whatever exists. + for m in manifests: + entry = _parse_manifest(m) + assert entry.name + assert entry.description + assert entry.transport.type in ("stdio", "http") diff --git a/tests/hermes_cli/test_mcp_tools_config.py b/tests/hermes_cli/test_mcp_tools_config.py index d7be938ad59..ada221a3ddc 100644 --- a/tests/hermes_cli/test_mcp_tools_config.py +++ b/tests/hermes_cli/test_mcp_tools_config.py @@ -68,8 +68,13 @@ def test_no_changes_when_checklist_cancelled(capsys): assert "no changes" in captured.out.lower() -def test_disabling_tool_writes_exclude_list(capsys): - """Unchecking a tool adds it to the exclude list.""" +def test_disabling_tool_writes_include_list(capsys): + """Unchecking a tool produces an include list of the still-chosen tools. + + Standardized on tools.include (whitelist) across the codebase — the + catalog flow, `hermes mcp configure`, and this UI all write the same + shape so users don\'t see config drift across UIs. + """ config = { "mcp_servers": { "github": {"command": "npx"}, @@ -89,8 +94,8 @@ def test_disabling_tool_writes_exclude_list(capsys): mock_save.assert_called_once() tools_cfg = config["mcp_servers"]["github"]["tools"] - assert tools_cfg["exclude"] == ["delete_repo"] - assert "include" not in tools_cfg + assert tools_cfg["include"] == ["create_issue", "search_repos"] + assert "exclude" not in tools_cfg def test_enabling_all_clears_filters(capsys): @@ -244,8 +249,9 @@ def test_description_truncation_in_labels(): assert len(label) < len(long_desc) + 30 # truncated + tool name + parens -def test_switching_from_include_to_exclude(capsys): - """When user modifies selection, include list is replaced by exclude list.""" +def test_modifying_include_stays_in_include_mode(capsys): + """Changing the selection updates the include list — never switches + to exclude mode. Standardized on include-mode writes across the codebase.""" config = { "mcp_servers": { "github": { @@ -256,16 +262,15 @@ def test_switching_from_include_to_exclude(capsys): } tools = [("create_issue", "Create"), ("search", "Search"), ("delete", "Delete")] - # User selects create_issue and search (deselects delete) - # pre_selected would be {0} (only create_issue from include), so {0, 1} is a change + # User adds search to the selection (deselects delete which was never on) with patch(_PROBE, return_value={"github": tools}), \ patch(_CHECKLIST, return_value={0, 1}), \ patch(_SAVE): _configure_mcp_tools_interactive(config) tools_cfg = config["mcp_servers"]["github"]["tools"] - assert tools_cfg["exclude"] == ["delete"] - assert "include" not in tools_cfg + assert tools_cfg["include"] == ["create_issue", "search"] + assert "exclude" not in tools_cfg def test_empty_tools_server_skipped(capsys): diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index eb5bda2f649..477f9908c80 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1001,8 +1001,11 @@ Manage MCP (Model Context Protocol) server configurations and run Hermes as an M | Subcommand | Description | |------------|-------------| +| *(none)* or `picker` | Interactive catalog picker — browse Nous-approved MCPs and install/enable/disable. | +| `catalog` | List Nous-approved MCPs (plain text, scriptable). | +| `install ` | Install a catalog entry (e.g. `hermes mcp install n8n`). | | `serve [-v\|--verbose]` | Run Hermes as an MCP server — expose conversations to other agents. | -| `add [--url URL] [--command CMD] [--args ...] [--auth oauth\|header]` | Add an MCP server with automatic tool discovery. | +| `add [--url URL] [--command CMD] [--args ...] [--auth oauth\|header]` | Add a custom MCP server with automatic tool discovery. | | `remove ` (alias: `rm`) | Remove an MCP server from config. | | `list` (alias: `ls`) | List configured MCP servers. | | `test ` | Test connection to an MCP server. | diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index 12142b650ee..071a97c3194 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -52,6 +52,126 @@ List the files in /home/user/projects and summarize the repo structure. Hermes will discover the MCP server's tools and use them like any other tool. +## Catalog: one-click install for Nous-approved MCPs + +Hermes ships a curated catalog of MCP servers that Nous staff has reviewed +and merged. They're disabled by default — install only what you actually +want. + +```bash +hermes mcp # interactive picker (default) +hermes mcp catalog # plain-text list, scriptable +hermes mcp install n8n # install a catalog entry by name +``` + +The picker shows each entry with its current status: + +``` +n8n available Manage and inspect n8n workflows from Hermes +linear enabled Linear issue/project management (remote OAuth) +github installed (disabled) GitHub repo + PR tools +``` + +Hit `Enter` on a row to install (and walk through any required credentials), +enable, disable, or uninstall. Catalog entries are stored under +`optional-mcps/` in the hermes-agent repo — presence in that directory means +Nous approval. There is no community submission tier; entries are added by +merging a PR. + +Catalog entries can require: + +- **API key** — Hermes prompts at install time and writes the value to + `~/.hermes/.env`. Non-secret values (base URLs) go to the same file. +- **OAuth** (remote MCP) — written as `auth: oauth` in your config; the MCP + client opens a browser on first connection. +- **OAuth** (third-party provider like Google/GitHub) — Hermes points you at + `hermes auth ` if you haven't authenticated already. + +### Tool selection at install time + +After credentials are configured, Hermes probes the MCP server to list every +tool it exposes and presents a checklist: + +``` +Select tools for 'linear' (SPACE toggle, ENTER confirm) + [x] find_issues Find issues matching a query + [x] get_issue Get a single issue + [x] create_issue Create a new issue + [ ] delete_workspace Delete a Linear workspace + ... +``` + +The pre-checked rows come from: + +1. **Your prior selection** if you've installed this entry before (reinstalls + preserve what you had — the manifest's defaults don't override it) +2. **The manifest's `tools.default_enabled`** if the entry declares one (some + catalog entries pre-prune mutating or rarely-useful tools) +3. **Everything** if neither applies + +Submit the checklist with ENTER. Only the checked tools end up in +`mcp_servers..tools.include`. If you select everything, no filter is +written (cleanest config shape, identical behavior). + +**If the probe fails** (server unreachable, OAuth not yet completed, +backing service not running), the install still succeeds: the manifest's +`tools.default_enabled` is applied directly (if declared), or no filter is +written (if not). Re-run `hermes mcp configure ` once the server is +reachable to refine. + +### Trust model + +Installing a catalog entry runs whatever the manifest specifies — `git clone`, +the entry's `bootstrap` commands (`pip install`, `npm install`, etc.), and +ultimately the MCP server's own code. Manifests are gated by PR review into +the hermes-agent repo, so Nous has reviewed each entry before it shipped — +**but you should still read the manifest before installing**, especially the +`source:` field's repository, the `install.bootstrap:` commands, and any +`transport.command:` invocation. + +Manifests live at +[`optional-mcps//manifest.yaml`](https://github.com/NousResearch/hermes-agent/tree/main/optional-mcps) +on GitHub. The picker also prints the manifest's `source:` URL at install +time so you can quickly verify the upstream repo. + +### Manifest version compatibility + +Manifests pin a `manifest_version`. The catalog is forward-compatible: if a +PR adds an entry with a newer `manifest_version` than your installed Hermes +understands, the picker will surface a warning (`⚠ '' requires a newer +Hermes`) for that entry instead of silently hiding it. Run `hermes update` +to install the latest Hermes when you see that. + +### Runtime `${ENV_VAR}` substitution + +Inside an entry's `transport.command`, `transport.args`, `transport.url`, +and `headers`, `${VAR}` placeholders are resolved at server-connect time +from environment variables (which include everything in `~/.hermes/.env`). +This is useful when a catalog entry wants to reference a value the user +configured elsewhere — e.g. `${HOME}/foo` or `${MY_PROVIDER_TOKEN}`. + +Note this is distinct from `${INSTALL_DIR}` in catalog manifests, which is +substituted at install-time with the path the catalog cloned the entry's +repo into. + +### Updating tool selection later + +```bash +hermes mcp configure linear +``` + +Reopens the same checklist with your current selection pre-checked. Use this +when you want more tools enabled, or when the server has added new tools that +you want to opt into. + +### Updating the catalog manifest + +MCPs are never auto-updated. Re-run `hermes mcp install ` to refresh +after a Hermes update if a manifest version changed. + +To add an MCP to the catalog, open a PR against +[`optional-mcps/`](https://github.com/NousResearch/hermes-agent/tree/main/optional-mcps). + ## Two kinds of MCP servers ### Stdio servers