diff --git a/EvoScientist/mcp/README.md b/EvoScientist/mcp/README.md index 1235f4d..58762de 100644 --- a/EvoScientist/mcp/README.md +++ b/EvoScientist/mcp/README.md @@ -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 ``` diff --git a/EvoScientist/mcp/client.py b/EvoScientist/mcp/client.py index 08e1039..0a5ff03 100644 --- a/EvoScientist/mcp/client.py +++ b/EvoScientist/mcp/client.py @@ -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( diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index a53e817..e0afa8c 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -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 ----