mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
70656be89e
commit
b6d9d85f3e
5 changed files with 189 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
**{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue