feat(mcp): curated exclude list for cloudflare + glob tool filters + default_excluded manifests

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.
This commit is contained in:
Teknium 2026-07-20 08:51:47 -07:00
parent ce0defe4d8
commit 58c97b9ddd
No known key found for this signature in database
6 changed files with 295 additions and 14 deletions

View file

@ -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.<name>.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.<name>.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

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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.<name>.tools.exclude` in config.yaml to re-enable a family.
Submit the checklist with ENTER. Only the checked tools end up in
`mcp_servers.<name>.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