fix(responses): reject MCP gateway requests that resolve zero tools

A request that explicitly asks for MCP tools via server_url litellm_proxy/...
but resolves none of them (the API key/team has no access to the MCP server
via allow_all_keys=false and no object-permission grant, the server name does
not exist, or allowed_tools matches nothing) was silently sent to the model
with no tools. The model then hallucinates, and the only trace is a
list_mcp_tools spend log with status success and an empty response — the
request looks healthy end to end while being completely broken.

Raise a 400 BadRequestError naming the requested server URLs and the likely
causes instead. Guard scope:

- Mixed requests are exempt: with other (function) tools present, the request
  proceeds using those tools, matching the previous fallback behaviour.
- Opt-out via litellm.reject_empty_mcp_resolved_tools = False (default True,
  per maintainer guidance).

The auth-header pass-through test in tests/mcp_tests now resolves a dummy
tool, since its purpose is header propagation, not zero-tool behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Thibault Serot 2026-07-09 14:48:15 +10:00
parent 70656be89e
commit b6d9d85f3e
5 changed files with 189 additions and 2 deletions

View file

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

View file

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

View file

@ -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(
**{

View file

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

View file

@ -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.",