diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 53ab794bfff1..61bcce598b6c 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -986,15 +986,25 @@ def cmd_mcp_configure(args): tool_names = [t[0] for t in all_tools] + # Same matching semantics as runtime registration (tools/mcp_tool.py): + # exact names or fnmatch globs. + try: + from tools.mcp_tool import matches_name_filter + except ImportError: # pragma: no cover — defensive fallback + def matches_name_filter(tool_name, patterns): + return tool_name in patterns + if include and isinstance(include, list): - include_set = set(include) + include_set = {str(p) for p in include} pre_selected = { - i for i, tn in enumerate(tool_names) if tn in include_set + i for i, tn in enumerate(tool_names) + if matches_name_filter(tn, include_set) } elif exclude and isinstance(exclude, list): - exclude_set = set(exclude) + exclude_set = {str(p) for p in exclude} pre_selected = { - i for i, tn in enumerate(tool_names) if tn not in exclude_set + i for i, tn in enumerate(tool_names) + if not matches_name_filter(tn, exclude_set) } else: pre_selected = set(range(len(all_tools))) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 769a006940ee..7e5b40c1cc37 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -3874,6 +3874,52 @@ class TestMCPSelectiveToolLoading: "mcp__ink_exclude__list_services", ] + def test_exclude_filter_supports_globs(self): + """fnmatch globs in exclude — the Cloudflare flat-mode shape + (``*_radar_*`` etc.). Previously silently matched nothing.""" + config = { + "url": "https://mcp.example.com", + "tools": {"exclude": ["*_radar_*", "delete_*"]}, + } + registered, _ = self._run_discover( + "ink_glob", + ["get_radar_summary", "get_accounts_radar_http", "delete_service", + "create_service", "list_services"], + config, + session=SimpleNamespace(), + ) + assert registered == [ + "mcp__ink_glob__create_service", + "mcp__ink_glob__list_services", + ] + + def test_include_filter_supports_globs(self): + """Globs work symmetrically on the include whitelist.""" + config = { + "url": "https://mcp.example.com", + "tools": {"include": ["get_zones_*"]}, + } + registered, _ = self._run_discover( + "ink_glob_inc", + ["get_zones_dns_records", "get_zones_settings", "delete_zone", + "get_accounts"], + config, + session=SimpleNamespace(), + ) + assert registered == [ + "mcp__ink_glob_inc__get_zones_dns_records", + "mcp__ink_glob_inc__get_zones_settings", + ] + + def test_exact_names_still_match_exactly(self): + """No-metachar entries stay literal — 'docs' must not glob-match + 'docs_search', and exact matching is unchanged.""" + from tools.mcp_tool import matches_name_filter + assert matches_name_filter("docs", {"docs"}) + assert not matches_name_filter("docs_search", {"docs"}) + assert matches_name_filter("docs_search", {"docs*"}) + assert not matches_name_filter("anything", set()) + def test_include_filter_skips_utility_tools_without_capabilities(self): config = { "url": "https://mcp.example.com", diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 3a1964b0cf08..28dd5e917419 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -93,6 +93,7 @@ import asyncio import contextvars import concurrent.futures import errno +import fnmatch import inspect import json import logging @@ -5307,7 +5308,12 @@ 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.""" + """Normalize include/exclude config to a set of tool-name patterns. + + Entries may be exact tool names or fnmatch-style globs + (``*_radar_*``, ``get_zones_*``). Matching happens in + :func:`matches_name_filter`. + """ if value is None: return set() if isinstance(value, str): @@ -5318,6 +5324,25 @@ def _normalize_name_filter(value: Any, label: str) -> set[str]: return set() +def matches_name_filter(tool_name: str, patterns: set[str]) -> bool: + """True if ``tool_name`` matches any entry in ``patterns``. + + Exact names match literally; entries containing fnmatch metacharacters + (``*``, ``?``, ``[``) match as case-sensitive globs — the same pattern + semantics as ``approvals.deny``. Exact membership is checked first so + large literal lists stay O(1). + """ + if not patterns: + return False + if tool_name in patterns: + return True + return any( + fnmatch.fnmatchcase(tool_name, p) + for p in patterns + if "*" in p or "?" in p or "[" in p + ) + + def _parse_boolish(value: Any, default: bool = True) -> bool: """Parse a bool-like config value with safe fallback.""" if value is None: @@ -5481,9 +5506,10 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li 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 + # Rules (matching issue #690 spec, extended with glob support): + # tools.include — whitelist: only matching tool names are registered + # tools.exclude — blacklist: all tools EXCEPT matching ones are registered + # entries may be exact names or fnmatch globs (e.g. "*_radar_*") # include takes precedence over exclude # Neither set → register all tools (backward-compatible default) tools_filter = config.get("tools") or {} @@ -5492,9 +5518,9 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li def _should_register(tool_name: str) -> bool: if include_set: - return tool_name in include_set + return matches_name_filter(tool_name, include_set) if exclude_set: - return tool_name not in exclude_set + return not matches_name_filter(tool_name, exclude_set) return True for mcp_tool in server._tools: diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index f025aea1f450..ad72afee9e03 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -460,6 +460,24 @@ mcp_servers: All server tools are registered except the excluded ones. +### Glob patterns + +Both lists accept fnmatch-style globs alongside exact names — essential for +huge flat surfaces like Cloudflare's API MCP (`?codemode=false`, ~3,300 +tools) where excluding product areas one endpoint at a time is impractical: + +```yaml +mcp_servers: + cloudflare: + url: "https://mcp.cloudflare.com/mcp?codemode=false" + auth: oauth + tools: + exclude: ["*_radar_*", "*_accounts_dlp_*", "*_zones_web3_*"] +``` + +Entries without glob metacharacters (`*`, `?`, `[`) match exactly — `docs` +excludes only the tool named `docs`, never `docs_search`. + ### Precedence rule If both are present: