From a2159c0b66a7aa1b7f64057a1c8e3e50f0a43e37 Mon Sep 17 00:00:00 2001 From: Parideboy Date: Tue, 23 Jun 2026 01:55:22 +0200 Subject: [PATCH] feat(proxy): support glob patterns in exclude_tools (#870) (#1259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- headroom/config.py | 24 ++++++++++ headroom/proxy/handlers/openai.py | 4 +- headroom/proxy/server.py | 4 +- headroom/transforms/content_router.py | 11 ++++- tests/test_transforms/test_content_router.py | 49 ++++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/headroom/config.py b/headroom/config.py index beb49d3de..ecc1a08ff 100644 --- a/headroom/config.py +++ b/headroom/config.py @@ -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"}) diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index a2fd6bac3..96f0ca0f1 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -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 {} diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index c822483a9..610a2a817 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -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.", ) diff --git a/headroom/transforms/content_router.py b/headroom/transforms/content_router.py index c2f62f448..2292a6ec6 100644 --- a/headroom/transforms/content_router.py +++ b/headroom/transforms/content_router.py @@ -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 --- diff --git a/tests/test_transforms/test_content_router.py b/tests/test_transforms/test_content_router.py index d34a34ec4..cfe94ebd6 100644 --- a/tests/test_transforms/test_content_router.py +++ b/tests/test_transforms/test_content_router.py @@ -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(