mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
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:
parent
2517917de3
commit
8b69ec03af
13 changed files with 2226 additions and 23 deletions
|
|
@ -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
776
hermes_cli/mcp_catalog.py
Normal 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
|
||||
|
|
@ -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
322
hermes_cli/mcp_picker.py
Normal 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
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
38
optional-mcps/linear/manifest.yaml
Normal file
38
optional-mcps/linear/manifest.yaml
Normal 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
|
||||
77
optional-mcps/n8n/manifest.yaml
Normal file
77
optional-mcps/n8n/manifest.yaml
Normal 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.
|
||||
794
tests/hermes_cli/test_mcp_catalog.py
Normal file
794
tests/hermes_cli/test_mcp_catalog.py
Normal 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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue