From 58c97b9dddaa8cc2ed0797dc5ac7e9d4e54cec43 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:51:47 -0700 Subject: [PATCH] feat(mcp): curated exclude list for cloudflare + glob tool filters + default_excluded manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cloudflare entry's 3,320-endpoint surface is ~43% product families a personal/dev account never touches (Zero Trust org-fleet suite, Magic Transit/WAN, Cloudforce One, Radar analytics, API Shield, legacy migration surfaces). Ship a 34-pattern curated exclude list in the manifest: 3,320 -> 1,905 tools kept, and everything Cloudflare adds later stays enabled by default. Mechanism, two small extensions: - tools/mcp_tool.py: tools.include/exclude entries containing glob metacharacters now match via fnmatch (plain names stay exact-match), so a product family is one pattern instead of hundreds of stale literals. - hermes_cli/mcp_catalog.py: manifests may declare tools.default_excluded (mutually exclusive with default_enabled); install writes it to tools.exclude and skips the probe/checklist — a 3,320-row curses checklist is not a UX. Prior user include selections still win on reinstall. Verified by replaying the real filter functions over the live-probed 3,320-tool list: 1,415 excluded, zero overmatch against a per-product target audit; DNS/Workers/R2/D1/tunnels/Access/AI kept. --- hermes_cli/mcp_catalog.py | 58 ++++++++++++++++- optional-mcps/cloudflare/manifest.yaml | 82 ++++++++++++++++++++++--- tests/hermes_cli/test_mcp_catalog.py | 79 ++++++++++++++++++++++++ tests/tools/test_mcp_tool.py | 53 ++++++++++++++++ tools/mcp_tool.py | 24 +++++++- website/docs/user-guide/features/mcp.md | 13 ++++ 6 files changed, 295 insertions(+), 14 deletions(-) diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index f2a5303abe9..d7ef71d9291 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -112,6 +112,14 @@ class ToolsSpec: # pre-checked (or no filter is written when probe fails). default_enabled: Optional[List[str]] = None + # Exclude-mode counterpart: tool names/glob patterns written to + # ``mcp_servers..tools.exclude`` at install time. Everything NOT + # matching stays enabled — including tools the server adds later. Use for + # huge auto-generated surfaces (OpenAPI-derived MCPs) where an include + # list would be thousands of lines and freeze out new endpoints. + # Mutually exclusive with ``default_enabled``. + default_excluded: Optional[List[str]] = None + @dataclass class CatalogEntry: @@ -241,7 +249,22 @@ def _parse_manifest(path: Path) -> CatalogEntry: raise CatalogError( f"{path}: tools.default_enabled must be a list of strings" ) - tools_spec = ToolsSpec(default_enabled=default_enabled) + default_excluded = tools_raw.get("default_excluded") + if default_excluded is not None: + if not isinstance(default_excluded, list) or not all( + isinstance(t, str) for t in default_excluded + ): + raise CatalogError( + f"{path}: tools.default_excluded must be a list of strings" + ) + if default_enabled is not None and default_excluded is not None: + raise CatalogError( + f"{path}: tools.default_enabled and tools.default_excluded are " + "mutually exclusive" + ) + tools_spec = ToolsSpec( + default_enabled=default_enabled, default_excluded=default_excluded + ) install: Optional[InstallSpec] = None install_raw = data.get("install") @@ -555,6 +578,22 @@ def _write_tools_include(name: str, include: Optional[List[str]]) -> None: save_config(cfg) +def _write_tools_exclude(name: str, exclude: List[str]) -> None: + """Persist ``mcp_servers..tools.exclude`` (names or glob patterns).""" + cfg = load_config() + servers = cfg.setdefault("mcp_servers", {}) + server_entry = servers.get(name) or {} + tools_block = server_entry.get("tools") or {} + if not isinstance(tools_block, dict): + tools_block = {} + tools_block["exclude"] = list(exclude) + tools_block.pop("include", None) + server_entry["tools"] = tools_block + servers[name] = server_entry + cfg["mcp_servers"] = servers + save_config(cfg) + + def _apply_tool_selection( entry: CatalogEntry, *, prior_selection: Optional[List[str]] ) -> None: @@ -576,6 +615,23 @@ def _apply_tool_selection( """ print() print(color(f" Probing '{entry.name}' for available tools...", Colors.CYAN)) + + # Exclude-mode manifests short-circuit the checklist entirely: the curated + # exclude list (names or glob patterns) is written as-is, everything else + # stays enabled — including tools the server adds later. A reinstall with + # a prior include selection still honours the user's own choice below. + if entry.tools.default_excluded and prior_selection is None: + _write_tools_exclude(entry.name, entry.tools.default_excluded) + print(color( + f" Applied manifest exclude list " + f"({len(entry.tools.default_excluded)} entries); everything else " + f"stays enabled. Edit mcp_servers.{entry.name}.tools.exclude in " + "config.yaml or run " + f"`hermes mcp configure {entry.name}` to change.", + Colors.GREEN, + )) + return + probed = _probe_tools(entry.name) # Probe failure path diff --git a/optional-mcps/cloudflare/manifest.yaml b/optional-mcps/cloudflare/manifest.yaml index 381d90d5110..8e8e7ede485 100644 --- a/optional-mcps/cloudflare/manifest.yaml +++ b/optional-mcps/cloudflare/manifest.yaml @@ -38,12 +38,66 @@ auth: # scope exactly which account permissions the agent gets. # Tool selection at install time: -# The surface is ~3,300 endpoint tools — far too many for a manual -# checklist, and exactly the case tool_search handles automatically. -# Leave default_enabled unset: no include filter is written and the full -# surface stays available behind tool_search's deferral gate. Users who -# want a hard subset can still write tools.include/exclude in config.yaml -# by hand (e.g. exclude the server's `docs` documentation-search tool). +# The surface is ~3,300 endpoint tools. Rather than a manual checklist (or +# a frozen include list that would block future endpoints), we ship a +# curated exclude list of glob patterns targeting product families that are +# enterprise-contract, org-fleet, or read-only-analytics surfaces — dead +# weight for the personal/dev accounts the catalog serves. Everything else +# (~1,900 tools: DNS, Workers, R2, KV, D1, Queues, Pages, WAF, rulesets, +# tunnels, Access, Stream, Images, AI, Vectorize, ...) stays enabled, +# including endpoints Cloudflare adds later. Users can re-enable any family +# by deleting its pattern from mcp_servers.cloudflare.tools.exclude. +tools: + default_excluded: + # The server's built-in Cloudflare-docs search tool (not an API + # endpoint) — redundant with the agent's own web tools. + - docs + # Radar: public read-only internet trend analytics (~275 tools). + # Cloudflare ships a dedicated Radar MCP for this. + - "*_radar_*" + # Enterprise networking: Magic Transit/WAN, network monitoring, + # interconnects, WAN teamnet. (cloudflared tunnels are NOT excluded.) + - "*_accounts_magic_*" + - "*_accounts_mnm_*" + - "*_accounts_cni_*" + - "*_accounts_teamnet_*" + # Cloudforce One threat-intel analyst platform (enterprise SOC). + - "*_accounts_cloudforceone_*" + # Zero Trust org-fleet suite: DLP, managed devices, DEX, data-security + # posture, email security, SCIM provisioning, SWG gateway policy. + # Access (login policies for your own apps) stays enabled. + - "*_accounts_dlp_*" + - "*_accounts_devices*" + - "*_accounts_dex_*" + - "*_accounts_datasecurity_*" + - "*_accounts_emailsecurity_*" + - "*_accounts_scim_*" + - "*_accounts_gateway*" + - "*_accounts_zerotrust_*" + - "*_accounts_one_*" + # Security-intel research products: brand protection, threat intel, + # URL scanner, CVE scanner, security center. + - "*_accounts_brandprotection_*" + - "*_accounts_intel_*" + - "*_accounts_urlscanner_*" + - "*_accounts_vuln_scanner_*" + - "*_zones_securitycenter_*" + - "*_accounts_securitycenter_*" + # Enterprise API Shield cluster + waiting rooms + BYOIP + data shares. + - "*_zones_api_gateway_*" + - "*_zones_schema_validation*" + - "*_zones_token_validation*" + - "*_zones_waiting_rooms*" + - "*_accounts_addressing_*" + - "*_accounts_shares*" + # Legacy / migration / niche: S3-migration slurper, Web3 gateways, + # secondary-DNS peering, legacy per-user load balancers. + - "*_accounts_slurper_*" + - "*_zones_web3_*" + - "*_accounts_flagship_*" + - "*_zones_secondary_dns_*" + - "*_accounts_secondary_dns_*" + - "*_user_load_balancers*" post_install: | On first connection, Hermes opens a browser to authorize with Cloudflare. @@ -51,10 +105,18 @@ post_install: | what you want the agent to touch. After auth, restart your Hermes session so the Cloudflare tools are loaded. - This entry exposes each Cloudflare API endpoint as an individual tool - (~3,300). Hermes's tool_search automatically defers them behind its - bridge tools, so your context is not flooded — the agent discovers the - right endpoint on demand with full schemas. + This entry exposes each Cloudflare API endpoint as an individual tool. + A curated exclude list ships in the manifest (enterprise-contract, + org-fleet, and read-only-analytics product families are disabled — + ~1,400 tools), leaving ~1,900 tools for the products people actually + drive from an agent: DNS, Workers, R2, KV, D1, Queues, Pages, WAF, + rulesets, tunnels, Access, Stream, Images, AI, Vectorize. Hermes's + tool_search defers them all, so your context is not flooded — the agent + discovers the right endpoint on demand with full schemas. + + Run a Zero Trust org or want Radar/Magic/API-Shield surfaces back? + Delete their patterns from mcp_servers.cloudflare.tools.exclude in + ~/.hermes/config.yaml. Headless / CI alternative: instead of OAuth, create a Cloudflare API token at https://dash.cloudflare.com/profile/api-tokens and configure the server diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 0c440f4909b..243c7b319b8 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -224,6 +224,31 @@ class TestManifestParsing: assert list_catalog() == [] + def test_tools_default_excluded_parsed(self, catalog_dir): + body = _basic_manifest( + tools={"default_excluded": ["docs", "*_radar_*"]}, + ) + _write_manifest(catalog_dir, "demo", body) + e = _entry("demo") + assert e.tools.default_excluded == ["docs", "*_radar_*"] + assert e.tools.default_enabled is None + + def test_tools_default_excluded_bad_shape_rejected(self, catalog_dir): + body = _basic_manifest(tools={"default_excluded": "docs"}) # str, not list + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + assert list_catalog() == [] + + def test_tools_enabled_and_excluded_mutually_exclusive(self, catalog_dir): + body = _basic_manifest( + tools={"default_enabled": ["a"], "default_excluded": ["b"]}, + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + assert list_catalog() == [] + # --------------------------------------------------------------------------- # Install flow @@ -245,6 +270,60 @@ class TestInstall: assert servers["demo"]["args"] == ["-y", "demo-mcp"] assert servers["demo"]["enabled"] is True + def test_install_default_excluded_writes_exclude_without_probe( + self, catalog_dir, monkeypatch + ): + """Exclude-mode manifests skip the probe/checklist and write + tools.exclude verbatim (names + glob patterns).""" + body = _basic_manifest( + tools={"default_excluded": ["docs", "*_radar_*"]}, + ) + _write_manifest(catalog_dir, "demo", body) + import hermes_cli.mcp_catalog as mc + from hermes_cli.config import load_config + + def _fail_probe(name): + raise AssertionError("probe must not run for exclude-mode manifests") + + monkeypatch.setattr(mc, "_probe_tools", _fail_probe) + mc.install_entry(_entry("demo"), enable=True) + + server = load_config()["mcp_servers"]["demo"] + assert server["tools"]["exclude"] == ["docs", "*_radar_*"] + assert "include" not in server["tools"] + + def test_reinstall_prior_include_wins_over_default_excluded( + self, catalog_dir, monkeypatch + ): + """A user's prior include selection survives reinstall of an + exclude-mode manifest (prior selection > manifest default).""" + body = _basic_manifest( + tools={"default_excluded": ["*_radar_*"]}, + ) + _write_manifest(catalog_dir, "demo", body) + import hermes_cli.mcp_catalog as mc + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("mcp_servers", {})["demo"] = { + "command": "npx", + "args": ["-y", "demo-mcp"], + "enabled": True, + "tools": {"include": ["tool_a"]}, + } + save_config(cfg) + + import sys as _sys + probed = [("tool_a", "a"), ("tool_b", "b")] + monkeypatch.setattr(mc, "_probe_tools", lambda name: probed) + monkeypatch.setattr(_sys.stdin, "isatty", lambda: False) + + mc.install_entry(_entry("demo"), enable=True) + + server = load_config()["mcp_servers"]["demo"] + assert server["tools"]["include"] == ["tool_a"] + assert "exclude" not in server["tools"] + def test_install_rejects_exfil_shaped_stdio_manifest(self, catalog_dir): body = _basic_manifest( "evil", diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index a6623504028..f5f8261fcf8 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -3832,6 +3832,59 @@ class TestMCPSelectiveToolLoading: "mcp__ink_exclude__list_services", ] + def test_exclude_filter_supports_glob_patterns(self): + """Glob entries in tools.exclude match families of tool names.""" + config = { + "url": "https://mcp.example.com", + "tools": {"exclude": ["*_radar_*", "docs"]}, + } + registered, _ = self._run_discover( + "ink_glob", + [ + "get_radar_http_summary", + "post_radar_scans", + "docs", + "get_zones_dns_records", + ], + config, + session=SimpleNamespace(), + ) + assert registered == ["mcp__ink_glob__get_zones_dns_records"] + + def test_include_filter_supports_glob_patterns(self): + """Glob entries in tools.include whitelist families of tool names.""" + config = { + "url": "https://mcp.example.com", + "tools": {"include": ["*_dns_*"]}, + } + registered, _ = self._run_discover( + "ink_glob_inc", + ["get_zones_dns_records", "post_zones_dns_records", "docs"], + config, + session=SimpleNamespace(), + ) + assert registered == [ + "mcp__ink_glob_inc__get_zones_dns_records", + "mcp__ink_glob_inc__post_zones_dns_records", + ] + + def test_plain_exclude_names_do_not_glob_match(self): + """Entries without metacharacters stay exact-match (no surprise hits).""" + config = { + "url": "https://mcp.example.com", + "tools": {"exclude": ["docs"]}, + } + registered, _ = self._run_discover( + "ink_exact", + ["docs", "docs_search", "get_docs"], + config, + session=SimpleNamespace(), + ) + assert registered == [ + "mcp__ink_exact__docs_search", + "mcp__ink_exact__get_docs", + ] + 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 7da89baff59..eb4c532860c 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -92,6 +92,7 @@ Thread safety: import asyncio import contextvars import concurrent.futures +import fnmatch import inspect import json import logging @@ -4862,7 +4863,7 @@ 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 names/patterns.""" if value is None: return set() if isinstance(value, str): @@ -4873,6 +4874,23 @@ def _normalize_name_filter(value: Any, label: str) -> set[str]: return set() +def _name_filter_matches(name: str, filter_set: set) -> bool: + """True if *name* matches any entry in a normalized include/exclude set. + + Entries containing a glob metacharacter (``*``, ``?``, ``[``) are matched + with :func:`fnmatch.fnmatchcase`; plain entries are exact names. Globs let + huge auto-generated surfaces (e.g. an OpenAPI-derived MCP exposing + thousands of endpoint tools) be filtered by product family + (``*_accounts_magic_*``) instead of thousand-line literal lists. + """ + if name in filter_set: + return True + for pat in filter_set: + if ("*" in pat or "?" in pat or "[" in pat) and fnmatch.fnmatchcase(name, pat): + return True + return False + + def _parse_boolish(value: Any, default: bool = True) -> bool: """Parse a bool-like config value with safe fallback.""" if value is None: @@ -5047,9 +5065,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 _name_filter_matches(tool_name, include_set) if exclude_set: - return tool_name not in exclude_set + return not _name_filter_matches(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 743c4c48617..aee42d96b92 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -104,6 +104,13 @@ The pre-checked rows come from: catalog entries pre-prune mutating or rarely-useful tools) 3. **Everything** if neither applies +Some entries with very large auto-generated surfaces (e.g. `cloudflare`, +~3,300 OpenAPI endpoint tools) instead declare `tools.default_excluded` — a +curated block-list of names and glob patterns. Installing one of these skips +the checklist entirely and writes `tools.exclude`; everything not matched +stays enabled, including tools the server adds later. Edit +`mcp_servers..tools.exclude` in config.yaml to re-enable a family. + Submit the checklist with ENTER. Only the checked tools end up in `mcp_servers..tools.include`. If you select everything, no filter is written (cleanest config shape, identical behavior). @@ -448,6 +455,12 @@ mcp_servers: Only those MCP server tools are registered. +Entries in `include`/`exclude` may also be glob patterns (`*`, `?`, `[...]`, +matched case-sensitively): `include: ["*_dns_*"]` registers every tool whose +name contains `_dns_`. Plain entries without metacharacters stay exact-match. +Globs are the practical way to filter servers that expose thousands of +auto-generated endpoint tools by product family. + ### Blacklist server tools ```yaml