feat: interactive MCP tool configuration in hermes tools (#1694)
Add the ability to selectively enable/disable individual MCP server tools through the interactive 'hermes tools' TUI. Changes: - tools/mcp_tool.py: Add probe_mcp_server_tools() — lightweight function that temporarily connects to configured MCP servers, discovers their tools (names + descriptions), and disconnects. No registry side effects. - hermes_cli/tools_config.py: Add 'Configure MCP tools' option to the interactive menu. When selected: 1. Probes all enabled MCP servers for their available tools 2. Shows a per-server curses checklist with tool descriptions 3. Pre-selects tools based on existing include/exclude config 4. Writes changes back as tools.exclude entries in config.yaml 5. Reports which servers failed to connect The existing CLI commands (hermes tools enable/disable server:tool) continue to work unchanged. This adds the interactive TUI counterpart so users can browse and toggle MCP tools visually. Tests: 22 new tests covering probe function edge cases and interactive flow (pre-selection, exclude/include modes, description truncation, multi-server handling, error paths).
This commit is contained in:
parent
56e0c90445
commit
ce7418e274
4 changed files with 712 additions and 1 deletions
|
|
@ -985,12 +985,19 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||||
if len(platform_keys) > 1:
|
if len(platform_keys) > 1:
|
||||||
platform_choices.append("Configure all platforms (global)")
|
platform_choices.append("Configure all platforms (global)")
|
||||||
platform_choices.append("Reconfigure an existing tool's provider or API key")
|
platform_choices.append("Reconfigure an existing tool's provider or API key")
|
||||||
|
|
||||||
|
# Show MCP option if any MCP servers are configured
|
||||||
|
_has_mcp = bool(config.get("mcp_servers"))
|
||||||
|
if _has_mcp:
|
||||||
|
platform_choices.append("Configure MCP server tools")
|
||||||
|
|
||||||
platform_choices.append("Done")
|
platform_choices.append("Done")
|
||||||
|
|
||||||
# Index offsets for the extra options after per-platform entries
|
# Index offsets for the extra options after per-platform entries
|
||||||
_global_idx = len(platform_keys) if len(platform_keys) > 1 else -1
|
_global_idx = len(platform_keys) if len(platform_keys) > 1 else -1
|
||||||
_reconfig_idx = len(platform_keys) + (1 if len(platform_keys) > 1 else 0)
|
_reconfig_idx = len(platform_keys) + (1 if len(platform_keys) > 1 else 0)
|
||||||
_done_idx = _reconfig_idx + 1
|
_mcp_idx = (_reconfig_idx + 1) if _has_mcp else -1
|
||||||
|
_done_idx = _reconfig_idx + (2 if _has_mcp else 1)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
idx = _prompt_choice("Select an option:", platform_choices, default=0)
|
idx = _prompt_choice("Select an option:", platform_choices, default=0)
|
||||||
|
|
@ -1005,6 +1012,12 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||||
print()
|
print()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# "Configure MCP tools" selected
|
||||||
|
if idx == _mcp_idx:
|
||||||
|
_configure_mcp_tools_interactive(config)
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
# "Configure all platforms (global)" selected
|
# "Configure all platforms (global)" selected
|
||||||
if idx == _global_idx:
|
if idx == _global_idx:
|
||||||
# Use the union of all platforms' current tools as the starting state
|
# Use the union of all platforms' current tools as the starting state
|
||||||
|
|
@ -1091,6 +1104,137 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── MCP Tools Interactive Configuration ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_mcp_tools_interactive(config: dict):
|
||||||
|
"""Probe MCP servers for available tools and let user toggle them on/off.
|
||||||
|
|
||||||
|
Connects to each configured MCP server, discovers tools, then shows
|
||||||
|
a per-server curses checklist. Writes changes back as ``tools.exclude``
|
||||||
|
entries in config.yaml.
|
||||||
|
"""
|
||||||
|
from hermes_cli.curses_ui import curses_checklist
|
||||||
|
|
||||||
|
mcp_servers = config.get("mcp_servers") or {}
|
||||||
|
if not mcp_servers:
|
||||||
|
_print_info("No MCP servers configured.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Count enabled servers
|
||||||
|
enabled_names = [
|
||||||
|
k for k, v in mcp_servers.items()
|
||||||
|
if v.get("enabled", True) not in (False, "false", "0", "no", "off")
|
||||||
|
]
|
||||||
|
if not enabled_names:
|
||||||
|
_print_info("All MCP servers are disabled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(color(" Discovering tools from MCP servers...", Colors.YELLOW))
|
||||||
|
print(color(f" Connecting to {len(enabled_names)} server(s): {', '.join(enabled_names)}", Colors.DIM))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
server_tools = probe_mcp_server_tools()
|
||||||
|
except Exception as exc:
|
||||||
|
_print_error(f"Failed to probe MCP servers: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not server_tools:
|
||||||
|
_print_warning("Could not discover tools from any MCP server.")
|
||||||
|
_print_info("Check that server commands/URLs are correct and dependencies are installed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Report discovery results
|
||||||
|
failed = [n for n in enabled_names if n not in server_tools]
|
||||||
|
if failed:
|
||||||
|
for name in failed:
|
||||||
|
_print_warning(f" Could not connect to '{name}'")
|
||||||
|
|
||||||
|
total_tools = sum(len(tools) for tools in server_tools.values())
|
||||||
|
print(color(f" Found {total_tools} tool(s) across {len(server_tools)} server(s)", Colors.GREEN))
|
||||||
|
print()
|
||||||
|
|
||||||
|
any_changes = False
|
||||||
|
|
||||||
|
for server_name, tools in server_tools.items():
|
||||||
|
if not tools:
|
||||||
|
_print_info(f" {server_name}: no tools found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
srv_cfg = mcp_servers.get(server_name, {})
|
||||||
|
tools_cfg = srv_cfg.get("tools") or {}
|
||||||
|
include_list = tools_cfg.get("include") or []
|
||||||
|
exclude_list = tools_cfg.get("exclude") or []
|
||||||
|
|
||||||
|
# Build checklist labels
|
||||||
|
labels = []
|
||||||
|
for tool_name, description in tools:
|
||||||
|
desc_short = description[:70] + "..." if len(description) > 70 else description
|
||||||
|
if desc_short:
|
||||||
|
labels.append(f"{tool_name} ({desc_short})")
|
||||||
|
else:
|
||||||
|
labels.append(tool_name)
|
||||||
|
|
||||||
|
# Determine which tools are currently enabled
|
||||||
|
pre_selected: Set[int] = set()
|
||||||
|
tool_names = [t[0] for t in tools]
|
||||||
|
for i, tool_name in enumerate(tool_names):
|
||||||
|
if include_list:
|
||||||
|
# Include mode: only included tools are selected
|
||||||
|
if tool_name in include_list:
|
||||||
|
pre_selected.add(i)
|
||||||
|
elif exclude_list:
|
||||||
|
# Exclude mode: everything except excluded
|
||||||
|
if tool_name not in exclude_list:
|
||||||
|
pre_selected.add(i)
|
||||||
|
else:
|
||||||
|
# No filter: all enabled
|
||||||
|
pre_selected.add(i)
|
||||||
|
|
||||||
|
chosen = curses_checklist(
|
||||||
|
f"MCP Server: {server_name} ({len(tools)} tools)",
|
||||||
|
labels,
|
||||||
|
pre_selected,
|
||||||
|
cancel_returns=pre_selected,
|
||||||
|
)
|
||||||
|
|
||||||
|
if chosen == pre_selected:
|
||||||
|
_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]
|
||||||
|
|
||||||
|
# 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
|
||||||
|
tools_cfg.pop("exclude", None)
|
||||||
|
tools_cfg.pop("include", None)
|
||||||
|
|
||||||
|
enabled_count = len(chosen)
|
||||||
|
disabled_count = len(tools) - enabled_count
|
||||||
|
_print_success(
|
||||||
|
f" {server_name}: {enabled_count} enabled, {disabled_count} disabled"
|
||||||
|
)
|
||||||
|
any_changes = True
|
||||||
|
|
||||||
|
if any_changes:
|
||||||
|
save_config(config)
|
||||||
|
print()
|
||||||
|
print(color(" ✓ MCP tool configuration saved", Colors.GREEN))
|
||||||
|
else:
|
||||||
|
print(color(" No changes to MCP tools", Colors.DIM))
|
||||||
|
|
||||||
|
|
||||||
# ─── Non-interactive disable/enable ──────────────────────────────────────────
|
# ─── Non-interactive disable/enable ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
291
tests/hermes_cli/test_mcp_tools_config.py
Normal file
291
tests/hermes_cli/test_mcp_tools_config.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""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_exclude_list(capsys):
|
||||||
|
"""Unchecking a tool adds it to the exclude list."""
|
||||||
|
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["exclude"] == ["delete_repo"]
|
||||||
|
assert "include" 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_switching_from_include_to_exclude(capsys):
|
||||||
|
"""When user modifies selection, include list is replaced by exclude list."""
|
||||||
|
config = {
|
||||||
|
"mcp_servers": {
|
||||||
|
"github": {
|
||||||
|
"command": "npx",
|
||||||
|
"tools": {"include": ["create_issue"]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
210
tests/tools/test_mcp_probe.py
Normal file
210
tests/tools/test_mcp_probe.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""Tests for probe_mcp_server_tools() in tools.mcp_tool."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_mcp_state():
|
||||||
|
"""Ensure clean MCP module state before/after each test."""
|
||||||
|
import tools.mcp_tool as mcp
|
||||||
|
old_loop = mcp._mcp_loop
|
||||||
|
old_thread = mcp._mcp_thread
|
||||||
|
old_servers = dict(mcp._servers)
|
||||||
|
yield
|
||||||
|
mcp._servers.clear()
|
||||||
|
mcp._servers.update(old_servers)
|
||||||
|
mcp._mcp_loop = old_loop
|
||||||
|
mcp._mcp_thread = old_thread
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeMcpServerTools:
|
||||||
|
"""Tests for the lightweight probe_mcp_server_tools function."""
|
||||||
|
|
||||||
|
def test_returns_empty_when_mcp_not_available(self):
|
||||||
|
with patch("tools.mcp_tool._MCP_AVAILABLE", False):
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_config(self):
|
||||||
|
with patch("tools.mcp_tool._load_mcp_config", return_value={}):
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_returns_empty_when_all_servers_disabled(self):
|
||||||
|
config = {
|
||||||
|
"github": {"command": "npx", "enabled": False},
|
||||||
|
"slack": {"command": "npx", "enabled": "off"},
|
||||||
|
}
|
||||||
|
with patch("tools.mcp_tool._load_mcp_config", return_value=config):
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_returns_tools_from_successful_server(self):
|
||||||
|
"""Successfully probed server returns its tools list."""
|
||||||
|
config = {
|
||||||
|
"github": {"command": "npx", "connect_timeout": 5},
|
||||||
|
}
|
||||||
|
mock_tool_1 = SimpleNamespace(name="create_issue", description="Create a new issue")
|
||||||
|
mock_tool_2 = SimpleNamespace(name="search_repos", description="Search repositories")
|
||||||
|
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_server._tools = [mock_tool_1, mock_tool_2]
|
||||||
|
mock_server.shutdown = AsyncMock()
|
||||||
|
|
||||||
|
async def fake_connect(name, cfg):
|
||||||
|
return mock_server
|
||||||
|
|
||||||
|
with patch("tools.mcp_tool._load_mcp_config", return_value=config), \
|
||||||
|
patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \
|
||||||
|
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||||
|
patch("tools.mcp_tool._run_on_mcp_loop") as mock_run, \
|
||||||
|
patch("tools.mcp_tool._stop_mcp_loop"):
|
||||||
|
|
||||||
|
# Simulate running the async probe
|
||||||
|
def run_coro(coro, timeout=120):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
mock_run.side_effect = run_coro
|
||||||
|
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
|
||||||
|
assert "github" in result
|
||||||
|
assert len(result["github"]) == 2
|
||||||
|
assert result["github"][0] == ("create_issue", "Create a new issue")
|
||||||
|
assert result["github"][1] == ("search_repos", "Search repositories")
|
||||||
|
mock_server.shutdown.assert_awaited_once()
|
||||||
|
|
||||||
|
def test_failed_server_omitted_from_results(self):
|
||||||
|
"""Servers that fail to connect are silently skipped."""
|
||||||
|
config = {
|
||||||
|
"github": {"command": "npx", "connect_timeout": 5},
|
||||||
|
"broken": {"command": "nonexistent", "connect_timeout": 5},
|
||||||
|
}
|
||||||
|
mock_tool = SimpleNamespace(name="create_issue", description="Create")
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_server._tools = [mock_tool]
|
||||||
|
mock_server.shutdown = AsyncMock()
|
||||||
|
|
||||||
|
async def fake_connect(name, cfg):
|
||||||
|
if name == "broken":
|
||||||
|
raise ConnectionError("Server not found")
|
||||||
|
return mock_server
|
||||||
|
|
||||||
|
with patch("tools.mcp_tool._load_mcp_config", return_value=config), \
|
||||||
|
patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \
|
||||||
|
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||||
|
patch("tools.mcp_tool._run_on_mcp_loop") as mock_run, \
|
||||||
|
patch("tools.mcp_tool._stop_mcp_loop"):
|
||||||
|
|
||||||
|
def run_coro(coro, timeout=120):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
mock_run.side_effect = run_coro
|
||||||
|
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
|
||||||
|
assert "github" in result
|
||||||
|
assert "broken" not in result
|
||||||
|
|
||||||
|
def test_handles_tool_without_description(self):
|
||||||
|
"""Tools without descriptions get empty string."""
|
||||||
|
config = {"github": {"command": "npx", "connect_timeout": 5}}
|
||||||
|
mock_tool = SimpleNamespace(name="my_tool") # no description attribute
|
||||||
|
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_server._tools = [mock_tool]
|
||||||
|
mock_server.shutdown = AsyncMock()
|
||||||
|
|
||||||
|
async def fake_connect(name, cfg):
|
||||||
|
return mock_server
|
||||||
|
|
||||||
|
with patch("tools.mcp_tool._load_mcp_config", return_value=config), \
|
||||||
|
patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \
|
||||||
|
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||||
|
patch("tools.mcp_tool._run_on_mcp_loop") as mock_run, \
|
||||||
|
patch("tools.mcp_tool._stop_mcp_loop"):
|
||||||
|
|
||||||
|
def run_coro(coro, timeout=120):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
mock_run.side_effect = run_coro
|
||||||
|
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
|
||||||
|
assert result["github"][0] == ("my_tool", "")
|
||||||
|
|
||||||
|
def test_cleanup_called_even_on_failure(self):
|
||||||
|
"""_stop_mcp_loop is called even when probe fails."""
|
||||||
|
config = {"github": {"command": "npx", "connect_timeout": 5}}
|
||||||
|
|
||||||
|
with patch("tools.mcp_tool._load_mcp_config", return_value=config), \
|
||||||
|
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||||
|
patch("tools.mcp_tool._run_on_mcp_loop", side_effect=RuntimeError("boom")), \
|
||||||
|
patch("tools.mcp_tool._stop_mcp_loop") as mock_stop:
|
||||||
|
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
mock_stop.assert_called_once()
|
||||||
|
|
||||||
|
def test_skips_disabled_servers(self):
|
||||||
|
"""Disabled servers are not probed."""
|
||||||
|
config = {
|
||||||
|
"github": {"command": "npx", "connect_timeout": 5},
|
||||||
|
"disabled_one": {"command": "npx", "enabled": False},
|
||||||
|
}
|
||||||
|
mock_tool = SimpleNamespace(name="create_issue", description="Create")
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_server._tools = [mock_tool]
|
||||||
|
mock_server.shutdown = AsyncMock()
|
||||||
|
|
||||||
|
connect_calls = []
|
||||||
|
|
||||||
|
async def fake_connect(name, cfg):
|
||||||
|
connect_calls.append(name)
|
||||||
|
return mock_server
|
||||||
|
|
||||||
|
with patch("tools.mcp_tool._load_mcp_config", return_value=config), \
|
||||||
|
patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \
|
||||||
|
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||||
|
patch("tools.mcp_tool._run_on_mcp_loop") as mock_run, \
|
||||||
|
patch("tools.mcp_tool._stop_mcp_loop"):
|
||||||
|
|
||||||
|
def run_coro(coro, timeout=120):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
mock_run.side_effect = run_coro
|
||||||
|
|
||||||
|
from tools.mcp_tool import probe_mcp_server_tools
|
||||||
|
result = probe_mcp_server_tools()
|
||||||
|
|
||||||
|
assert "github" in result
|
||||||
|
assert "disabled_one" not in result
|
||||||
|
assert "disabled_one" not in connect_calls
|
||||||
|
|
@ -1624,6 +1624,72 @@ def get_mcp_status() -> List[dict]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def probe_mcp_server_tools() -> Dict[str, List[tuple]]:
|
||||||
|
"""Temporarily connect to configured MCP servers and list their tools.
|
||||||
|
|
||||||
|
Designed for ``hermes tools`` interactive configuration — connects to each
|
||||||
|
enabled server, grabs tool names and descriptions, then disconnects.
|
||||||
|
Does NOT register tools in the Hermes registry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping server name to list of (tool_name, description) tuples.
|
||||||
|
Servers that fail to connect are omitted from the result.
|
||||||
|
"""
|
||||||
|
if not _MCP_AVAILABLE:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
servers_config = _load_mcp_config()
|
||||||
|
if not servers_config:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
enabled = {
|
||||||
|
k: v for k, v in servers_config.items()
|
||||||
|
if _parse_boolish(v.get("enabled", True), default=True)
|
||||||
|
}
|
||||||
|
if not enabled:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
_ensure_mcp_loop()
|
||||||
|
|
||||||
|
result: Dict[str, List[tuple]] = {}
|
||||||
|
probed_servers: List[MCPServerTask] = []
|
||||||
|
|
||||||
|
async def _probe_all():
|
||||||
|
names = list(enabled.keys())
|
||||||
|
coros = []
|
||||||
|
for name, cfg in enabled.items():
|
||||||
|
ct = cfg.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT)
|
||||||
|
coros.append(asyncio.wait_for(_connect_server(name, cfg), timeout=ct))
|
||||||
|
|
||||||
|
outcomes = await asyncio.gather(*coros, return_exceptions=True)
|
||||||
|
|
||||||
|
for name, outcome in zip(names, outcomes):
|
||||||
|
if isinstance(outcome, Exception):
|
||||||
|
logger.debug("Probe: failed to connect to '%s': %s", name, outcome)
|
||||||
|
continue
|
||||||
|
probed_servers.append(outcome)
|
||||||
|
tools = []
|
||||||
|
for t in outcome._tools:
|
||||||
|
desc = getattr(t, "description", "") or ""
|
||||||
|
tools.append((t.name, desc))
|
||||||
|
result[name] = tools
|
||||||
|
|
||||||
|
# Shut down all probed connections
|
||||||
|
await asyncio.gather(
|
||||||
|
*(s.shutdown() for s in probed_servers),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_run_on_mcp_loop(_probe_all(), timeout=120)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("MCP probe failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
_stop_mcp_loop()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def shutdown_mcp_servers():
|
def shutdown_mcp_servers():
|
||||||
"""Close all MCP server connections and stop the background loop.
|
"""Close all MCP server connections and stop the background loop.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue