Merge pull request #7 from EvoScientist/feat/mcp-wildcards
Add wildcard support for MCP tool filtering
This commit is contained in:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user