fix(mcp_semantic_filter): keep tool names whole in filter response header (#32282)

The x-litellm-semantic-filter-tools response header was sliced mid-name at
MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH with a trailing "...", so the
admin UI test panel rendered the last selected tool name chopped. Truncate
the CSV at a tool name boundary instead so the header only ever carries
complete names, and note in the test panel how many selected tools did not
fit in the header
This commit is contained in:
tin-berri 2026-07-06 20:00:17 -07:00 committed by GitHub
parent 8449ecee6a
commit 5e73994441
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 96 additions and 5 deletions

View file

@ -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

View file

@ -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) == ""

View file

@ -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(<MCPSemanticFilterTestPanel {...buildProps({ testResult })} />);
expect(screen.getByText("+5 more selected tools not shown")).toBeInTheDocument();
});
it("should not render the results section when testResult is null", () => {

View file

@ -103,6 +103,11 @@ export default function MCPSemanticFilterTestPanel({
</li>
))}
</ul>
{testResult.selectedTools > testResult.tools.length && (
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}>
+{testResult.selectedTools - testResult.tools.length} more selected tools not shown
</Typography.Text>
)}
</div>
</div>
)}