diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index 0bafd7da265..96ec25f125f 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -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) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 87c597c659b..6363b566ff6 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -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(): """