feat(mcp): Nous-approved MCP catalog with interactive picker (#30870)

* feat(mcp): Nous-approved MCP catalog with interactive picker

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The catalog is already surfaced via three tip-corpus entries in
hermes_cli/tips.py (which the scanner doesn't flag), so dropping the
wizard mention loses no discoverability. Worth revisiting after a
scanner allowlist for this specific file lands.
This commit is contained in:
Teknium 2026-05-26 12:48:14 -07:00 committed by GitHub
parent 2517917de3
commit 8b69ec03af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2226 additions and 23 deletions

View file

@ -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/<name>`)",
)
_add_accept_hooks_flag(mcp_parser)
def cmd_mcp(args):

776
hermes_cli/mcp_catalog.py Normal file
View file

@ -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/<name>/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 <name>`` (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 <name>`` 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/<name>`` 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/<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.
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.<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]
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.<name>.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 <name>``.
"""
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 <name>` 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.<name>`` 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 <provider>` 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

View file

@ -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 <name> Install a catalog MCP")
_info("hermes mcp serve Run as MCP server")
_info("hermes mcp add <name> --url <endpoint> Add an MCP server")
_info("hermes mcp add <name> --url <endpoint> Add a custom MCP server")
_info("hermes mcp add <name> --command <cmd> Add a stdio server")
_info("hermes mcp add <name> --preset <preset> Add from a known preset")
_info("hermes mcp remove <name> Remove a server")
_info("hermes mcp list List servers")
_info("hermes mcp list List configured servers")
_info("hermes mcp test <name> Test connection")
_info("hermes mcp configure <name> Toggle tools")
_info("hermes mcp login <name> Re-authenticate OAuth")

322
hermes_cli/mcp_picker.py Normal file
View file

@ -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 <name> 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 <name>` — 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

View file

@ -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 <name> 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.",

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.<name>` 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.

View file

@ -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 <ref>` 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 <ref>` 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")

View file

@ -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):

View file

@ -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 <name>` | 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 <name> [--url URL] [--command CMD] [--args ...] [--auth oauth\|header]` | Add an MCP server with automatic tool discovery. |
| `add <name> [--url URL] [--command CMD] [--args ...] [--auth oauth\|header]` | Add a custom MCP server with automatic tool discovery. |
| `remove <name>` (alias: `rm`) | Remove an MCP server from config. |
| `list` (alias: `ls`) | List configured MCP servers. |
| `test <name>` | Test connection to an MCP server. |

View file

@ -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 <provider>` 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.<name>.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 <name>` 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/<name>/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 (`⚠ '<name>' 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 <name>` 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