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
+
+ )}
)}