fix(mcp): fall back to top_k MCP tools when zero matches and no non-MCP tools

When all tools are MCP and zero semantic matches are found, return the
first top_k MCP tools instead of an empty list. Avoids sending an empty
tool list to the LLM.
This commit is contained in:
Lance Hsu 2026-04-03 21:52:26 +08:00
parent a87329d2a5
commit 24ae278990
No known key found for this signature in database
GPG key ID: CFF816BB8560B8B8
2 changed files with 60 additions and 0 deletions

View file

@ -245,6 +245,16 @@ class SemanticToolFilterHook(CustomLogger):
available_tools=mcp_tools, # type: ignore
)
# If no MCP tools matched and no non-MCP tools exist, fall back
# to the first top_k MCP tools to avoid an empty tool list.
if not filtered_mcp_tools and not non_mcp_tools:
limit = self.filter.top_k
filtered_mcp_tools = mcp_tools[:limit]
verbose_proxy_logger.warning(
f"No semantic matches and no non-MCP tools — "
f"falling back to first {limit} MCP tools"
)
filtered_tools = filtered_mcp_tools + non_mcp_tools
# Always update tools and emit header (even if count unchanged)

View file

@ -434,6 +434,56 @@ async def test_semantic_filter_hook_skips_non_mcp_only_tools():
assert result is None, "Hook should skip when no MCP tools are present"
@pytest.mark.asyncio
async def test_hook_falls_back_to_top_k_when_only_mcp_and_zero_matches():
"""
Test that when all tools are MCP and zero semantic matches are found,
the hook falls back to the first top_k MCP tools instead of returning empty.
"""
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
SemanticMCPToolFilter,
)
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
mock_router = Mock()
filter_instance = SemanticMCPToolFilter(
embedding_model="text-embedding-3-small",
litellm_router_instance=mock_router,
top_k=3,
similarity_threshold=0.3,
enabled=True,
)
# Mock the semantic router to return no matches
filter_instance.tool_router = Mock(return_value=[])
hook = SemanticToolFilterHook(filter_instance)
# Only MCP tools (all prefixed), no non-MCP tools
mcp_only_tools = [
MCPTool(name=f"server-tool_{i}", description=f"MCP tool {i}", inputSchema={"type": "object"})
for i in range(10)
]
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hello"}],
"tools": mcp_only_tools,
"metadata": {},
}
result = await hook.async_pre_call_hook(
user_api_key_dict=Mock(),
cache=Mock(),
data=data,
call_type="completion",
)
assert result is not None
# Should fall back to first top_k (3) MCP tools, not empty
assert len(result["tools"]) == 3, f"Expected 3 tools (top_k), got {len(result['tools'])}"
@pytest.mark.asyncio
async def test_semantic_filter_zero_matches_returns_empty():
"""