From 4b53b89f0964933790b5c31819b3f53446dda183 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 18 Mar 2026 03:04:17 -0700 Subject: [PATCH] feat(mcp): expose MCP servers as standalone toolsets Each configured MCP server now registers as its own toolset in TOOLSETS (e.g. TOOLSETS['github'] = {tools: ['mcp_github_list_files', ...]}), making raw server names resolvable in platform_toolsets overrides. Previously MCP tools were only injected into hermes-* umbrella toolsets, so gateway sessions using raw toolset names like ['terminal', 'github'] in platform_toolsets couldn't resolve MCP tools. Skips server names that collide with built-in toolsets. Also handles idempotent reloads (syncs toolsets even when no new servers connect). Inspired by PR #1876 by @kshitijk4poor. Adds 2 tests (standalone toolset creation + built-in collision guard). --- tests/tools/test_mcp_tool.py | 36 +++++++++++++++++++++ tools/mcp_tool.py | 61 +++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 9c49bd2c2..38654a18e 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -505,6 +505,42 @@ class TestToolsetInjection: assert "mcp_fs_list_files" not in fake_toolsets["non-hermes"]["tools"] # Original tools preserved assert "terminal" in fake_toolsets["hermes-cli"]["tools"] + # Server name becomes a standalone toolset + assert "fs" in fake_toolsets + assert "mcp_fs_list_files" in fake_toolsets["fs"]["tools"] + assert fake_toolsets["fs"]["description"].startswith("MCP server '") + + def test_server_toolset_skips_builtin_collision(self): + """MCP server named after a built-in toolset shouldn't overwrite it.""" + from tools.mcp_tool import MCPServerTask + + mock_tools = [_make_mcp_tool("run", "Run command")] + mock_session = MagicMock() + fresh_servers = {} + + async def fake_connect(name, config): + server = MCPServerTask(name) + server.session = mock_session + server._tools = mock_tools + return server + + fake_toolsets = { + "hermes-cli": {"tools": ["terminal"], "description": "CLI", "includes": []}, + # Built-in toolset named "terminal" — must not be overwritten + "terminal": {"tools": ["terminal"], "description": "Terminal tools", "includes": []}, + } + fake_config = {"terminal": {"command": "npx", "args": []}} + + with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch("tools.mcp_tool._servers", fresh_servers), \ + patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ + patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ + patch("toolsets.TOOLSETS", fake_toolsets): + from tools.mcp_tool import discover_mcp_tools + discover_mcp_tools() + + # Built-in toolset preserved — description unchanged + assert fake_toolsets["terminal"]["description"] == "Terminal tools" def test_server_connection_failure_skipped(self): """If one server fails to connect, others still proceed.""" diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 7ff8103b2..c22b824f3 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1238,6 +1238,57 @@ def _convert_mcp_schema(server_name: str, mcp_tool) -> dict: } +def _sync_mcp_toolsets(server_names: Optional[List[str]] = None) -> None: + """Expose each MCP server as a standalone toolset and inject into hermes-* sets. + + Creates a real toolset entry in TOOLSETS for each server name (e.g. + TOOLSETS["github"] = {"tools": ["mcp_github_list_files", ...]}). This + makes raw server names resolvable in platform_toolsets overrides. + + Also injects all MCP tools into hermes-* umbrella toolsets for the + default behavior. + + Skips server names that collide with built-in toolsets. + """ + from toolsets import TOOLSETS + + if server_names is None: + server_names = list(_load_mcp_config().keys()) + + existing = _existing_tool_names() + all_mcp_tools: List[str] = [] + + for server_name in server_names: + safe_prefix = f"mcp_{server_name.replace('-', '_').replace('.', '_')}_" + server_tools = sorted( + t for t in existing if t.startswith(safe_prefix) + ) + all_mcp_tools.extend(server_tools) + + # Don't overwrite a built-in toolset that happens to share the name. + existing_ts = TOOLSETS.get(server_name) + if existing_ts and not str(existing_ts.get("description", "")).startswith("MCP server '"): + logger.warning( + "Skipping MCP toolset alias '%s' — a built-in toolset already uses that name", + server_name, + ) + continue + + TOOLSETS[server_name] = { + "description": f"MCP server '{server_name}' tools", + "tools": server_tools, + "includes": [], + } + + # Also inject into hermes-* umbrella toolsets for default behavior. + for ts_name, ts in TOOLSETS.items(): + if not ts_name.startswith("hermes-"): + continue + for tool_name in all_mcp_tools: + if tool_name not in ts["tools"]: + ts["tools"].append(tool_name) + + def _build_utility_schemas(server_name: str) -> List[dict]: """Build schemas for the MCP utility tools (resources & prompts). @@ -1523,6 +1574,7 @@ def discover_mcp_tools() -> List[str]: } if not new_servers: + _sync_mcp_toolsets(list(servers.keys())) return _existing_tool_names() # Start the background event loop for MCP connections @@ -1562,14 +1614,7 @@ def discover_mcp_tools() -> List[str]: # The outer timeout is generous: 120s total for parallel discovery. _run_on_mcp_loop(_discover_all(), timeout=120) - if all_tools: - # Dynamically inject into all hermes-* platform toolsets - from toolsets import TOOLSETS - for ts_name, ts in TOOLSETS.items(): - if ts_name.startswith("hermes-"): - for tool_name in all_tools: - if tool_name not in ts["tools"]: - ts["tools"].append(tool_name) + _sync_mcp_toolsets(list(servers.keys())) # Print summary total_servers = len(new_servers)