From 902bc8ed3cd6b90ad5111f069e5cf3b6455b73b4 Mon Sep 17 00:00:00 2001 From: Harshvardhan Date: Thu, 9 Jul 2026 16:41:12 +0530 Subject: [PATCH 1/3] fix(mcp): semantic tool filter now transforms tools to chat format _process_mcp_tools_to_openai_format defaulted target_format to "responses", so tools passed through the semantic filter got the flat Responses-API shape instead of the nested {type, function} Chat Completions shape. This broke hosted_vllm's strict schema validation on /v1/chat/completions. Add a target_format param (default preserved for existing callers) and have the semantic filter hook explicitly request "chat". Fixes #32281 (comment from brian-sbc confirming persistence in the semantic filter path after #32285/#32282 landed). --- litellm/proxy/hooks/mcp_semantic_filter/hook.py | 4 +++- litellm/responses/mcp/litellm_proxy_mcp_handler.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/hooks/mcp_semantic_filter/hook.py b/litellm/proxy/hooks/mcp_semantic_filter/hook.py index 7379096bf9b2..32125d408f58 100644 --- a/litellm/proxy/hooks/mcp_semantic_filter/hook.py +++ b/litellm/proxy/hooks/mcp_semantic_filter/hook.py @@ -100,7 +100,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 diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index e03f0296109f..6451f9185e41 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -371,6 +371,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. @@ -394,8 +395,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 From b42c1a4cf99dd27556c72b287e7a38b77a192e38 Mon Sep 17 00:00:00 2001 From: Harshvardhan Date: Thu, 9 Jul 2026 16:59:29 +0530 Subject: [PATCH 2/3] test(mcp): add regression test for semantic filter chat format Covers _expand_mcp_tools directly with a mocked MCP tool list, asserting the output uses the Chat Completions wrapper shape ({type, function}) rather than the flat Responses API shape. Addresses Greptile's test-coverage suggestion on #32606. --- .../test_semantic_tool_filter_e2e.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/mcp_tests/test_semantic_tool_filter_e2e.py b/tests/mcp_tests/test_semantic_tool_filter_e2e.py index f71067fde6da..3162810bec08 100644 --- a/tests/mcp_tests/test_semantic_tool_filter_e2e.py +++ b/tests/mcp_tests/test_semantic_tool_filter_e2e.py @@ -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" From 6cb25e5bb3bf739b9add40fd28d983a3b9a12c2c Mon Sep 17 00:00:00 2001 From: Harshvardhan Date: Thu, 9 Jul 2026 17:07:39 +0530 Subject: [PATCH 3/3] test(mcp): cover target_format forwarding in _process_mcp_tools_to_openai_format Unit test for the coverage-tracked test suite (tests/test_litellm/), since the earlier e2e test in tests/mcp_tests/ isn't part of the coverage-collected suite. Addresses the codecov patch-coverage flag on #32606. --- .../mcp/test_litellm_proxy_mcp_handler.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index 6fdbb0741aa6..457267fd427f 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -605,3 +605,42 @@ def find_spec(self, fullname, path=None, target=None): timeout=120, ) 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"}