fix(mcp): return only non-MCP tools when semantic filter finds zero matches

Closes #24984

- When filter_tools() finds zero semantic matches, it previously returned
  all available tools, which could exceed the 128 tool limit
- Now returns only non-MCP tools (built-in tools without server prefix)
- Added regression test for zero-match fallback behavior
This commit is contained in:
VitalCheffe 2026-04-02 11:49:43 +00:00
parent d1df4e838b
commit 3ff9c98066
2 changed files with 67 additions and 1 deletions

View file

@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
from litellm._logging import verbose_logger
from litellm.proxy._experimental.mcp_server.utils import is_tool_name_prefixed
if TYPE_CHECKING:
from semantic_router.routers import SemanticRouter
@ -190,7 +192,13 @@ class SemanticMCPToolFilter:
matched_tool_names = self._extract_tool_names_from_matches(matches)
if not matched_tool_names:
return available_tools
# Return only non-MCP tools (built-in tools without a server prefix)
# to avoid exceeding provider tool limits when no semantic matches exist
return [
tool
for tool in available_tools
if not is_tool_name_prefixed(self._extract_tool_info(tool)[0])
]
return self._get_tools_by_names(matched_tool_names, available_tools)

View file

@ -191,6 +191,64 @@ async def test_semantic_filter_disabled():
assert len(filtered) == len(tools), f"Expected all {len(tools)} tools, got {len(filtered)}"
@pytest.mark.asyncio
async def test_semantic_filter_zero_matches_returns_only_non_mcp_tools():
"""
Test that when zero semantic matches are found, only non-MCP tools are returned.
Given: A mix of MCP tools (prefixed) and built-in tools (not prefixed)
When: Semantic filter finds zero matches (e.g., irrelevant query like "hello")
Then: Only built-in (non-prefixed) tools should be returned, not all tools
Regression test for: https://github.com/BerriAI/litellm/issues/24984
"""
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
SemanticMCPToolFilter,
)
# Create mock tools: 3 MCP tools (prefixed with server name) and 2 built-in tools
mcp_tools = [
MCPTool(name="server1-tool_a", description="MCP tool A", inputSchema={"type": "object"}),
MCPTool(name="server1-tool_b", description="MCP tool B", inputSchema={"type": "object"}),
MCPTool(name="server2-tool_c", description="MCP tool C", inputSchema={"type": "object"}),
]
builtin_tools = [
MCPTool(name="web_search", description="Search the web", inputSchema={"type": "object"}),
MCPTool(name="code_interpreter", description="Run code", inputSchema={"type": "object"}),
]
all_tools = mcp_tools + builtin_tools
# Mock router that returns zero matches for any query
mock_router = Mock()
mock_router.return_value = [] # No semantic matches
filter_instance = SemanticMCPToolFilter(
embedding_model="text-embedding-3-small",
litellm_router_instance=Mock(),
top_k=5,
similarity_threshold=0.1,
enabled=True,
)
# Inject a mock router that returns empty matches
filter_instance.tool_router = mock_router
# Filter with a query that matches nothing
filtered = await filter_instance.filter_tools(
query="hello",
available_tools=all_tools,
)
# Should return only non-MCP tools, not all tools
assert len(filtered) == 2, f"Expected 2 non-MCP tools, got {len(filtered)}"
filtered_names = [t.name for t in filtered]
assert "web_search" in filtered_names
assert "code_interpreter" in filtered_names
# No MCP tools should be returned
for name in filtered_names:
assert "-" not in name, f"MCP tool {name} should not be returned on zero matches"
@pytest.mark.asyncio
async def test_semantic_filter_empty_tools():
"""