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
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.
296 lines
9.7 KiB
Python
296 lines
9.7 KiB
Python
"""Tests for MCP tools interactive configuration in hermes_cli.tools_config."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from hermes_cli.tools_config import _configure_mcp_tools_interactive
|
|
|
|
# Patch targets: imports happen inside the function body, so patch at source
|
|
_PROBE = "tools.mcp_tool.probe_mcp_server_tools"
|
|
_CHECKLIST = "hermes_cli.curses_ui.curses_checklist"
|
|
_SAVE = "hermes_cli.tools_config.save_config"
|
|
|
|
|
|
def test_no_mcp_servers_prints_info(capsys):
|
|
"""Returns immediately when no MCP servers are configured."""
|
|
config = {}
|
|
_configure_mcp_tools_interactive(config)
|
|
captured = capsys.readouterr()
|
|
assert "No MCP servers configured" in captured.out
|
|
|
|
|
|
def test_all_servers_disabled_prints_info(capsys):
|
|
"""Returns immediately when all configured servers have enabled=false."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {"command": "npx", "enabled": False},
|
|
"slack": {"command": "npx", "enabled": "false"},
|
|
}
|
|
}
|
|
_configure_mcp_tools_interactive(config)
|
|
captured = capsys.readouterr()
|
|
assert "disabled" in captured.out
|
|
|
|
|
|
def test_probe_failure_shows_warning(capsys):
|
|
"""Shows warning when probe returns no tools."""
|
|
config = {"mcp_servers": {"github": {"command": "npx"}}}
|
|
with patch(_PROBE, return_value={}):
|
|
_configure_mcp_tools_interactive(config)
|
|
captured = capsys.readouterr()
|
|
assert "Could not discover" in captured.out
|
|
|
|
|
|
def test_probe_exception_shows_error(capsys):
|
|
"""Shows error when probe raises an exception."""
|
|
config = {"mcp_servers": {"github": {"command": "npx"}}}
|
|
with patch(_PROBE, side_effect=RuntimeError("MCP not installed")):
|
|
_configure_mcp_tools_interactive(config)
|
|
captured = capsys.readouterr()
|
|
assert "Failed to probe" in captured.out
|
|
|
|
|
|
def test_no_changes_when_checklist_cancelled(capsys):
|
|
"""No config changes when user cancels (ESC) the checklist."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {"command": "npx", "args": ["-y", "server-github"]},
|
|
}
|
|
}
|
|
tools = [("create_issue", "Create an issue"), ("search_repos", "Search repos")]
|
|
|
|
with patch(_PROBE, return_value={"github": tools}), \
|
|
patch(_CHECKLIST, return_value={0, 1}), \
|
|
patch(_SAVE) as mock_save:
|
|
_configure_mcp_tools_interactive(config)
|
|
mock_save.assert_not_called()
|
|
captured = capsys.readouterr()
|
|
assert "no changes" in captured.out.lower()
|
|
|
|
|
|
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"},
|
|
}
|
|
}
|
|
tools = [
|
|
("create_issue", "Create an issue"),
|
|
("delete_repo", "Delete a repo"),
|
|
("search_repos", "Search repos"),
|
|
]
|
|
|
|
# User unchecks delete_repo (index 1)
|
|
with patch(_PROBE, return_value={"github": tools}), \
|
|
patch(_CHECKLIST, return_value={0, 2}), \
|
|
patch(_SAVE) as mock_save:
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
mock_save.assert_called_once()
|
|
tools_cfg = config["mcp_servers"]["github"]["tools"]
|
|
assert tools_cfg["include"] == ["create_issue", "search_repos"]
|
|
assert "exclude" not in tools_cfg
|
|
|
|
|
|
def test_enabling_all_clears_filters(capsys):
|
|
"""Checking all tools clears both include and exclude lists."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {
|
|
"command": "npx",
|
|
"tools": {"exclude": ["delete_repo"], "include": ["create_issue"]},
|
|
},
|
|
}
|
|
}
|
|
tools = [("create_issue", "Create"), ("delete_repo", "Delete")]
|
|
|
|
# User checks all tools — pre_selected would be {0} (include mode),
|
|
# so returning {0, 1} is a change
|
|
with patch(_PROBE, return_value={"github": tools}), \
|
|
patch(_CHECKLIST, return_value={0, 1}), \
|
|
patch(_SAVE) as mock_save:
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
mock_save.assert_called_once()
|
|
tools_cfg = config["mcp_servers"]["github"]["tools"]
|
|
assert "exclude" not in tools_cfg
|
|
assert "include" not in tools_cfg
|
|
|
|
|
|
def test_pre_selection_respects_existing_exclude(capsys):
|
|
"""Tools in exclude list start unchecked."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {
|
|
"command": "npx",
|
|
"tools": {"exclude": ["delete_repo"]},
|
|
},
|
|
}
|
|
}
|
|
tools = [("create_issue", "Create"), ("delete_repo", "Delete"), ("search", "Search")]
|
|
captured_pre_selected = {}
|
|
|
|
def fake_checklist(title, labels, pre_selected, **kwargs):
|
|
captured_pre_selected["value"] = set(pre_selected)
|
|
return pre_selected # No changes
|
|
|
|
with patch(_PROBE, return_value={"github": tools}), \
|
|
patch(_CHECKLIST, side_effect=fake_checklist), \
|
|
patch(_SAVE):
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
# create_issue (0) and search (2) should be pre-selected, delete_repo (1) should not
|
|
assert captured_pre_selected["value"] == {0, 2}
|
|
|
|
|
|
def test_pre_selection_respects_existing_include(capsys):
|
|
"""Only tools in include list start checked."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {
|
|
"command": "npx",
|
|
"tools": {"include": ["search"]},
|
|
},
|
|
}
|
|
}
|
|
tools = [("create_issue", "Create"), ("delete_repo", "Delete"), ("search", "Search")]
|
|
captured_pre_selected = {}
|
|
|
|
def fake_checklist(title, labels, pre_selected, **kwargs):
|
|
captured_pre_selected["value"] = set(pre_selected)
|
|
return pre_selected # No changes
|
|
|
|
with patch(_PROBE, return_value={"github": tools}), \
|
|
patch(_CHECKLIST, side_effect=fake_checklist), \
|
|
patch(_SAVE):
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
# Only search (2) should be pre-selected
|
|
assert captured_pre_selected["value"] == {2}
|
|
|
|
|
|
def test_multiple_servers_each_get_checklist(capsys):
|
|
"""Each server gets its own checklist."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {"command": "npx"},
|
|
"slack": {"url": "https://mcp.example.com"},
|
|
}
|
|
}
|
|
checklist_calls = []
|
|
|
|
def fake_checklist(title, labels, pre_selected, **kwargs):
|
|
checklist_calls.append(title)
|
|
return pre_selected # No changes
|
|
|
|
with patch(
|
|
_PROBE,
|
|
return_value={
|
|
"github": [("create_issue", "Create")],
|
|
"slack": [("send_message", "Send")],
|
|
},
|
|
), patch(_CHECKLIST, side_effect=fake_checklist), \
|
|
patch(_SAVE):
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
assert len(checklist_calls) == 2
|
|
assert any("github" in t for t in checklist_calls)
|
|
assert any("slack" in t for t in checklist_calls)
|
|
|
|
|
|
def test_failed_server_shows_warning(capsys):
|
|
"""Servers that fail to connect show warnings."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {"command": "npx"},
|
|
"broken": {"command": "nonexistent"},
|
|
}
|
|
}
|
|
|
|
# Only github succeeds
|
|
with patch(
|
|
_PROBE, return_value={"github": [("create_issue", "Create")]},
|
|
), patch(_CHECKLIST, return_value={0}), \
|
|
patch(_SAVE):
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "broken" in captured.out
|
|
|
|
|
|
def test_description_truncation_in_labels():
|
|
"""Long descriptions are truncated in checklist labels."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"github": {"command": "npx"},
|
|
}
|
|
}
|
|
long_desc = "A" * 100
|
|
captured_labels = {}
|
|
|
|
def fake_checklist(title, labels, pre_selected, **kwargs):
|
|
captured_labels["value"] = labels
|
|
return pre_selected
|
|
|
|
with patch(
|
|
_PROBE, return_value={"github": [("my_tool", long_desc)]},
|
|
), patch(_CHECKLIST, side_effect=fake_checklist), \
|
|
patch(_SAVE):
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
label = captured_labels["value"][0]
|
|
assert "..." in label
|
|
assert len(label) < len(long_desc) + 30 # truncated + tool name + parens
|
|
|
|
|
|
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": {
|
|
"command": "npx",
|
|
"tools": {"include": ["create_issue"]},
|
|
},
|
|
}
|
|
}
|
|
tools = [("create_issue", "Create"), ("search", "Search"), ("delete", "Delete")]
|
|
|
|
# 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["include"] == ["create_issue", "search"]
|
|
assert "exclude" not in tools_cfg
|
|
|
|
|
|
def test_empty_tools_server_skipped(capsys):
|
|
"""Server with no tools shows info message and skips checklist."""
|
|
config = {
|
|
"mcp_servers": {
|
|
"empty": {"command": "npx"},
|
|
}
|
|
}
|
|
checklist_calls = []
|
|
|
|
def fake_checklist(title, labels, pre_selected, **kwargs):
|
|
checklist_calls.append(title)
|
|
return pre_selected
|
|
|
|
with patch(_PROBE, return_value={"empty": []}), \
|
|
patch(_CHECKLIST, side_effect=fake_checklist), \
|
|
patch(_SAVE):
|
|
_configure_mcp_tools_interactive(config)
|
|
|
|
assert len(checklist_calls) == 0
|
|
captured = capsys.readouterr()
|
|
assert "no tools found" in captured.out
|