diff --git a/litellm/__init__.py b/litellm/__init__.py index 9327e121b1d..59bc8c37fd2 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -316,6 +316,12 @@ disable_add_transform_inline_image_block: bool = False disable_add_user_agent_to_request_tags: bool = False disable_anthropic_gemini_context_caching_transform: bool = False disable_vertex_batch_output_transformation: bool = False +# Raise a 400 when a Responses API request asks for MCP gateway tools +# (server_url litellm_proxy/...) but zero tools resolve (key/team lacks server +# access, unknown server name, or allowed_tools matches nothing) and the +# request carries no other tools. Without this the model is silently called +# with no tools and hallucinates. Set to False to restore the old behaviour. +reject_empty_mcp_resolved_tools: bool = True extra_spend_tag_headers: Optional[List[str]] = None in_memory_llm_clients_cache: "LLMClientCache" safe_memory_mode: bool = False diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 0f9e3d86ece..64528c7dd0a 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -216,6 +216,35 @@ async def aresponses_api_with_mcp( ) openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(original_mcp_tools) + if ( + litellm.reject_empty_mcp_resolved_tools + and mcp_tools_with_litellm_proxy + and not original_mcp_tools + and not other_tools + ): + # The request explicitly asked for MCP tools but none resolved, and + # there are no other tools to fall back on. This is almost always a + # misconfiguration: the API key/team has no access to the MCP server + # (allow_all_keys=false and no object-permission grant), the server + # name does not exist, or allowed_tools matches no tool on the server. + # Silently calling the model with no tools makes it hallucinate, and + # the only trace is a list_mcp_tools spend log with an empty response — + # so fail loudly instead. + requested_mcp_urls = [tool.get("server_url") for tool in mcp_tools_with_litellm_proxy if isinstance(tool, dict)] + raise litellm.BadRequestError( + message=( + "MCP gateway resolved 0 tools for the requested MCP tool(s) " + f"(server_url(s): {requested_mcp_urls}). Likely causes: the API " + "key/team does not have access to the MCP server (server has " + "allow_all_keys=false and no key/team object-permission grant), " + "the server name does not exist, or allowed_tools matches no " + "tool on the server. Set litellm.reject_empty_mcp_resolved_tools " + "= False to restore the previous silent behaviour." + ), + model=model, + llm_provider=custom_llm_provider or "openai", + ) + # Combine with other tools all_tools = openai_tools + other_tools if (openai_tools or other_tools) else None diff --git a/tests/mcp_tests/test_aresponses_api_with_mcp.py b/tests/mcp_tests/test_aresponses_api_with_mcp.py index 9cd45f3d6fc..cf2a9586dff 100644 --- a/tests/mcp_tests/test_aresponses_api_with_mcp.py +++ b/tests/mcp_tests/test_aresponses_api_with_mcp.py @@ -278,7 +278,12 @@ async def test_aresponses_api_with_mcp_passes_mcp_server_auth_headers_to_process async def mock_process(**kwargs): captured_process_kwargs.update(kwargs) - return ([], {}) + from mcp.types import Tool as MCPTool + + dummy_tool = MCPTool( + name="dummy_tool", description="dummy", inputSchema={"type": "object"} + ) + return ([dummy_tool], {"dummy_tool": "dummy_server"}) mock_response = ResponsesAPIResponse( **{ diff --git a/tests/test_litellm/responses/mcp/test_mcp_empty_resolved_tools.py b/tests/test_litellm/responses/mcp/test_mcp_empty_resolved_tools.py new file mode 100644 index 00000000000..1a71b14475f --- /dev/null +++ b/tests/test_litellm/responses/mcp/test_mcp_empty_resolved_tools.py @@ -0,0 +1,144 @@ +""" +Guard tests: a request that explicitly asks for MCP tools via the litellm_proxy +gateway but resolves zero of them (and has no other tools to fall back on) must +fail loudly with a 400 instead of silently calling the model with no tools — +which makes it hallucinate, with the only trace being a "success" +list_mcp_tools spend log with an empty response. +""" + +import sys +from unittest.mock import AsyncMock + +import pytest + +import litellm +from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler +from litellm.types.llms.openai import ResponsesAPIResponse + +# See test_mcp_streaming_iterator.py: look the real submodule up in sys.modules +# to sidestep litellm.responses being shadowed by the re-exported function. +responses_main_module = sys.modules["litellm.responses.main"] + +MCP_TOOL = { + "type": "mcp", + "server_url": "litellm_proxy/mcp/nonexistent_server", + "require_approval": "never", + "allowed_tools": ["get_links"], +} + + +def _patch_resolved_tools(monkeypatch: pytest.MonkeyPatch, resolved_tools: list) -> None: + monkeypatch.setattr( + LiteLLM_Proxy_MCP_Handler, + "_process_mcp_tools_without_openai_transform", + AsyncMock(return_value=(resolved_tools, {})), + ) + + +def _model_response() -> ResponsesAPIResponse: + return ResponsesAPIResponse(id="resp-1", created_at=0, output=[]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_zero_resolved_mcp_tools_raises_before_model_call(monkeypatch, stream): + _patch_resolved_tools(monkeypatch, []) + aresponses_mock = AsyncMock() + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + with pytest.raises(litellm.BadRequestError) as excinfo: + await responses_main_module.aresponses_api_with_mcp( + input="how many links do i have?", + model="gpt-4", + stream=stream, + tools=[MCP_TOOL], + ) + + message = str(excinfo.value) + assert "resolved 0 tools" in message + assert "litellm_proxy/mcp/nonexistent_server" in message + assert "allow_all_keys" in message + # The model was never called without its tools. + aresponses_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_zero_resolved_mcp_tools_with_function_tools_falls_back(monkeypatch): + """Mixed requests keep working: with other (function) tools present, the + request proceeds using those tools instead of hard-failing.""" + _patch_resolved_tools(monkeypatch, []) + response = _model_response() + aresponses_mock = AsyncMock(return_value=response) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + function_tool = {"type": "function", "name": "my_fn", "parameters": {}} + result = await responses_main_module.aresponses_api_with_mcp( + input="hello", + model="gpt-4", + stream=False, + tools=[MCP_TOOL, function_tool], + ) + + assert result is response + aresponses_mock.assert_called_once() + assert aresponses_mock.call_args.kwargs["tools"] == [function_tool] + + +@pytest.mark.asyncio +async def test_zero_resolved_mcp_tools_flag_off_restores_old_behaviour(monkeypatch): + _patch_resolved_tools(monkeypatch, []) + response = _model_response() + aresponses_mock = AsyncMock(return_value=response) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + monkeypatch.setattr(litellm, "reject_empty_mcp_resolved_tools", False) + + result = await responses_main_module.aresponses_api_with_mcp( + input="how many links do i have?", + model="gpt-4", + stream=False, + tools=[MCP_TOOL], + ) + + assert result is response + aresponses_mock.assert_called_once() + + +@pytest.mark.asyncio +async def test_resolved_mcp_tools_proceed_to_model_call(monkeypatch): + from mcp.types import Tool as MCPTool + + resolved = [MCPTool(name="get_links", description="List links", inputSchema={"type": "object"})] + _patch_resolved_tools(monkeypatch, resolved) + + response = _model_response() + aresponses_mock = AsyncMock(return_value=response) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + result = await responses_main_module.aresponses_api_with_mcp( + input="how many links do i have?", + model="gpt-4", + stream=False, + tools=[MCP_TOOL], + ) + + assert result is response + aresponses_mock.assert_called_once() + assert aresponses_mock.call_args.kwargs["tools"], "model call must carry the resolved tools" + + +@pytest.mark.asyncio +async def test_request_without_mcp_tools_is_unaffected(monkeypatch): + """Plain function-tool requests never hit the guard.""" + response = _model_response() + aresponses_mock = AsyncMock(return_value=response) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + result = await responses_main_module.aresponses_api_with_mcp( + input="hello", + model="gpt-4", + stream=False, + tools=[{"type": "function", "name": "my_fn", "parameters": {}}], + ) + + assert result is response + aresponses_mock.assert_called_once() diff --git a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py index d2e593b4896..d6cefaed1b6 100644 --- a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py +++ b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py @@ -101,12 +101,15 @@ async def test_eager_creation_reraises_pre_stream_failure_as_http_error(monkeypa the stashed creation failure, so the proxy returns a real 4xx/5xx before any SSE bytes are written instead of an HTTP 200 with a broken stream. """ + from mcp.types import Tool as MCPTool + from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler + resolved_tool = MCPTool(name="read_wiki_contents", description="read", inputSchema={"type": "object"}) monkeypatch.setattr( LiteLLM_Proxy_MCP_Handler, "_process_mcp_tools_without_openai_transform", - AsyncMock(return_value=([], {})), + AsyncMock(return_value=([resolved_tool], {"read_wiki_contents": "deepwiki"})), ) boom = litellm.BadRequestError( message="Previous response with id 'resp_bogus' not found.",