diff --git a/litellm/proxy/hooks/mcp_semantic_filter/hook.py b/litellm/proxy/hooks/mcp_semantic_filter/hook.py index 92b74848188..a374d9ce18f 100644 --- a/litellm/proxy/hooks/mcp_semantic_filter/hook.py +++ b/litellm/proxy/hooks/mcp_semantic_filter/hook.py @@ -24,6 +24,18 @@ if TYPE_CHECKING: from litellm.router import Router +def _truncate_csv_at_tool_name_boundary(tool_names_csv: str, max_length: int) -> str: + """Cap a CSV of tool names to max_length, dropping any name that does not fit whole.""" + if len(tool_names_csv) <= max_length: + return tool_names_csv + + head = tool_names_csv[: max_length + 1] + if "," not in head: + return "" + + return head.rsplit(",", 1)[0] + + class SemanticToolFilterHook(CustomLogger): """ Pre-call hook that filters MCP tools semantically. @@ -327,11 +339,12 @@ class SemanticToolFilterHook(CustomLogger): # Add CSV of filtered tool names (nginx-safe length) tool_names_csv = metadata.get("litellm_semantic_filter_tools", "") - if tool_names_csv: - if len(tool_names_csv) > MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH: - tool_names_csv = tool_names_csv[: MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH - 3] + "..." - - headers["x-litellm-semantic-filter-tools"] = tool_names_csv + header_safe_csv = _truncate_csv_at_tool_name_boundary( + tool_names_csv=tool_names_csv, + max_length=MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH, + ) + if header_safe_csv: + headers["x-litellm-semantic-filter-tools"] = header_safe_csv return headers 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 cebc265a148..39e630cb1e0 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 @@ -1050,3 +1050,64 @@ class TestGetToolsByNames: ) assert matched == [] + + +@pytest.mark.asyncio +async def test_semantic_filter_headers_hook_emits_only_complete_tool_names(): + """ + Regression test for LIT-4215. + + The x-litellm-semantic-filter-tools header used to be sliced mid-name at + MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH with a "..." suffix, so the UI + rendered a chopped tool name as the last entry. The header must only ever + contain complete tool names, in their original order, within the cap. + """ + from litellm.constants import MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + filter_instance = SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=Mock(), + top_k=10, + similarity_threshold=0.3, + enabled=True, + ) + hook = SemanticToolFilterHook(filter_instance) + + tool_names = [f"metrics_mcp-very_long_tool_name_for_header_{i:02d}" for i in range(8)] + data = { + "metadata": { + "litellm_semantic_filter_stats": "40->8", + "litellm_semantic_filter_tools": ",".join(tool_names), + } + } + + headers = await hook.async_post_call_response_headers_hook( + data=data, + user_api_key_dict=Mock(), + response=None, + ) + + assert headers is not None + assert headers["x-litellm-semantic-filter"] == "40->8" + + tools_header = headers["x-litellm-semantic-filter-tools"] + assert len(tools_header) <= MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH + emitted_names = tools_header.split(",") + assert emitted_names == tool_names[: len(emitted_names)] + assert 0 < len(emitted_names) < len(tool_names) + + +def test_truncate_csv_at_tool_name_boundary_edges(): + from litellm.proxy.hooks.mcp_semantic_filter.hook import ( + _truncate_csv_at_tool_name_boundary, + ) + + assert _truncate_csv_at_tool_name_boundary(tool_names_csv="a,b,c", max_length=150) == "a,b,c" + assert _truncate_csv_at_tool_name_boundary(tool_names_csv="abc,def", max_length=3) == "abc" + assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=5) == "ab,cd" + assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=4) == "ab" + assert _truncate_csv_at_tool_name_boundary(tool_names_csv="single_name_longer_than_cap", max_length=10) == "" diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx index 1302ac48376..af620ea3793 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx @@ -103,6 +103,18 @@ describe("MCPSemanticFilterTestPanel", () => { expect(screen.getByText("wiki-fetch")).toBeInTheDocument(); expect(screen.getByText("github-search")).toBeInTheDocument(); expect(screen.getByText("slack-post")).toBeInTheDocument(); + expect(screen.queryByText(/more selected tools not shown/i)).not.toBeInTheDocument(); + }); + + it("should note how many selected tools are missing when the header list is incomplete", () => { + const testResult: TestResult = { + totalTools: 40, + selectedTools: 8, + tools: ["metrics_mcp-node_query_by_id", "metrics_mcp-latency_query_api", "inventory_mcp-site_lookup"], + }; + render(); + + expect(screen.getByText("+5 more selected tools not shown")).toBeInTheDocument(); }); it("should not render the results section when testResult is null", () => { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx index d8dc675361a..550eabf1f58 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx @@ -103,6 +103,11 @@ export default function MCPSemanticFilterTestPanel({ ))} + {testResult.selectedTools > testResult.tools.length && ( + + +{testResult.selectedTools - testResult.tools.length} more selected tools not shown + + )} )}