From 3198cc8fd9cc1741fdad5be0f0f26cedef2eda9b Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Thu, 12 Mar 2026 02:31:09 +0300 Subject: [PATCH 1/3] feat(mcp): per-server tool filtering via include/exclude and enabled flag Add optional config keys under each mcp_servers entry: - tools.include: whitelist, only listed tools are registered - tools.exclude: blacklist, all tools except listed are registered - enabled: false: skip server entirely, no connection attempt Backward-compatible: no config keys = all tools registered as before. Tests: TestMCPSelectiveToolLoading (4 tests), 134 passed total. --- tests/tools/test_mcp_tool.py | 76 ++++++++++++++++++++++++++++++++++++ tools/mcp_tool.py | 28 ++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index f300082e..377714ef 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -2447,3 +2447,79 @@ class TestDiscoveryFailedCount: _servers.pop("ok1", None) _servers.pop("ok2", None) _servers.pop("fail1", None) + + +class TestMCPSelectiveToolLoading: + """Tests for tools.include / tools.exclude / enabled config keys.""" + + def _make_server(self, name, tool_names): + from tools.mcp_tool import MCPServerTask + server = MCPServerTask(name) + server.session = MagicMock() + server._tools = [_make_mcp_tool(n, n) for n in tool_names] + return server + + def _run_discover(self, name, tool_names, config): + """Run _discover_and_register_server directly and return registered names.""" + import asyncio + from tools.mcp_tool import _discover_and_register_server + server = self._make_server(name, tool_names) + + async def fake_connect(n, c): + return server + + async def run(): + with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), patch("tools.mcp_tool._servers", {}): + return await _discover_and_register_server(name, config) + + return asyncio.run(run()) + + def test_include_filter_registers_only_listed_tools(self): + """tools.include whitelist: only specified tools are registered.""" + tool_names = ["create_service", "delete_service", "list_services"] + config = {"url": "https://mcp.example.com", "tools": {"include": ["create_service", "list_services"]}} + result = self._run_discover("ink", tool_names, config) + assert "mcp_ink_create_service" in result + assert "mcp_ink_list_services" in result + assert "mcp_ink_delete_service" not in result + + def test_exclude_filter_skips_listed_tools(self): + """tools.exclude blacklist: all tools except specified are registered.""" + tool_names = ["create_service", "delete_service", "list_services"] + config = {"url": "https://mcp.example.com", "tools": {"exclude": ["delete_service"]}} + result = self._run_discover("ink2", tool_names, config) + assert "mcp_ink2_create_service" in result + assert "mcp_ink2_list_services" in result + assert "mcp_ink2_delete_service" not in result + + def test_no_filter_registers_all_tools(self): + """No tools filter: all tools registered (backward compatible).""" + tool_names = ["create_service", "delete_service", "list_services"] + config = {"url": "https://mcp.example.com"} + result = self._run_discover("ink3", tool_names, config) + assert "mcp_ink3_create_service" in result + assert "mcp_ink3_delete_service" in result + assert "mcp_ink3_list_services" in result + + def test_enabled_false_skips_server(self): + """enabled: false skips the server entirely.""" + fresh_servers = {} + fake_config = { + "ink": { + "url": "https://mcp.example.com", + "enabled": False, + } + } + connect_called = [] + + async def fake_connect(name, config): + connect_called.append(name) + return self._make_server(name, ["create_service"]) + + 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): + from tools.mcp_tool import discover_mcp_tools + result = discover_mcp_tools() + + assert connect_called == [] + assert "mcp_ink_create_service" not in result + diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 448af920..a8deb3ae 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1343,7 +1343,27 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]: registered_names: List[str] = [] toolset_name = f"mcp-{name}" + # Selective tool loading: honour include/exclude lists from config. + # Rules (matching issue #690 spec): + # tools.include — whitelist: only these tool names are registered + # tools.exclude — blacklist: all tools EXCEPT these are registered + # include and exclude are mutually exclusive; include takes precedence + # Neither set → register all tools (backward-compatible default) + tools_filter = config.get("tools") or {} + include_set = set(tools_filter.get("include") or []) + exclude_set = set(tools_filter.get("exclude") or []) + + def _should_register(tool_name: str) -> bool: + if include_set: + return tool_name in include_set + if exclude_set: + return tool_name not in exclude_set + return True + for mcp_tool in server._tools: + if not _should_register(mcp_tool.name): + logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name) + continue schema = _convert_mcp_schema(name, mcp_tool) tool_name_prefixed = schema["name"] @@ -1424,9 +1444,13 @@ def discover_mcp_tools() -> List[str]: logger.debug("No MCP servers configured") return [] - # Only attempt servers that aren't already connected + # Only attempt servers that aren't already connected and are enabled + # (enabled: false skips the server entirely without removing its config) with _lock: - new_servers = {k: v for k, v in servers.items() if k not in _servers} + new_servers = { + k: v for k, v in servers.items() + if k not in _servers and v.get("enabled", True) is not False + } if not new_servers: return _existing_tool_names() From 04e151714f21deb8abc95d97a07fa850d35cf0c5 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 06:22:02 -0700 Subject: [PATCH 2/3] feat(mcp): make selective tool loading capability-aware Extend the salvaged MCP filtering work so utility tools are also governed by policy and server capabilities. Store the registered tool subset per server so rediscovery and status reporting stay accurate after filtering. --- tests/tools/test_mcp_tool.py | 256 +++++++++++++++++++++++++++-------- tools/mcp_tool.py | 98 ++++++++++++-- 2 files changed, 287 insertions(+), 67 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 377714ef..3796d8ce 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -2450,76 +2450,226 @@ class TestDiscoveryFailedCount: class TestMCPSelectiveToolLoading: - """Tests for tools.include / tools.exclude / enabled config keys.""" + """Tests for per-server MCP filtering and utility tool policies.""" - def _make_server(self, name, tool_names): - from tools.mcp_tool import MCPServerTask - server = MCPServerTask(name) - server.session = MagicMock() - server._tools = [_make_mcp_tool(n, n) for n in tool_names] + def _make_server(self, name, tool_names, session=None): + server = _make_mock_server( + name, + session=session or SimpleNamespace(), + tools=[_make_mcp_tool(n, n) for n in tool_names], + ) return server - def _run_discover(self, name, tool_names, config): - """Run _discover_and_register_server directly and return registered names.""" - import asyncio - from tools.mcp_tool import _discover_and_register_server - server = self._make_server(name, tool_names) + def _run_discover(self, name, tool_names, config, session=None): + from tools.registry import ToolRegistry + from tools.mcp_tool import _discover_and_register_server, _servers - async def fake_connect(n, c): + mock_registry = ToolRegistry() + server = self._make_server(name, tool_names, session=session) + + async def fake_connect(_name, _config): return server async def run(): - with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), patch("tools.mcp_tool._servers", {}): + with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ + patch("tools.registry.registry", mock_registry), \ + patch("toolsets.create_custom_toolset"): return await _discover_and_register_server(name, config) - return asyncio.run(run()) + try: + registered = asyncio.run(run()) + finally: + _servers.pop(name, None) + return registered, mock_registry - def test_include_filter_registers_only_listed_tools(self): - """tools.include whitelist: only specified tools are registered.""" - tool_names = ["create_service", "delete_service", "list_services"] - config = {"url": "https://mcp.example.com", "tools": {"include": ["create_service", "list_services"]}} - result = self._run_discover("ink", tool_names, config) - assert "mcp_ink_create_service" in result - assert "mcp_ink_list_services" in result - assert "mcp_ink_delete_service" not in result - - def test_exclude_filter_skips_listed_tools(self): - """tools.exclude blacklist: all tools except specified are registered.""" - tool_names = ["create_service", "delete_service", "list_services"] - config = {"url": "https://mcp.example.com", "tools": {"exclude": ["delete_service"]}} - result = self._run_discover("ink2", tool_names, config) - assert "mcp_ink2_create_service" in result - assert "mcp_ink2_list_services" in result - assert "mcp_ink2_delete_service" not in result - - def test_no_filter_registers_all_tools(self): - """No tools filter: all tools registered (backward compatible).""" - tool_names = ["create_service", "delete_service", "list_services"] - config = {"url": "https://mcp.example.com"} - result = self._run_discover("ink3", tool_names, config) - assert "mcp_ink3_create_service" in result - assert "mcp_ink3_delete_service" in result - assert "mcp_ink3_list_services" in result - - def test_enabled_false_skips_server(self): - """enabled: false skips the server entirely.""" - fresh_servers = {} - fake_config = { - "ink": { - "url": "https://mcp.example.com", - "enabled": False, - } + def test_include_takes_precedence_over_exclude(self): + config = { + "url": "https://mcp.example.com", + "tools": { + "include": ["create_service"], + "exclude": ["create_service", "delete_service"], + }, } + registered, _ = self._run_discover( + "ink", + ["create_service", "delete_service", "list_services"], + config, + session=SimpleNamespace(), + ) + assert registered == ["mcp_ink_create_service"] + + def test_exclude_filter_registers_all_except_listed_tools(self): + config = { + "url": "https://mcp.example.com", + "tools": {"exclude": ["delete_service"]}, + } + registered, _ = self._run_discover( + "ink_exclude", + ["create_service", "delete_service", "list_services"], + config, + session=SimpleNamespace(), + ) + assert registered == [ + "mcp_ink_exclude_create_service", + "mcp_ink_exclude_list_services", + ] + + def test_include_filter_skips_utility_tools_without_capabilities(self): + config = { + "url": "https://mcp.example.com", + "tools": {"include": ["create_service"]}, + } + registered, mock_registry = self._run_discover( + "ink_no_caps", + ["create_service", "delete_service"], + config, + session=SimpleNamespace(), + ) + assert registered == ["mcp_ink_no_caps_create_service"] + assert set(mock_registry.get_all_tool_names()) == {"mcp_ink_no_caps_create_service"} + + def test_no_filter_registers_all_server_tools_when_no_utilities_supported(self): + registered, _ = self._run_discover( + "ink_no_filter", + ["create_service", "delete_service", "list_services"], + {"url": "https://mcp.example.com"}, + session=SimpleNamespace(), + ) + assert registered == [ + "mcp_ink_no_filter_create_service", + "mcp_ink_no_filter_delete_service", + "mcp_ink_no_filter_list_services", + ] + + def test_resources_and_prompts_can_be_disabled_explicitly(self): + session = SimpleNamespace( + list_resources=AsyncMock(), + read_resource=AsyncMock(), + list_prompts=AsyncMock(), + get_prompt=AsyncMock(), + ) + config = { + "url": "https://mcp.example.com", + "tools": { + "resources": False, + "prompts": False, + }, + } + registered, _ = self._run_discover( + "ink_disabled_utils", + ["create_service"], + config, + session=session, + ) + assert registered == ["mcp_ink_disabled_utils_create_service"] + + def test_registers_only_utility_tools_supported_by_server_capabilities(self): + session = SimpleNamespace( + list_resources=AsyncMock(return_value=SimpleNamespace(resources=[])), + read_resource=AsyncMock(return_value=SimpleNamespace(contents=[])), + ) + registered, _ = self._run_discover( + "ink_resources_only", + ["create_service"], + {"url": "https://mcp.example.com"}, + session=session, + ) + assert "mcp_ink_resources_only_create_service" in registered + assert "mcp_ink_resources_only_list_resources" in registered + assert "mcp_ink_resources_only_read_resource" in registered + assert "mcp_ink_resources_only_list_prompts" not in registered + assert "mcp_ink_resources_only_get_prompt" not in registered + + def test_existing_tool_names_reflect_registered_subset(self): + from tools.mcp_tool import _existing_tool_names, _servers, _discover_and_register_server + from tools.registry import ToolRegistry + + mock_registry = ToolRegistry() + server = self._make_server( + "ink_existing", + ["create_service", "delete_service"], + session=SimpleNamespace(), + ) + + async def fake_connect(_name, _config): + return server + + async def run(): + with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ + patch("tools.registry.registry", mock_registry), \ + patch("toolsets.create_custom_toolset"): + return await _discover_and_register_server( + "ink_existing", + {"url": "https://mcp.example.com", "tools": {"include": ["create_service"]}}, + ) + + try: + registered = asyncio.run(run()) + assert registered == ["mcp_ink_existing_create_service"] + assert _existing_tool_names() == ["mcp_ink_existing_create_service"] + finally: + _servers.pop("ink_existing", None) + + def test_no_toolset_created_when_everything_is_filtered_out(self): + from tools.registry import ToolRegistry + from tools.mcp_tool import _discover_and_register_server, _servers + + mock_registry = ToolRegistry() + server = self._make_server("ink_none", ["create_service"], session=SimpleNamespace()) + mock_create = MagicMock() + + async def fake_connect(_name, _config): + return server + + async def run(): + with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ + patch("tools.registry.registry", mock_registry), \ + patch("toolsets.create_custom_toolset", mock_create): + return await _discover_and_register_server( + "ink_none", + { + "url": "https://mcp.example.com", + "tools": { + "include": ["missing_tool"], + "resources": False, + "prompts": False, + }, + }, + ) + + try: + registered = asyncio.run(run()) + assert registered == [] + mock_create.assert_not_called() + assert mock_registry.get_all_tool_names() == [] + finally: + _servers.pop("ink_none", None) + + def test_enabled_false_skips_connection_attempt(self): + from tools.mcp_tool import discover_mcp_tools + connect_called = [] async def fake_connect(name, config): connect_called.append(name) return self._make_server(name, ["create_service"]) - 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): - from tools.mcp_tool import discover_mcp_tools + fake_config = { + "ink": { + "url": "https://mcp.example.com", + "enabled": False, + } + } + fake_toolsets = { + "hermes-cli": {"tools": [], "description": "CLI", "includes": []}, + } + + with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch("tools.mcp_tool._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): result = discover_mcp_tools() assert connect_called == [] - assert "mcp_ink_create_service" not in result - + assert result == [] diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index a8deb3ae..7294e8be 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -688,7 +688,7 @@ class MCPServerTask: __slots__ = ( "name", "session", "tool_timeout", "_task", "_ready", "_shutdown_event", "_tools", "_error", "_config", - "_sampling", + "_sampling", "_registered_tool_names", ) def __init__(self, name: str): @@ -702,6 +702,7 @@ class MCPServerTask: self._error: Optional[Exception] = None self._config: dict = {} self._sampling: Optional[SamplingHandler] = None + self._registered_tool_names: list[str] = [] def _is_http(self) -> bool: """Check if this server uses HTTP transport.""" @@ -1308,16 +1309,81 @@ def _build_utility_schemas(server_name: str) -> List[dict]: ] +def _normalize_name_filter(value: Any, label: str) -> set[str]: + """Normalize include/exclude config to a set of tool names.""" + if value is None: + return set() + if isinstance(value, str): + return {value} + if isinstance(value, (list, tuple, set)): + return {str(item) for item in value} + logger.warning("MCP config %s must be a string or list of strings; ignoring %r", label, value) + return set() + + +def _parse_boolish(value: Any, default: bool = True) -> bool: + """Parse a bool-like config value with safe fallback.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + logger.warning("MCP config expected a boolean-ish value, got %r; using default=%s", value, default) + return default + + +_UTILITY_CAPABILITY_METHODS = { + "list_resources": "list_resources", + "read_resource": "read_resource", + "list_prompts": "list_prompts", + "get_prompt": "get_prompt", +} + + +def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]: + """Select utility schemas based on config and server capabilities.""" + tools_filter = config.get("tools") or {} + resources_enabled = _parse_boolish(tools_filter.get("resources"), default=True) + prompts_enabled = _parse_boolish(tools_filter.get("prompts"), default=True) + + selected: List[dict] = [] + for entry in _build_utility_schemas(server_name): + handler_key = entry["handler_key"] + if handler_key in {"list_resources", "read_resource"} and not resources_enabled: + logger.debug("MCP server '%s': skipping utility '%s' (resources disabled)", server_name, handler_key) + continue + if handler_key in {"list_prompts", "get_prompt"} and not prompts_enabled: + logger.debug("MCP server '%s': skipping utility '%s' (prompts disabled)", server_name, handler_key) + continue + + required_method = _UTILITY_CAPABILITY_METHODS[handler_key] + if not hasattr(server.session, required_method): + logger.debug( + "MCP server '%s': skipping utility '%s' (session lacks %s)", + server_name, + handler_key, + required_method, + ) + continue + selected.append(entry) + return selected + + def _existing_tool_names() -> List[str]: """Return tool names for all currently connected servers.""" names: List[str] = [] - for sname, server in _servers.items(): + for _sname, server in _servers.items(): + if hasattr(server, "_registered_tool_names"): + names.extend(server._registered_tool_names) + continue for mcp_tool in server._tools: - schema = _convert_mcp_schema(sname, mcp_tool) + schema = _convert_mcp_schema(server.name, mcp_tool) names.append(schema["name"]) - # Also include utility tool names - for entry in _build_utility_schemas(sname): - names.append(entry["schema"]["name"]) return names @@ -1347,11 +1413,11 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]: # Rules (matching issue #690 spec): # tools.include — whitelist: only these tool names are registered # tools.exclude — blacklist: all tools EXCEPT these are registered - # include and exclude are mutually exclusive; include takes precedence + # include takes precedence over exclude # Neither set → register all tools (backward-compatible default) tools_filter = config.get("tools") or {} - include_set = set(tools_filter.get("include") or []) - exclude_set = set(tools_filter.get("exclude") or []) + include_set = _normalize_name_filter(tools_filter.get("include"), f"mcp_servers.{name}.tools.include") + exclude_set = _normalize_name_filter(tools_filter.get("exclude"), f"mcp_servers.{name}.tools.exclude") def _should_register(tool_name: str) -> bool: if include_set: @@ -1378,7 +1444,8 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]: ) registered_names.append(tool_name_prefixed) - # Register MCP Resources & Prompts utility tools + # Register MCP Resources & Prompts utility tools, filtered by config and + # only when the server actually supports the corresponding capability. _handler_factories = { "list_resources": _make_list_resources_handler, "read_resource": _make_read_resource_handler, @@ -1386,7 +1453,7 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]: "get_prompt": _make_get_prompt_handler, } check_fn = _make_check_fn(name) - for entry in _build_utility_schemas(name): + for entry in _select_utility_schemas(name, server, config): schema = entry["schema"] handler_key = entry["handler_key"] handler = _handler_factories[handler_key](name, server.tool_timeout) @@ -1402,6 +1469,8 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]: ) registered_names.append(schema["name"]) + server._registered_tool_names = list(registered_names) + # Create a custom toolset so these tools are discoverable if registered_names: create_custom_toolset( @@ -1448,8 +1517,9 @@ def discover_mcp_tools() -> List[str]: # (enabled: false skips the server entirely without removing its config) with _lock: new_servers = { - k: v for k, v in servers.items() - if k not in _servers and v.get("enabled", True) is not False + k: v + for k, v in servers.items() + if k not in _servers and _parse_boolish(v.get("enabled", True), default=True) } if not new_servers: @@ -1537,7 +1607,7 @@ def get_mcp_status() -> List[dict]: entry = { "name": name, "transport": transport, - "tools": len(server._tools), + "tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools), "connected": True, } if server._sampling: From 67e80def53b57cddc2efc37949830659cbfc71e3 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 06:36:01 -0700 Subject: [PATCH 3/3] docs(mcp): add comprehensive Hermes MCP docs Expand the MCP feature docs with filtering and capability-aware registration details, add a practical 'Use MCP with Hermes' tutorial, add a config reference page, and wire the new docs into the sidebar and landing page. --- website/docs/guides/use-mcp-with-hermes.md | 410 +++++++++++++ website/docs/index.md | 3 +- website/docs/reference/faq.md | 15 +- .../docs/reference/mcp-config-reference.md | 215 +++++++ website/docs/user-guide/features/mcp.md | 556 ++++++++++-------- website/sidebars.ts | 2 + 6 files changed, 955 insertions(+), 246 deletions(-) create mode 100644 website/docs/guides/use-mcp-with-hermes.md create mode 100644 website/docs/reference/mcp-config-reference.md diff --git a/website/docs/guides/use-mcp-with-hermes.md b/website/docs/guides/use-mcp-with-hermes.md new file mode 100644 index 00000000..e202594d --- /dev/null +++ b/website/docs/guides/use-mcp-with-hermes.md @@ -0,0 +1,410 @@ +--- +sidebar_position: 5 +title: "Use MCP with Hermes" +description: "A practical guide to connecting MCP servers to Hermes Agent, filtering their tools, and using them safely in real workflows" +--- + +# Use MCP with Hermes + +This guide shows how to actually use MCP with Hermes Agent in day-to-day workflows. + +If the feature page explains what MCP is, this guide is about how to get value from it quickly and safely. + +## When should you use MCP? + +Use MCP when: +- a tool already exists in MCP form and you do not want to build a native Hermes tool +- you want Hermes to operate against a local or remote system through a clean RPC layer +- you want fine-grained per-server exposure control +- you want to connect Hermes to internal APIs, databases, or company systems without modifying Hermes core + +Do not use MCP when: +- a built-in Hermes tool already solves the job well +- the server exposes a huge dangerous tool surface and you are not prepared to filter it +- you only need one very narrow integration and a native tool would be simpler and safer + +## Mental model + +Think of MCP as an adapter layer: + +- Hermes remains the agent +- MCP servers contribute tools +- Hermes discovers those tools at startup or reload time +- the model can use them like normal tools +- you control how much of each server is visible + +That last part matters. Good MCP usage is not just “connect everything.” It is “connect the right thing, with the smallest useful surface.” + +## Step 1: install MCP support + +```bash +pip install hermes-agent[mcp] +``` + +For npm-based servers, make sure Node.js and `npx` are available. + +For many Python MCP servers, `uvx` is a nice default. + +## Step 2: add one server first + +Start with a single, safe server. + +Example: filesystem access to one project directory only. + +```yaml +mcp_servers: + project_fs: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/my-project"] +``` + +Then start Hermes: + +```bash +hermes chat +``` + +Now ask something concrete: + +```text +Inspect this project and summarize the repo layout. +``` + +## Step 3: verify MCP loaded + +You can verify MCP in a few ways: + +- Hermes banner/status should show MCP integration when configured +- ask Hermes what tools it has available +- use `/reload-mcp` after config changes +- check logs if the server failed to connect + +A practical test prompt: + +```text +Tell me which MCP-backed tools are available right now. +``` + +## Step 4: start filtering immediately + +Do not wait until later if the server exposes a lot of tools. + +### Example: whitelist only what you want + +```yaml +mcp_servers: + github: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [list_issues, create_issue, search_code] +``` + +This is usually the best default for sensitive systems. + +### Example: blacklist dangerous actions + +```yaml +mcp_servers: + stripe: + url: "https://mcp.stripe.com" + headers: + Authorization: "Bearer ***" + tools: + exclude: [delete_customer, refund_payment] +``` + +### Example: disable utility wrappers too + +```yaml +mcp_servers: + docs: + url: "https://mcp.docs.example.com" + tools: + prompts: false + resources: false +``` + +## What does filtering actually affect? + +There are two categories of MCP-exposed functionality in Hermes: + +1. Server-native MCP tools +- filtered with: + - `tools.include` + - `tools.exclude` + +2. Hermes-added utility wrappers +- filtered with: + - `tools.resources` + - `tools.prompts` + +### Utility wrappers you may see + +Resources: +- `list_resources` +- `read_resource` + +Prompts: +- `list_prompts` +- `get_prompt` + +These wrappers only appear if: +- your config allows them, and +- the MCP server session actually supports those capabilities + +So Hermes will not pretend a server has resources/prompts if it does not. + +## Common patterns + +### Pattern 1: local project assistant + +Use MCP for a repo-local filesystem or git server when you want Hermes to reason over a bounded workspace. + +```yaml +mcp_servers: + fs: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/project"] + + git: + command: "uvx" + args: ["mcp-server-git", "--repository", "/home/user/project"] +``` + +Good prompts: + +```text +Review the project structure and identify where configuration lives. +``` + +```text +Check the local git state and summarize what changed recently. +``` + +### Pattern 2: GitHub triage assistant + +```yaml +mcp_servers: + github: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [list_issues, create_issue, update_issue, search_code] + prompts: false + resources: false +``` + +Good prompts: + +```text +List open issues about MCP, cluster them by theme, and draft a high-quality issue for the most common bug. +``` + +```text +Search the repo for uses of _discover_and_register_server and explain how MCP tools are registered. +``` + +### Pattern 3: internal API assistant + +```yaml +mcp_servers: + internal_api: + url: "https://mcp.internal.example.com" + headers: + Authorization: "Bearer ***" + tools: + include: [list_customers, get_customer, list_invoices] + resources: false + prompts: false +``` + +Good prompts: + +```text +Look up customer ACME Corp and summarize recent invoice activity. +``` + +This is the sort of place where a strict whitelist is far better than an exclude list. + +### Pattern 4: documentation / knowledge servers + +Some MCP servers expose prompts or resources that are more like shared knowledge assets than direct actions. + +```yaml +mcp_servers: + docs: + url: "https://mcp.docs.example.com" + tools: + prompts: true + resources: true +``` + +Good prompts: + +```text +List available MCP resources from the docs server, then read the onboarding guide and summarize it. +``` + +```text +List prompts exposed by the docs server and tell me which ones would help with incident response. +``` + +## Tutorial: end-to-end setup with filtering + +Here is a practical progression. + +### Phase 1: add GitHub MCP with a tight whitelist + +```yaml +mcp_servers: + github: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [list_issues, create_issue, search_code] + prompts: false + resources: false +``` + +Start Hermes and ask: + +```text +Search the codebase for references to MCP and summarize the main integration points. +``` + +### Phase 2: expand only when needed + +If you later need issue updates too: + +```yaml +tools: + include: [list_issues, create_issue, update_issue, search_code] +``` + +Then reload: + +```text +/reload-mcp +``` + +### Phase 3: add a second server with different policy + +```yaml +mcp_servers: + github: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [list_issues, create_issue, update_issue, search_code] + prompts: false + resources: false + + filesystem: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/project"] +``` + +Now Hermes can combine them: + +```text +Inspect the local project files, then create a GitHub issue summarizing the bug you find. +``` + +That is where MCP gets powerful: multi-system workflows without changing Hermes core. + +## Safe usage recommendations + +### Prefer allowlists for dangerous systems + +For anything financial, customer-facing, or destructive: +- use `tools.include` +- start with the smallest set possible + +### Disable unused utilities + +If you do not want the model browsing server-provided resources/prompts, turn them off: + +```yaml +tools: + resources: false + prompts: false +``` + +### Keep servers scoped narrowly + +Examples: +- filesystem server rooted to one project dir, not your whole home directory +- git server pointed at one repo +- internal API server with read-heavy tool exposure by default + +### Reload after config changes + +```text +/reload-mcp +``` + +Do this after changing: +- include/exclude lists +- enabled flags +- resources/prompts toggles +- auth headers / env + +## Troubleshooting by symptom + +### "The server connects but the tools I expected are missing" + +Possible causes: +- filtered by `tools.include` +- excluded by `tools.exclude` +- utility wrappers disabled via `resources: false` or `prompts: false` +- server does not actually support resources/prompts + +### "The server is configured but nothing loads" + +Check: +- `enabled: false` was not left in config +- command/runtime exists (`npx`, `uvx`, etc.) +- HTTP endpoint is reachable +- auth env or headers are correct + +### "Why do I see fewer tools than the MCP server advertises?" + +Because Hermes now respects your per-server policy and capability-aware registration. That is expected, and usually desirable. + +### "How do I remove an MCP server without deleting the config?" + +Use: + +```yaml +enabled: false +``` + +That keeps the config around but prevents connection and registration. + +## Recommended first MCP setups + +Good first servers for most users: +- filesystem +- git +- GitHub +- fetch / documentation MCP servers +- one narrow internal API + +Not-great first servers: +- giant business systems with lots of destructive actions and no filtering +- anything you do not understand well enough to constrain + +## Related docs + +- [MCP (Model Context Protocol)](/docs/user-guide/features/mcp) +- [FAQ](/docs/reference/faq) +- [Slash Commands](/docs/reference/slash-commands) diff --git a/website/docs/index.md b/website/docs/index.md index a4ea0a8e..0e33c9dc 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -31,7 +31,8 @@ It's not a coding copilot tethered to an IDE or a chatbot wrapper around a singl | 🔧 **[Tools & Toolsets](/docs/user-guide/features/tools)** | 40+ built-in tools and how to configure them | | 🧠 **[Memory System](/docs/user-guide/features/memory)** | Persistent memory that grows across sessions | | 📚 **[Skills System](/docs/user-guide/features/skills)** | Procedural memory the agent creates and reuses | -| 🔌 **[MCP Integration](/docs/user-guide/features/mcp)** | Connect to any MCP server for extended capabilities | +| 🔌 **[MCP Integration](/docs/user-guide/features/mcp)** | Connect to MCP servers, filter their tools, and extend Hermes safely | +| 🧭 **[Use MCP with Hermes](/docs/guides/use-mcp-with-hermes)** | Practical MCP setup patterns, examples, and tutorials | | 📄 **[Context Files](/docs/user-guide/features/context-files)** | Project context files that shape every conversation | | 🔒 **[Security](/docs/user-guide/security)** | Command approval, authorization, container isolation | | 💡 **[Tips & Best Practices](/docs/guides/tips)** | Quick wins to get the most out of Hermes | diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 88e5210a..02a82dce 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -391,21 +391,28 @@ mcp_servers: #### Tools not showing up from MCP server -**Cause:** Server started but tool discovery failed, or tools are filtered out. +**Cause:** Server started but tool discovery failed, tools were filtered out by config, or the server does not support the MCP capability you expected. **Solution:** - Check gateway/agent logs for MCP connection errors - Ensure the server responds to the `tools/list` RPC method -- Restart the agent — MCP tools are discovered at startup +- Review any `tools.include`, `tools.exclude`, `tools.resources`, `tools.prompts`, or `enabled` settings under that server +- Remember that resource/prompt utility tools are only registered when the session actually supports those capabilities +- Use `/reload-mcp` after changing config ```bash # Verify MCP servers are configured -hermes config show | grep -A 5 mcp_servers +hermes config show | grep -A 12 mcp_servers -# Restart hermes to re-discover tools +# Restart Hermes or reload MCP after config changes hermes chat ``` +See also: +- [MCP (Model Context Protocol)](/docs/user-guide/features/mcp) +- [Use MCP with Hermes](/docs/guides/use-mcp-with-hermes) +- [MCP Config Reference](/docs/reference/mcp-config-reference) + #### MCP timeout errors **Cause:** The MCP server is taking too long to respond, or it crashed during execution. diff --git a/website/docs/reference/mcp-config-reference.md b/website/docs/reference/mcp-config-reference.md new file mode 100644 index 00000000..5f78185b --- /dev/null +++ b/website/docs/reference/mcp-config-reference.md @@ -0,0 +1,215 @@ +--- +sidebar_position: 8 +title: "MCP Config Reference" +description: "Reference for Hermes Agent MCP configuration keys, filtering semantics, and utility-tool policy" +--- + +# MCP Config Reference + +This page is the compact reference companion to the main MCP docs. + +For conceptual guidance, see: +- [MCP (Model Context Protocol)](/docs/user-guide/features/mcp) +- [Use MCP with Hermes](/docs/guides/use-mcp-with-hermes) + +## Root config shape + +```yaml +mcp_servers: + : + command: "..." # stdio servers + args: [] + env: {} + + # OR + url: "..." # HTTP servers + headers: {} + + enabled: true + timeout: 120 + connect_timeout: 60 + tools: + include: [] + exclude: [] + resources: true + prompts: true +``` + +## Server keys + +| Key | Type | Applies to | Meaning | +|---|---|---|---| +| `command` | string | stdio | Executable to launch | +| `args` | list | stdio | Arguments for the subprocess | +| `env` | mapping | stdio | Environment passed to the subprocess | +| `url` | string | HTTP | Remote MCP endpoint | +| `headers` | mapping | HTTP | Headers for remote server requests | +| `enabled` | bool | both | Skip the server entirely when false | +| `timeout` | number | both | Tool call timeout | +| `connect_timeout` | number | both | Initial connection timeout | +| `tools` | mapping | both | Filtering and utility-tool policy | + +## `tools` policy keys + +| Key | Type | Meaning | +|---|---|---| +| `include` | string or list | Whitelist server-native MCP tools | +| `exclude` | string or list | Blacklist server-native MCP tools | +| `resources` | bool-like | Enable/disable `list_resources` + `read_resource` | +| `prompts` | bool-like | Enable/disable `list_prompts` + `get_prompt` | + +## Filtering semantics + +### `include` + +If `include` is set, only those server-native MCP tools are registered. + +```yaml +tools: + include: [create_issue, list_issues] +``` + +### `exclude` + +If `exclude` is set and `include` is not, every server-native MCP tool except those names is registered. + +```yaml +tools: + exclude: [delete_customer] +``` + +### Precedence + +If both are set, `include` wins. + +```yaml +tools: + include: [create_issue] + exclude: [create_issue, delete_issue] +``` + +Result: +- `create_issue` is still allowed +- `delete_issue` is ignored because `include` takes precedence + +## Utility-tool policy + +Hermes may register these utility wrappers per MCP server: + +Resources: +- `list_resources` +- `read_resource` + +Prompts: +- `list_prompts` +- `get_prompt` + +### Disable resources + +```yaml +tools: + resources: false +``` + +### Disable prompts + +```yaml +tools: + prompts: false +``` + +### Capability-aware registration + +Even when `resources: true` or `prompts: true`, Hermes only registers those utility tools if the MCP session actually exposes the corresponding capability. + +So this is normal: +- you enable prompts +- but no prompt utilities appear +- because the server does not support prompts + +## `enabled: false` + +```yaml +mcp_servers: + legacy: + url: "https://mcp.legacy.internal" + enabled: false +``` + +Behavior: +- no connection attempt +- no discovery +- no tool registration +- config remains in place for later reuse + +## Empty result behavior + +If filtering removes all server-native tools and no utility tools are registered, Hermes does not create an empty MCP runtime toolset for that server. + +## Example configs + +### Safe GitHub allowlist + +```yaml +mcp_servers: + github: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [list_issues, create_issue, update_issue, search_code] + resources: false + prompts: false +``` + +### Stripe blacklist + +```yaml +mcp_servers: + stripe: + url: "https://mcp.stripe.com" + headers: + Authorization: "Bearer ***" + tools: + exclude: [delete_customer, refund_payment] +``` + +### Resource-only docs server + +```yaml +mcp_servers: + docs: + url: "https://mcp.docs.example.com" + tools: + include: [] + resources: true + prompts: false +``` + +## Reloading config + +After changing MCP config, reload servers with: + +```text +/reload-mcp +``` + +## Tool naming + +Server-native MCP tools become: + +```text +mcp__ +``` + +Examples: +- `mcp_github_create_issue` +- `mcp_filesystem_read_file` +- `mcp_my_api_query_data` + +Utility tools follow the same prefixing pattern: +- `mcp__list_resources` +- `mcp__read_resource` +- `mcp__list_prompts` +- `mcp__get_prompt` diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index d1caeb06..5009fab7 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -1,334 +1,408 @@ --- sidebar_position: 4 title: "MCP (Model Context Protocol)" -description: "Connect Hermes Agent to external tool servers via MCP — databases, APIs, filesystems, and more" +description: "Connect Hermes Agent to external tool servers via MCP — and control exactly which MCP tools Hermes loads" --- # MCP (Model Context Protocol) -MCP lets Hermes Agent connect to external tool servers — giving the agent access to databases, APIs, filesystems, and more without any code changes. +MCP lets Hermes Agent connect to external tool servers so the agent can use tools that live outside Hermes itself — GitHub, databases, file systems, browser stacks, internal APIs, and more. -## Overview +If you have ever wanted Hermes to use a tool that already exists somewhere else, MCP is usually the cleanest way to do it. -The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is an open standard for connecting AI agents to external tools and data sources. MCP servers expose tools over a lightweight RPC protocol, and Hermes Agent can connect to any compliant server automatically. +## What MCP gives you -What this means for you: +- Access to external tool ecosystems without writing a native Hermes tool first +- Local stdio servers and remote HTTP MCP servers in the same config +- Automatic tool discovery and registration at startup +- Utility wrappers for MCP resources and prompts when supported by the server +- Per-server filtering so you can expose only the MCP tools you actually want Hermes to see -- **Thousands of ready-made tools** — browse the [MCP server directory](https://github.com/modelcontextprotocol/servers) for servers covering GitHub, Slack, databases, file systems, web scraping, and more -- **No code changes needed** — add a few lines to `~/.hermes/config.yaml` and the tools appear alongside built-in ones -- **Mix and match** — run multiple MCP servers simultaneously, combining stdio-based and HTTP-based servers -- **Secure by default** — environment variables are filtered and credentials are stripped from error messages +## Quick start -## Prerequisites +1. Install MCP support: ```bash pip install hermes-agent[mcp] ``` -| Server Type | Runtime Needed | Example | -|-------------|---------------|---------| -| HTTP/remote | Nothing extra | `url: "https://mcp.example.com"` | -| npm-based (npx) | Node.js 18+ | `command: "npx"` | -| Python-based | uv (recommended) | `command: "uvx"` | - -## Configuration - -MCP servers are configured in `~/.hermes/config.yaml` under the `mcp_servers` key. - -### Stdio Servers - -Stdio servers run as local subprocesses, communicating over stdin/stdout: +2. Add an MCP server to `~/.hermes/config.yaml`: ```yaml mcp_servers: filesystem: command: "npx" args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"] - env: {} +``` +3. Start Hermes: + +```bash +hermes chat +``` + +4. Ask Hermes to use the MCP-backed capability. + +For example: + +```text +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. + +## Two kinds of MCP servers + +### Stdio servers + +Stdio servers run as local subprocesses and talk over stdin/stdout. + +```yaml +mcp_servers: github: command: "npx" args: ["-y", "@modelcontextprotocol/server-github"] env: - GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx" + GITHUB_PERSONAL_ACCESS_TOKEN: "***" ``` -| Key | Required | Description | -|-----|----------|-------------| -| `command` | Yes | Executable to run (`npx`, `uvx`, `python`) | -| `args` | No | Command-line arguments | -| `env` | No | Environment variables for the subprocess | +Use stdio servers when: +- the server is installed locally +- you want low-latency access to local resources +- you are following MCP server docs that show `command`, `args`, and `env` -:::info Security -Only explicitly listed `env` variables plus a safe baseline (`PATH`, `HOME`, `USER`, `LANG`, `SHELL`, `TMPDIR`, `XDG_*`) are passed to the subprocess. Your API keys and secrets are **not** leaked. -::: +### HTTP servers -### HTTP Servers +HTTP MCP servers are remote endpoints Hermes connects to directly. ```yaml mcp_servers: remote_api: - url: "https://my-mcp-server.example.com/mcp" + url: "https://mcp.example.com/mcp" headers: - Authorization: "Bearer sk-xxxxxxxxxxxx" + Authorization: "Bearer ***" ``` -### Per-Server Timeouts +Use HTTP servers when: +- the MCP server is hosted elsewhere +- your organization exposes internal MCP endpoints +- you do not want Hermes spawning a local subprocess for that integration + +## Basic configuration reference + +Hermes reads MCP config from `~/.hermes/config.yaml` under `mcp_servers`. + +### Common keys + +| Key | Type | Meaning | +|---|---|---| +| `command` | string | Executable for a stdio MCP server | +| `args` | list | Arguments for the stdio server | +| `env` | mapping | Environment variables passed to the stdio server | +| `url` | string | HTTP MCP endpoint | +| `headers` | mapping | HTTP headers for remote servers | +| `timeout` | number | Tool call timeout | +| `connect_timeout` | number | Initial connection timeout | +| `enabled` | bool | If `false`, Hermes skips the server entirely | +| `tools` | mapping | Per-server tool filtering and utility policy | + +### Minimal stdio example ```yaml mcp_servers: - slow_database: - command: "npx" - args: ["-y", "@modelcontextprotocol/server-postgres"] - env: - DATABASE_URL: "postgres://user:pass@localhost/mydb" - timeout: 300 # Tool call timeout (default: 120s) - connect_timeout: 90 # Initial connection timeout (default: 60s) -``` - -### Mixed Configuration Example - -```yaml -mcp_servers: - # Local filesystem via stdio - filesystem: - command: "npx" - args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - - # GitHub API via stdio with auth - github: - command: "npx" - args: ["-y", "@modelcontextprotocol/server-github"] - env: - GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx" - - # Remote database via HTTP - company_db: - url: "https://mcp.internal.company.com/db" - headers: - Authorization: "Bearer sk-xxxxxxxxxxxx" - timeout: 180 - - # Python-based server via uvx - memory: - command: "uvx" - args: ["mcp-server-memory"] -``` - -## Translating from Claude Desktop Config - -Many MCP server docs show Claude Desktop JSON format. Here's the translation: - -**Claude Desktop JSON:** -```json -{ - "mcpServers": { - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - } - } -} -``` - -**Hermes YAML:** -```yaml -mcp_servers: # mcpServers → mcp_servers (snake_case) filesystem: command: "npx" args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] ``` -Rules: `mcpServers` → `mcp_servers` (snake_case), JSON → YAML. Keys like `command`, `args`, `env` are identical. +### Minimal HTTP example -## How It Works - -### Tool Registration - -Each MCP tool is registered with a prefixed name: - -``` -mcp_{server_name}_{tool_name} +```yaml +mcp_servers: + company_api: + url: "https://mcp.internal.example.com" + headers: + Authorization: "Bearer ***" ``` -| Server Name | MCP Tool Name | Registered As | -|-------------|--------------|---------------| +## How Hermes registers MCP tools + +Hermes prefixes MCP tools so they do not collide with built-in names: + +```text +mcp__ +``` + +Examples: + +| Server | MCP tool | Registered name | +|---|---|---| | `filesystem` | `read_file` | `mcp_filesystem_read_file` | | `github` | `create-issue` | `mcp_github_create_issue` | | `my-api` | `query.data` | `mcp_my_api_query_data` | -Tools appear alongside built-in tools — the agent calls them like any other tool. +In practice, you usually do not need to call the prefixed name manually — Hermes sees the tool and chooses it during normal reasoning. -:::info -In addition to the server's own tools, each MCP server also gets 4 utility tools auto-registered: `list_resources`, `read_resource`, `list_prompts`, and `get_prompt`. These allow the agent to discover and use MCP resources and prompts exposed by the server. +## MCP utility tools -Each configured server also creates a **runtime toolset** named `mcp-`. This means you can filter or reason about MCP servers at the toolset level in the same way you do with built-in toolsets. -::: +When supported, Hermes also registers utility tools around MCP resources and prompts: -### Reconnection +- `list_resources` +- `read_resource` +- `list_prompts` +- `get_prompt` -If an MCP server disconnects, Hermes automatically reconnects with exponential backoff (1s, 2s, 4s, 8s, 16s — max 5 attempts). Initial connection failures are reported immediately. +These are registered per server with the same prefix pattern, for example: -### Shutdown +- `mcp_github_list_resources` +- `mcp_github_get_prompt` -On agent exit, all MCP server connections are cleanly shut down. +### Important -## Popular MCP Servers +These utility tools are now capability-aware: +- Hermes only registers resource utilities if the MCP session actually supports resource operations +- Hermes only registers prompt utilities if the MCP session actually supports prompt operations -| Server | Package | Description | -|--------|---------|-------------| -| Filesystem | `@modelcontextprotocol/server-filesystem` | Read/write/search local files | -| GitHub | `@modelcontextprotocol/server-github` | Issues, PRs, repos, code search | -| Git | `@modelcontextprotocol/server-git` | Git operations on local repos | -| Fetch | `@modelcontextprotocol/server-fetch` | HTTP fetching and web content | -| Memory | `@modelcontextprotocol/server-memory` | Persistent key-value memory | -| SQLite | `@modelcontextprotocol/server-sqlite` | Query SQLite databases | -| PostgreSQL | `@modelcontextprotocol/server-postgres` | Query PostgreSQL databases | -| Brave Search | `@modelcontextprotocol/server-brave-search` | Web search via Brave API | -| Puppeteer | `@modelcontextprotocol/server-puppeteer` | Browser automation | +So a server that exposes callable tools but no resources/prompts will not get those extra wrappers. -### Example Configs +## Per-server filtering + +This is the main feature added by the PR work. + +You can now control which tools each MCP server contributes to Hermes. + +### Disable a server entirely ```yaml mcp_servers: - # No API key needed - filesystem: - command: "npx" - args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"] + legacy: + url: "https://mcp.legacy.internal" + enabled: false +``` - git: - command: "uvx" - args: ["mcp-server-git", "--repository", "/home/user/my-repo"] +If `enabled: false`, Hermes skips the server completely and does not even attempt a connection. - fetch: - command: "uvx" - args: ["mcp-server-fetch"] +### Whitelist server tools - sqlite: - command: "uvx" - args: ["mcp-server-sqlite", "--db-path", "/home/user/data.db"] - - # Requires API key +```yaml +mcp_servers: github: command: "npx" args: ["-y", "@modelcontextprotocol/server-github"] env: - GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx" + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [create_issue, list_issues] +``` - brave_search: +Only those MCP server tools are registered. + +### Blacklist server tools + +```yaml +mcp_servers: + stripe: + url: "https://mcp.stripe.com" + tools: + exclude: [delete_customer] +``` + +All server tools are registered except the excluded ones. + +### Precedence rule + +If both are present: + +```yaml +tools: + include: [create_issue] + exclude: [create_issue, delete_issue] +``` + +`include` wins. + +### Filter utility tools too + +You can also separately disable Hermes-added utility wrappers: + +```yaml +mcp_servers: + docs: + url: "https://mcp.docs.example.com" + tools: + prompts: false + resources: false +``` + +That means: +- `tools.resources: false` disables `list_resources` and `read_resource` +- `tools.prompts: false` disables `list_prompts` and `get_prompt` + +### Full example + +```yaml +mcp_servers: + github: command: "npx" - args: ["-y", "@modelcontextprotocol/server-brave-search"] + args: ["-y", "@modelcontextprotocol/server-github"] env: - BRAVE_API_KEY: "BSA_xxxxxxxxxxxx" + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [create_issue, list_issues, search_code] + prompts: false + + stripe: + url: "https://mcp.stripe.com" + headers: + Authorization: "Bearer ***" + tools: + exclude: [delete_customer] + resources: false + + legacy: + url: "https://mcp.legacy.internal" + enabled: false +``` + +## What happens if everything is filtered out? + +If your config filters out all callable tools and disables or omits all supported utilities, Hermes does not create an empty runtime MCP toolset for that server. + +That keeps the tool list clean. + +## Runtime behavior + +### Discovery time + +Hermes discovers MCP servers at startup and registers their tools into the normal tool registry. + +### Reloading + +If you change MCP config, use: + +```text +/reload-mcp +``` + +This reloads MCP servers from config and refreshes the available tool list. + +### Toolsets + +Each configured MCP server also creates a runtime toolset when it contributes at least one registered tool: + +```text +mcp- +``` + +That makes MCP servers easier to reason about at the toolset level. + +## Security model + +### Stdio env filtering + +For stdio servers, Hermes does not blindly pass your full shell environment. + +Only explicitly configured `env` plus a safe baseline are passed through. This reduces accidental secret leakage. + +### Config-level exposure control + +The new filtering support is also a security control: +- disable dangerous tools you do not want the model to see +- expose only a minimal whitelist for a sensitive server +- disable resource/prompt wrappers when you do not want that surface exposed + +## Example use cases + +### GitHub server with a minimal issue-management surface + +```yaml +mcp_servers: + github: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "***" + tools: + include: [list_issues, create_issue, update_issue] + prompts: false + resources: false +``` + +Use it like: + +```text +Show me open issues labeled bug, then draft a new issue for the flaky MCP reconnection behavior. +``` + +### Stripe server with dangerous actions removed + +```yaml +mcp_servers: + stripe: + url: "https://mcp.stripe.com" + headers: + Authorization: "Bearer ***" + tools: + exclude: [delete_customer, refund_payment] +``` + +Use it like: + +```text +Look up the last 10 failed payments and summarize common failure reasons. +``` + +### Filesystem server for a single project root + +```yaml +mcp_servers: + project_fs: + command: "npx" + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/my-project"] +``` + +Use it like: + +```text +Inspect the project root and explain the directory layout. ``` ## Troubleshooting -### "MCP SDK not available" +### MCP server not connecting + +Check: ```bash pip install hermes-agent[mcp] +node --version +npx --version ``` -### Server fails to start +Then verify your config and restart Hermes. -The MCP server command (`npx`, `uvx`) is not on PATH. Install the required runtime: +### Tools not appearing -```bash -# For npm-based servers -npm install -g npx # or ensure Node.js 18+ is installed +Possible causes: +- the server failed to connect +- discovery failed +- your filter config excluded the tools +- the utility capability does not exist on that server +- the server is disabled with `enabled: false` -# For Python-based servers -pip install uv # then use "uvx" as the command -``` +If you are intentionally filtering, this is expected. -### Server connects but tools fail with auth errors +### Why didn't resource or prompt utilities appear? -Ensure the key is in the server's `env` block: +Because Hermes now only registers those wrappers when both are true: +1. your config allows them +2. the server session actually supports the capability -```yaml -mcp_servers: - github: - command: "npx" - args: ["-y", "@modelcontextprotocol/server-github"] - env: - GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_your_actual_token" # Check this -``` +This is intentional and keeps the tool list honest. -### Connection timeout +## Related docs -Increase `connect_timeout` for slow-starting servers: - -```yaml -mcp_servers: - slow_server: - command: "npx" - args: ["-y", "heavy-server-package"] - connect_timeout: 120 # default is 60 -``` - -### Reload MCP Servers - -You can reload MCP servers without restarting Hermes: - -- In the CLI: the agent reconnects automatically -- In messaging: send `/reload-mcp` - -## Sampling (Server-Initiated LLM Requests) - -MCP's `sampling/createMessage` capability allows MCP servers to request LLM completions through the Hermes agent. This enables agent-in-the-loop workflows where servers can leverage the LLM during tool execution — for example, a database server asking the LLM to interpret query results, or a code analysis server requesting the LLM to review findings. - -### How It Works - -When an MCP server sends a `sampling/createMessage` request: - -1. The sampling callback validates against rate limits and model whitelist -2. Resolves which model to use (config override > server hint > default) -3. Converts MCP messages to OpenAI-compatible format -4. Offloads the LLM call to a thread via `asyncio.to_thread()` (non-blocking) -5. Returns the response (text or tool use) back to the server - -### Configuration - -Sampling is **enabled by default** for all MCP servers. No extra setup needed — if you have an auxiliary LLM client configured, sampling works automatically. - -```yaml -mcp_servers: - analysis_server: - command: "npx" - args: ["-y", "my-analysis-server"] - sampling: - enabled: true # default: true - model: "gemini-3-flash" # override model (optional) - max_tokens_cap: 4096 # max tokens per request (default: 4096) - timeout: 30 # LLM call timeout in seconds (default: 30) - max_rpm: 10 # max requests per minute (default: 10) - allowed_models: [] # model whitelist (empty = allow all) - max_tool_rounds: 5 # max consecutive tool use rounds (0 = disable) - log_level: "info" # audit verbosity: debug, info, warning -``` - -### Tool Use in Sampling - -Servers can include `tools` and `toolChoice` in sampling requests, enabling multi-turn tool-augmented workflows within a single sampling session. The callback forwards tool definitions to the LLM, handles tool use responses with proper `ToolUseContent` types, and enforces `max_tool_rounds` to prevent infinite loops. - -### Security - -- **Rate limiting**: Per-server sliding window (default: 10 req/min) -- **Token cap**: Servers can't request more than `max_tokens_cap` (default: 4096) -- **Model whitelist**: `allowed_models` restricts which models a server can use -- **Tool loop limit**: `max_tool_rounds` caps consecutive tool use rounds -- **Credential stripping**: LLM responses are sanitized before returning to the server -- **Non-blocking**: LLM calls run in a separate thread via `asyncio.to_thread()` -- **Typed errors**: All failures return structured `ErrorData` per MCP spec - -To disable sampling for untrusted servers: - -```yaml -mcp_servers: - untrusted: - command: "npx" - args: ["-y", "untrusted-server"] - sampling: - enabled: false -``` +- [Use MCP with Hermes](/docs/guides/use-mcp-with-hermes) +- [CLI Commands](/docs/reference/cli-commands) +- [Slash Commands](/docs/reference/slash-commands) +- [FAQ](/docs/reference/faq) diff --git a/website/sidebars.ts b/website/sidebars.ts index 0861cdf0..21b20b31 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -22,6 +22,7 @@ const sidebars: SidebarsConfig = { 'guides/daily-briefing-bot', 'guides/team-telegram-assistant', 'guides/python-library', + 'guides/use-mcp-with-hermes', ], }, { @@ -128,6 +129,7 @@ const sidebars: SidebarsConfig = { 'reference/slash-commands', 'reference/tools-reference', 'reference/toolsets-reference', + 'reference/mcp-config-reference', 'reference/skills-catalog', 'reference/optional-skills-catalog', 'reference/environment-variables',