Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion litellm/proxy/hooks/mcp_semantic_filter/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ async def _expand_mcp_tools(
openai_tools,
_,
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_to_openai_format(
user_api_key_auth=user_api_key_dict, mcp_tools_with_litellm_proxy=mcp_tools
user_api_key_auth=user_api_key_dict,
mcp_tools_with_litellm_proxy=mcp_tools,
target_format="chat",
)

# Convert Pydantic models to dicts for compatibility
Expand Down
6 changes: 4 additions & 2 deletions litellm/responses/mcp/litellm_proxy_mcp_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ async def _process_mcp_tools_to_openai_format(
mcp_tools_with_litellm_proxy: List[ToolParam],
litellm_trace_id: Optional[str] = None,
request_tags: Optional[list[str]] = None,
target_format: Literal["responses", "chat"] = "responses",
) -> tuple[List[Any], dict[str, str]]:
"""
Centralized method to process MCP tools through the complete pipeline.
Expand All @@ -395,8 +396,9 @@ async def _process_mcp_tools_to_openai_format(
request_tags=request_tags,
)

openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(deduplicated_mcp_tools)

openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
deduplicated_mcp_tools, target_format=target_format
)
return openai_tools, tool_server_map

@staticmethod
Expand Down
46 changes: 46 additions & 0 deletions tests/mcp_tests/test_semantic_tool_filter_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,49 @@ async def test_e2e_semantic_filter():
f"✅ E2E test passed: Filtering reduced tools from {len(tools)} to {len(result['tools'])}"
)
print(f" Filtered tools: {[t.name for t in result['tools']]}")


@pytest.mark.asyncio
async def test_expand_mcp_tools_uses_chat_format():
"""_expand_mcp_tools must request the Chat Completions tool shape
({"type": "function", "function": {...}}), not the flat Responses API
shape, since it feeds /v1/chat/completions providers with strict
schema validation (e.g. hosted_vllm). Regression test for #32281.
"""
from unittest.mock import AsyncMock, patch

from litellm.proxy.hooks.mcp_semantic_filter.hook import SemanticToolFilterHook

fake_tools = [
MCPTool(
name="matomo-matomo_site_list",
description="List sites",
inputSchema={"type": "object", "properties": {}},
),
]

with patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._get_mcp_tools_from_manager",
new=AsyncMock(return_value=(fake_tools, ["matomo"])),
):
hook = SemanticToolFilterHook(Mock())
result = await hook._expand_mcp_tools(
tools=[
{
"type": "mcp",
"server_url": "litellm_proxy/mcp/matomo",
"server_label": "matomo_mcp",
"require_approval": "never",
}
],
user_api_key_dict=Mock(),
)

assert len(result) == 1
tool = result[0]
assert tool["type"] == "function"
assert "function" in tool, (
"Expected the Chat Completions wrapper shape "
f"{{'type': 'function', 'function': {{...}}}}, got flat keys: {list(tool.keys())}"
)
assert tool["function"]["name"] == "matomo-matomo_site_list"
39 changes: 39 additions & 0 deletions tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,45 @@ def find_spec(self, fullname, path=None, target=None):
assert result.returncode == 0, result.stderr


@pytest.mark.asyncio
async def test_process_mcp_tools_to_openai_format_forwards_target_format(monkeypatch):
"""_process_mcp_tools_to_openai_format must forward target_format to
_transform_mcp_tools_to_openai, so callers like the semantic tool
filter can request the Chat Completions shape. Regression test for #32281.
"""
captured = {}

async def fake_process_without_transform(*args, **kwargs):
return (["tool"], {"tool": "server"})

def fake_transform(tools, target_format="responses"):
captured["target_format"] = target_format
return [{"format": target_format}]

monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_process_mcp_tools_without_openai_transform",
fake_process_without_transform,
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_transform_mcp_tools_to_openai",
staticmethod(fake_transform),
)

chat_tools, tool_server_map = (
await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_to_openai_format(
user_api_key_auth=None,
mcp_tools_with_litellm_proxy=[],
target_format="chat",
)
)

assert captured["target_format"] == "chat"
assert chat_tools == [{"format": "chat"}]
assert tool_server_map == {"tool": "server"}


def test_extract_tool_call_details_reads_anthropic_tool_use_input():
"""
Regression test (LIT-4517): an Anthropic tool_use block carries its arguments
Expand Down
Loading