Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,12 @@ def _dev_env_hot_reload_enabled() -> bool:
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
Expand Down
41 changes: 40 additions & 1 deletion litellm/responses/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The list comprehension passes through dicts missing a server_url key, so tool.get("server_url") silently populates the list with None values, making the error message less actionable.

Suggested change
requested_mcp_urls = [tool.get("server_url") for tool in mcp_tools_with_litellm_proxy if isinstance(tool, dict)]
requested_mcp_urls = [tool["server_url"] for tool in mcp_tools_with_litellm_proxy if isinstance(tool, dict) and "server_url" in tool]

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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

Expand Down Expand Up @@ -261,7 +290,7 @@ async def aresponses_api_with_mcp(
pre_processed_mcp_tools=original_mcp_tools,
)

return LiteLLM_Proxy_MCP_Handler._create_mcp_streaming_response(
mcp_streaming_response = LiteLLM_Proxy_MCP_Handler._create_mcp_streaming_response(
input=input,
model=model,
all_tools=all_tools,
Expand All @@ -272,6 +301,16 @@ async def aresponses_api_with_mcp(
tool_server_map=tool_server_map,
**kwargs,
)
# Make the initial LLM call eagerly, before any SSE bytes are written,
# so a pre-stream failure (e.g. an invalid previous_response_id ->
# provider 400 "No tool output found for function call ...") surfaces
# as a normal HTTP error instead of an HTTP 200 whose stream emits
# mcp_list_tools events with no response.created (which crashes SDK
# stream accumulators).
await mcp_streaming_response._create_initial_response_iterator()
if mcp_streaming_response._initial_creation_error is not None:
raise mcp_streaming_response._initial_creation_error
return mcp_streaming_response

# Determine if we should auto-execute tools
should_auto_execute = bool(mcp_tools_with_litellm_proxy) and LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(
Expand Down
88 changes: 79 additions & 9 deletions litellm/responses/mcp/mcp_streaming_iterator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator
from litellm.types.llms.openai import (
BaseLiteLLMOpenAIResponseObject,
ErrorEvent,
ErrorEventError,
MCPCallArgumentsDeltaEvent,
MCPCallArgumentsDoneEvent,
MCPCallCompletedEvent,
Expand Down Expand Up @@ -316,6 +318,18 @@ def __init__(
# Cache the response ID to ensure consistency across all events
self._cached_response_id: Optional[str] = None

# Internal failures (initial LLM call, tool execution, follow-up call)
# are stashed here so they can be surfaced to the client as an `error`
# stream event, or re-raised before any SSE bytes are written (eager
# path in aresponses_api_with_mcp for the initial call).
self._initial_creation_error: Optional[Exception] = None
self._stream_error: Optional[Exception] = None
self._error_event_emitted = False
# Highest sequence_number emitted so far; the terminal `error` event
# must be numbered after it to keep the stream monotonic for strict
# clients.
self._last_sequence_number = 0

def _extract_mcp_headers_from_params(self) -> None:
"""Extract MCP headers from original request params to pass to tool calls"""
from typing import Dict, Optional
Expand Down Expand Up @@ -380,10 +394,34 @@ def _should_auto_execute_tools(self) -> bool:

return LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(self.mcp_tools_with_litellm_proxy)

def _make_stream_error_event(self) -> ResponsesAPIStreamingResponse:
"""Build an OpenAI-style `error` stream event from the stashed internal
failure, so clients receive a real terminal error instead of a stream
that silently ends mid-flow."""
err = self._stream_error
status_code = getattr(err, "status_code", None)
return ErrorEvent(
type=ResponsesAPIStreamEvents.ERROR,
sequence_number=self._last_sequence_number + 1,
error=ErrorEventError(
type="mcp_gateway_error",
code=str(status_code) if status_code is not None else "internal_error",
message=str(err) if err is not None else "MCP gateway stream failed",
param=None,
),
)

def __aiter__(self):
return self

async def __anext__(self) -> ResponsesAPIStreamingResponse:
chunk = await self._anext_impl()
sequence_number = getattr(chunk, "sequence_number", None)
if isinstance(sequence_number, int) and sequence_number > self._last_sequence_number:
self._last_sequence_number = sequence_number
return chunk

async def _anext_impl(self) -> ResponsesAPIStreamingResponse:
"""
Phase-based streaming:
1. initial_response - Stream the first LLM response (includes response.created, response.in_progress, response.output_item.added)
Expand Down Expand Up @@ -438,6 +476,12 @@ async def __anext__(self) -> ResponsesAPIStreamingResponse:
self.phase = "continue_initial_response"
return await self.__anext__()
self.phase = "finished"
# Tool execution or the follow-up call failed: emit a terminal
# `error` event so the client can distinguish a failed stream
# from a completed one.
if self._stream_error is not None and not self._error_event_emitted:
self._error_event_emitted = True
return self._make_stream_error_event()
raise StopAsyncIteration

# Phase 6: Finished
Expand All @@ -460,13 +504,17 @@ async def _handle_initial_response_phase(
await self._create_initial_response_iterator()

if self.base_iterator is None:
# LLM call failed — still emit MCP discovery events before finishing
if self.mcp_discovery_events:
self.phase = "mcp_discovery"
else:
self.phase = "finished"
raise StopAsyncIteration
return None
# The initial LLM call failed. Do NOT emit MCP discovery events: a
# stream that starts with mcp_list_tools events and no
# response.created violates the Responses API streaming contract
# and crashes SDK stream accumulators (openai-node: "expected
# 'response.created' event, got response.mcp_list_tools.in_progress").
# Surface the failure as an `error` event instead.
self.phase = "finished"
if self._stream_error is not None:
self._error_event_emitted = True
return self._make_stream_error_event()
raise StopAsyncIteration

if self.base_iterator:
if hasattr(self.base_iterator, "__anext__"):
Expand Down Expand Up @@ -589,8 +637,11 @@ async def _create_initial_response_iterator(self) -> None:

traceback.print_exc()
self.base_iterator = None
# Don't set phase to "finished" here — let __anext__ emit any
# pre-generated MCP discovery events before ending the iteration.
# Stash the failure so aresponses_api_with_mcp can re-raise it
# before any SSE bytes are written (eager creation), or so
# __anext__ can emit an `error` event instead of ending silently.
self._initial_creation_error = e
self._stream_error = e

async def _generate_tool_execution_events(self) -> None:
"""Generate tool execution events and execute tools"""
Expand Down Expand Up @@ -705,9 +756,25 @@ async def _generate_tool_execution_events(self) -> None:
traceback.print_exc()
self.tool_results = []
self._tool_results_for_response = self.collected_response
# Drop the queued per-tool events: emitting mcp_call.in_progress
# items that never receive a completed/failed terminal event is a
# protocol deviation. The terminal `error` event carries the
# failure instead.
self.tool_execution_events = []
# Remember the failure. Without this, the follow-up call is made
# with function_call items but no function_call_output items and
# the provider rejects it with "No tool output found for function
# call ...".
self._stream_error = e

async def _create_follow_up_iterator(self) -> None:
"""Create the follow-up response iterator with tool results"""
# Tool execution already failed; skip the doomed follow-up call (it
# would be rejected with "No tool output found for function call ...")
# and let __anext__ emit the terminal error event.
if self._stream_error is not None:
self.base_iterator = None
return
if self.collected_response is None or self.collected_response is not self._tool_results_for_response:
# Either no response to follow up on, or the current round's
# response had no tool calls (self.tool_results is stale from an
Expand Down Expand Up @@ -768,6 +835,9 @@ async def _create_follow_up_iterator(self) -> None:

traceback.print_exc()
self.base_iterator = None
# Surface via a terminal `error` event in __anext__ instead of
# silently ending the stream with no terminal event.
self._stream_error = e

def __iter__(self):
return self
Expand Down
7 changes: 6 additions & 1 deletion tests/mcp_tests/test_aresponses_api_with_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
**{
Expand Down
145 changes: 145 additions & 0 deletions tests/test_litellm/responses/mcp/test_mcp_empty_resolved_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""
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
async def test_zero_resolved_mcp_tools_raises_before_model_call(monkeypatch):
# The guard runs before the stream/non-stream branch in
# aresponses_api_with_mcp, so one case covers both.
_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=False,
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()
Loading
Loading