Merge pull request #7 from EvoScientist/feat/mcp-wildcards

Add wildcard support for MCP tool filtering
This commit is contained in:
Xi Zhang
2026-02-13 17:32:31 +00:00
committed by GitHub
3 changed files with 235 additions and 3 deletions
+52
View File
@@ -67,6 +67,58 @@ github:
Available agents: `main`, `planner-agent`, `research-agent`, `code-agent`, `debug-agent`, `data-analysis-agent`, `writing-agent`.
## Tool Filtering with Wildcards
Use the `tools` field to filter which tools from a server are exposed. Supports glob-style wildcards:
```yaml
# Exact matching (original behavior)
exa:
transport: http
url: https://mcp.exa.ai/mcp
tools:
- web_search_exa
- get_code_context_exa
- company_research_exa
# Wildcard: all tools ending with _exa
exa:
transport: http
url: https://mcp.exa.ai/mcp
tools:
- "*_exa"
# Multiple patterns
filesystem:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
tools:
- "read_*" # read_file, read_directory, etc.
- "write_*" # write_file, etc.
- "list_*" # list_directory, etc.
# Mix wildcards and exact matches
mixed:
transport: http
url: https://example.com/mcp
tools:
- "search_*" # All search tools
- "get_metadata" # Plus this specific tool
```
### Wildcard Patterns
| Pattern | Matches | Example |
|---------|---------|---------|
| `*` | Any sequence of characters | `*_exa` matches `web_search_exa`, `get_code_context_exa` |
| `?` | Any single character | `tool_?` matches `tool_1`, `tool_2` but not `tool_10` |
| `[seq]` | Any character in sequence | `tool_[abc]` matches `tool_a`, `tool_b`, `tool_c` |
| `[0-9]` | Any character in range | `version_[0-9]` matches `version_0` through `version_9` |
| `[!seq]` | Any character NOT in sequence | `tool_[!0-9]` matches `tool_a` but not `tool_1` |
Wildcards work with exact patterns — you can mix both in the same `tools` list.
## CLI Commands
```
+31 -3
View File
@@ -7,6 +7,7 @@ and routes the resulting LangChain tools to the appropriate agents.
from __future__ import annotations
import asyncio
import fnmatch
import logging
import os
import re
@@ -534,14 +535,41 @@ def _build_connections(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
def _filter_tools(tools: list, allowed_names: list[str] | None) -> list:
"""Filter tools by allowlist.
"""Filter tools by allowlist with wildcard support.
If *allowed_names* is ``None``, all tools pass through.
Supports glob-style wildcards:
- ``*`` matches any sequence of characters
- ``?`` matches any single character
- ``[seq]`` matches any character in seq
- ``[!seq]`` matches any character not in seq
Examples:
- ``*_exa`` matches ``web_search_exa``, ``get_code_context_exa``
- ``read_*`` matches ``read_file``, ``read_directory``
- ``tool_[0-9]`` matches ``tool_1``, ``tool_2``, etc.
"""
if allowed_names is None:
return tools
allowed_set = set(allowed_names)
return [t for t in tools if t.name in allowed_set]
# Check if any pattern contains wildcard characters
has_wildcards = any(
any(char in pattern for char in "*?[]")
for pattern in allowed_names
)
if not has_wildcards:
# Fast path: exact matching with set lookup
allowed_set = set(allowed_names)
return [t for t in tools if t.name in allowed_set]
# Wildcard matching: check each tool against all patterns
filtered = []
for tool in tools:
if any(fnmatch.fnmatch(tool.name, pattern) for pattern in allowed_names):
filtered.append(tool)
return filtered
def _route_tools(
+152
View File
@@ -211,6 +211,158 @@ class TestFilterTools:
assert _filter_tools([], ["a"]) == []
assert _filter_tools([], None) == []
# Wildcard tests
def test_wildcard_star_suffix(self):
"""Test *_exa pattern matching."""
tools = [
_make_tool("web_search_exa"),
_make_tool("get_code_context_exa"),
_make_tool("company_research_exa"),
_make_tool("unrelated_tool"),
]
result = _filter_tools(tools, ["*_exa"])
assert [t.name for t in result] == [
"web_search_exa",
"get_code_context_exa",
"company_research_exa",
]
def test_wildcard_star_prefix(self):
"""Test read_* pattern matching."""
tools = [
_make_tool("read_file"),
_make_tool("read_directory"),
_make_tool("read_link"),
_make_tool("write_file"),
]
result = _filter_tools(tools, ["read_*"])
assert [t.name for t in result] == [
"read_file",
"read_directory",
"read_link",
]
def test_wildcard_star_middle(self):
"""Test pattern with * in the middle."""
tools = [
_make_tool("get_user_data"),
_make_tool("get_admin_data"),
_make_tool("get_file"),
]
result = _filter_tools(tools, ["get_*_data"])
assert [t.name for t in result] == ["get_user_data", "get_admin_data"]
def test_wildcard_star_only(self):
"""Test * matches everything."""
tools = [_make_tool("a"), _make_tool("b"), _make_tool("c")]
result = _filter_tools(tools, ["*"])
assert [t.name for t in result] == ["a", "b", "c"]
def test_wildcard_question_mark(self):
"""Test ? matches single character."""
tools = [
_make_tool("tool_1"),
_make_tool("tool_2"),
_make_tool("tool_10"),
]
result = _filter_tools(tools, ["tool_?"])
assert [t.name for t in result] == ["tool_1", "tool_2"]
def test_wildcard_character_class(self):
"""Test [seq] matches characters in sequence."""
tools = [
_make_tool("tool_a"),
_make_tool("tool_b"),
_make_tool("tool_c"),
_make_tool("tool_d"),
]
result = _filter_tools(tools, ["tool_[abc]"])
assert [t.name for t in result] == ["tool_a", "tool_b", "tool_c"]
def test_wildcard_character_class_range(self):
"""Test [0-9] matches digit range."""
tools = [
_make_tool("tool_0"),
_make_tool("tool_5"),
_make_tool("tool_9"),
_make_tool("tool_a"),
]
result = _filter_tools(tools, ["tool_[0-9]"])
assert [t.name for t in result] == ["tool_0", "tool_5", "tool_9"]
def test_wildcard_negated_character_class(self):
"""Test [!seq] matches characters not in sequence."""
tools = [
_make_tool("tool_a"),
_make_tool("tool_b"),
_make_tool("tool_1"),
_make_tool("tool_2"),
]
result = _filter_tools(tools, ["tool_[!0-9]"])
assert [t.name for t in result] == ["tool_a", "tool_b"]
def test_wildcard_mixed_with_exact(self):
"""Test mixing wildcard and exact patterns."""
tools = [
_make_tool("web_search_exa"),
_make_tool("get_code_context_exa"),
_make_tool("specific_tool"),
_make_tool("another_tool"),
]
result = _filter_tools(tools, ["*_exa", "specific_tool"])
assert [t.name for t in result] == [
"web_search_exa",
"get_code_context_exa",
"specific_tool",
]
def test_wildcard_multiple_patterns(self):
"""Test multiple wildcard patterns."""
tools = [
_make_tool("read_file"),
_make_tool("write_file"),
_make_tool("delete_file"),
_make_tool("search_database"),
]
result = _filter_tools(tools, ["read_*", "write_*"])
assert [t.name for t in result] == ["read_file", "write_file"]
def test_wildcard_no_match(self):
"""Test wildcard pattern that doesn't match anything."""
tools = [_make_tool("foo"), _make_tool("bar")]
result = _filter_tools(tools, ["baz_*"])
assert result == []
def test_wildcard_overlapping_patterns(self):
"""Test overlapping patterns don't duplicate results."""
tools = [_make_tool("tool_abc"), _make_tool("tool_xyz")]
result = _filter_tools(tools, ["tool_*", "*_abc"])
# Should include each tool only once
assert [t.name for t in result] == ["tool_abc", "tool_xyz"]
def test_wildcard_complex_pattern(self):
"""Test complex wildcard pattern."""
tools = [
_make_tool("get_user_info_v1"),
_make_tool("get_user_info_v2"),
_make_tool("get_admin_info_v1"),
_make_tool("set_user_info"),
]
result = _filter_tools(tools, ["get_*_info_v?"])
assert [t.name for t in result] == [
"get_user_info_v1",
"get_user_info_v2",
"get_admin_info_v1",
]
def test_exact_match_performance_path(self):
"""Test exact matching still works (fast path)."""
# This test verifies backward compatibility with exact matching
tools = [_make_tool("a"), _make_tool("b"), _make_tool("c")]
result = _filter_tools(tools, ["a", "c"])
assert [t.name for t in result] == ["a", "c"]
# ---- _route_tools ----