mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
* 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.
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""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
|