mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
ce0defe4d8
commit
58c97b9ddd
6 changed files with 295 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue