feat(proxy): support glob patterns in exclude_tools (#870) (#1259)

## Description

`exclude_tools` only matched tool names exactly, so users could not
exclude families of tools (for example all `mcp__*`). This adds
glob-pattern support via a shared `is_tool_excluded` helper used by both
the content router and the OpenAI handler, keeping
exact/case-insensitive matching intact.

Closes #870

## Type of Change

- [x] New feature (non-breaking change that adds functionality)

## Changes Made

- `headroom/config.py`: added `is_tool_excluded(name, exclude_tools)`
helper that keeps exact/case-insensitive matching and adds `fnmatch`
glob support.
- `headroom/transforms/content_router.py` and
`headroom/proxy/handlers/openai.py`: routed tool-exclusion checks
through the shared helper.
- `headroom/proxy/server.py`: documented glob support in the
`--exclude-tools` CLI help and `_parse_exclude_tools` docstring.
- `tests/test_transforms/test_content_router.py`: added
`test_glob_exclude_tools` and `test_is_tool_excluded_helper`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality

### Test Output

```text
$ pytest tests/test_transforms/test_content_router.py -q
53 passed

$ pytest tests/ -k "exclude or config" -q
59 passed
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, pytest-asyncio 1.4.0
- Exact command / steps: Ran the content-router suite and the
exclude/config-focused tests after adding the helper and glob support.
- Observed result: 53 content-router tests pass (including the two new
glob tests) and 59 exclude/config tests pass; glob patterns like
`mcp__*` now exclude matching tools while exact names still work.
- Not tested: Did not exercise glob exclusion against a live MCP server
end to end.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Parideboy 2026-06-23 01:55:22 +02:00 committed by GitHub
parent 14e8dc4c84
commit a2159c0b66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 87 additions and 5 deletions

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import fnmatch
from collections.abc import Iterable
from dataclasses import InitVar, dataclass, field
from datetime import datetime
from enum import Enum
@ -224,6 +226,28 @@ DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
}
)
def is_tool_excluded(name: str, exclude_tools: Iterable[str]) -> bool:
"""Return True if ``name`` matches the tool-exclusion set.
Plain entries match by exact (case-insensitive) name, so the common case
stays a set lookup. Entries containing a glob metacharacter (``*``, ``?`` or
``[``) are matched with :func:`fnmatch.fnmatchcase`, letting a single pattern
such as ``mcp__*`` cover every tool an MCP server exposes without listing
each name (issue #870).
"""
if not exclude_tools:
return False
if name in exclude_tools or name.lower() in exclude_tools:
return True
lname = name.lower()
return any(
fnmatch.fnmatchcase(lname, pat.lower())
for pat in exclude_tools
if "*" in pat or "?" in pat or "[" in pat
)
# Tool names recognized as Read/Edit/Write for lifecycle tracking
_READ_TOOL_NAMES: frozenset[str] = frozenset({"Read", "read"})
_EDIT_TOOL_NAMES: frozenset[str] = frozenset({"Edit", "edit"})

View file

@ -790,7 +790,7 @@ class OpenAIHandlerMixin:
# mirroring ContentRouter's policy. exclude_tools already contains both
# original and lowercased name variants (see _parse_exclude_tools), but
# we also test the lowercased name defensively for case-insensitivity.
from headroom.config import DEFAULT_EXCLUDE_TOOLS
from headroom.config import DEFAULT_EXCLUDE_TOOLS, is_tool_excluded
router_exclude_tools = getattr(router.config, "exclude_tools", None)
effective_exclude_tools = (
@ -799,7 +799,7 @@ class OpenAIHandlerMixin:
excluded_call_ids: set[str] = {
call_id
for call_id, fn_name in function_name_by_call_id.items()
if fn_name in effective_exclude_tools or fn_name.lower() in effective_exclude_tools
if is_tool_excluded(fn_name, effective_exclude_tools)
}
timing_sink: dict[str, float] = timing if timing is not None else {}

View file

@ -3994,7 +3994,8 @@ def _parse_exclude_tools(cli_excludes: str | None) -> set[str]:
(e.g. "WebSearch,WebFetch"). Each name is added in both original and
lowercase form for case-insensitive matching, mirroring
DEFAULT_EXCLUDE_TOOLS. Unset/empty -> empty set (DEFAULT_EXCLUDE_TOOLS
used unchanged).
used unchanged). Entries may contain glob patterns (e.g. "mcp__*"); see
config.is_tool_excluded for the matching semantics.
"""
raw = ",".join(s for s in (cli_excludes, os.environ.get("HEADROOM_EXCLUDE_TOOLS")) if s)
names: set[str] = set()
@ -4205,6 +4206,7 @@ if __name__ == "__main__":
default=None,
help="Comma-separated tool names whose output is never compressed, "
"merged with the built-in defaults (e.g., WebSearch,WebFetch). "
"Entries may use glob patterns, e.g. 'mcp__*' to exclude every MCP tool. "
"Also settable via HEADROOM_EXCLUDE_TOOLS env var.",
)

View file

@ -47,7 +47,12 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from ..config import DEFAULT_EXCLUDE_TOOLS, ReadLifecycleConfig, TransformResult
from ..config import (
DEFAULT_EXCLUDE_TOOLS,
ReadLifecycleConfig,
TransformResult,
is_tool_excluded,
)
from ..tokenizer import Tokenizer
from .base import Transform
from .content_detector import ContentType, DetectionResult
@ -2321,7 +2326,9 @@ class ContentRouter(Transform):
else DEFAULT_EXCLUDE_TOOLS
)
excluded_tool_ids = {
tool_id for tool_id, name in tool_name_map.items() if name in exclude_tools
tool_id
for tool_id, name in tool_name_map.items()
if is_tool_excluded(name, exclude_tools)
}
# --- Adaptive parameters based on context pressure ---

View file

@ -681,6 +681,55 @@ class TestExcludeTools:
assert result.messages[1]["content"] == messages[1]["content"]
assert "router:excluded:tool" in result.transforms_applied
def test_glob_exclude_tools(self, tokenizer):
"""Glob patterns in exclude_tools match by prefix (issue #870)."""
config = ContentRouterConfig(
min_section_tokens=10,
exclude_tools={"mcp__*"}, # One pattern excludes every MCP tool
)
router = ContentRouter(config)
messages = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_mcp_1",
"type": "function",
"function": {
"name": "mcp__build123d__measure",
"arguments": "{}",
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_mcp_1",
"content": generate_json_data(50),
},
]
result = router.apply(messages, tokenizer)
# The MCP tool result matched the glob and was left unchanged.
assert result.messages[1]["content"] == messages[1]["content"]
assert "router:excluded:tool" in result.transforms_applied
def test_is_tool_excluded_helper(self):
"""is_tool_excluded: exact (case-insensitive) and glob matching."""
from headroom.config import is_tool_excluded
# Glob entry covers a whole MCP server; unrelated tools are untouched.
assert is_tool_excluded("mcp__build123d__measure", {"mcp__*"})
assert not is_tool_excluded("Bash", {"mcp__*"})
# Plain entries keep exact, case-insensitive membership.
assert is_tool_excluded("Read", {"read"})
assert is_tool_excluded("MCP__X", {"mcp__*"})
# Empty set never excludes.
assert not is_tool_excluded("Read", set())
def test_non_excluded_tools_are_compressed(self, tokenizer):
"""Tools not in exclude_tools set are still compressed."""
config = ContentRouterConfig(