From aa48016d913e1240978b53ccc007f9ecaa3c482a Mon Sep 17 00:00:00 2001 From: Thibault Serot Date: Thu, 9 Jul 2026 14:43:14 +1000 Subject: [PATCH 1/2] fix(responses): surface MCP gateway initial-call failures instead of emitting a broken stream When the initial LLM call inside MCPEnhancedStreamingIterator fails (e.g. an invalid previous_response_id -> provider 400 'No tool output found for function call ...'), the proxy returned HTTP 200 and the stream emitted the pre-generated mcp_list_tools discovery events with no response.created before them. That violates the Responses API streaming contract and crashes SDK stream accumulators (openai-node: "expected 'response.created' event, got response.mcp_list_tools.in_progress"). - aresponses_api_with_mcp now makes the initial call eagerly, before any SSE bytes are written, and re-raises the stashed failure so the client gets a real 4xx/5xx with the provider error body. - If a creation failure still surfaces during iteration, the stream emits a single terminal 'error' event instead of discovery events. Co-Authored-By: Claude Fable 5 --- litellm/proxy/dev_config.yaml | 5 + litellm/responses/main.py | 12 +- .../responses/mcp/mcp_streaming_iterator.py | 52 +++++-- .../mcp/test_mcp_streaming_iterator.py | 145 ++++++++++++++++++ 4 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py diff --git a/litellm/proxy/dev_config.yaml b/litellm/proxy/dev_config.yaml index 65a4b8e7cbf5..8ad7da6d21a9 100644 --- a/litellm/proxy/dev_config.yaml +++ b/litellm/proxy/dev_config.yaml @@ -211,3 +211,8 @@ litellm_settings: sandbox_tool_name: e2b_sandbox callbacks: - code_interpreter_interception + +mcp_servers: + deepwiki: + url: "https://mcp.deepwiki.com/mcp" + transport: "http" diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 8e3be2bc12de..0f9e3d86ece8 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -261,7 +261,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, @@ -272,6 +272,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( diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py index 3c24a703b686..02a978c9445e 100644 --- a/litellm/responses/mcp/mcp_streaming_iterator.py +++ b/litellm/responses/mcp/mcp_streaming_iterator.py @@ -5,6 +5,8 @@ from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator from litellm.types.llms.openai import ( BaseLiteLLMOpenAIResponseObject, + ErrorEvent, + ErrorEventError, MCPCallArgumentsDeltaEvent, MCPCallArgumentsDoneEvent, MCPCallCompletedEvent, @@ -304,6 +306,14 @@ def __init__( # Cache the response ID to ensure consistency across all events self._cached_response_id: Optional[str] = None + # Initial-LLM-call failures are stashed here so they can be surfaced + # to the client as an `error` stream event (lazy path) or re-raised + # before any SSE bytes are written (eager path in + # aresponses_api_with_mcp). + self._initial_creation_error: Optional[Exception] = None + self._stream_error: Optional[Exception] = None + self._error_event_emitted = False + 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 @@ -368,6 +378,23 @@ 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=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 @@ -451,13 +478,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__"): @@ -580,8 +611,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""" diff --git a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py new file mode 100644 index 000000000000..a9de6ebd4ddf --- /dev/null +++ b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py @@ -0,0 +1,145 @@ +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import litellm +from litellm.responses.mcp.mcp_streaming_iterator import MCPEnhancedStreamingIterator +from litellm.types.llms.openai import ResponsesAPIResponse, ResponsesAPIStreamEvents + +# `litellm.__init__` re-exports a function named `responses`, which shadows the +# `litellm.responses` subpackage as an attribute — `import litellm.responses.main` +# can resolve to the unrelated third-party `responses` package instead. Look the +# real submodule up in sys.modules directly to sidestep the shadowing. +responses_main_module = sys.modules["litellm.responses.main"] + + +class _FakeAsyncStream: + """Minimal async iterator yielding pre-built chunks, one per __anext__ call.""" + + def __init__(self, chunks): + self._chunks = list(chunks) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._chunks: + raise StopAsyncIteration + return self._chunks.pop(0) + + +def _completed_chunk(output): + response = ResponsesAPIResponse(id="resp-1", created_at=0, output=output) + return SimpleNamespace(type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=response) + + +def _text_message(text: str): + return {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]} + + +def _text_only_stream(text: str) -> _FakeAsyncStream: + return _FakeAsyncStream([_completed_chunk([_text_message(text)])]) + + +def _make_lazy_iterator(mcp_events=None) -> MCPEnhancedStreamingIterator: + """Iterator with no base_iterator: the initial LLM call happens lazily on iteration.""" + return MCPEnhancedStreamingIterator( + base_iterator=None, + mcp_events=list(mcp_events or []), + tool_server_map={"read_wiki_contents": "deepwiki"}, + mcp_tools_with_litellm_proxy=[{"require_approval": "never"}], + user_api_key_auth=None, + original_request_params={ + "model": "gpt-4", + "input": "what is berriai/litellm?", + "tools": [{"type": "mcp"}], + }, + ) + + +@pytest.mark.asyncio +async def test_initial_call_failure_emits_error_event_not_discovery_events(monkeypatch): + """ + Regression test: when the initial LLM call fails (e.g. an invalid + previous_response_id -> provider 400 "No tool output found for function + call ..."), the stream used to emit the pre-generated mcp_list_tools + discovery events with no response.created before them — which violates + the Responses API streaming contract and crashes SDK stream accumulators + (openai-node: "expected 'response.created' event, got + response.mcp_list_tools.in_progress"). The stream must instead surface a + single terminal `error` event and end. + """ + aresponses_mock = AsyncMock( + side_effect=litellm.BadRequestError( + message="No tool output found for function call call_x.", + model="gpt-4", + llm_provider="openai", + ) + ) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + discovery_event = SimpleNamespace(type=ResponsesAPIStreamEvents.MCP_LIST_TOOLS_IN_PROGRESS) + iterator = _make_lazy_iterator(mcp_events=[discovery_event]) + + chunks = [chunk async for chunk in iterator] + + assert len(chunks) == 1 + error_event = chunks[0] + assert error_event.type == ResponsesAPIStreamEvents.ERROR + assert error_event.error.code == "400" + assert "No tool output found" in error_event.error.message + # No discovery events leaked before/after the error. + assert discovery_event not in chunks + + +@pytest.mark.asyncio +async def test_eager_creation_reraises_pre_stream_failure_as_http_error(monkeypatch): + """ + aresponses_api_with_mcp creates the initial response eagerly and re-raises + 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 litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler + + monkeypatch.setattr( + LiteLLM_Proxy_MCP_Handler, + "_process_mcp_tools_without_openai_transform", + AsyncMock(return_value=([], {})), + ) + boom = litellm.BadRequestError( + message="Previous response with id 'resp_bogus' not found.", + model="gpt-4", + llm_provider="openai", + ) + monkeypatch.setattr(responses_main_module, "aresponses", AsyncMock(side_effect=boom)) + + with pytest.raises(litellm.BadRequestError) as excinfo: + await responses_main_module.aresponses_api_with_mcp( + input="hi", + model="gpt-4", + stream=True, + previous_response_id="resp_bogus", + tools=[{"type": "mcp", "server_url": "litellm_proxy/mcp/deepwiki", "require_approval": "never"}], + ) + + assert "resp_bogus" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_initial_call_success_does_not_emit_error_event(monkeypatch): + """Happy path is unchanged: no error event, stream flows as before.""" + monkeypatch.setattr( + responses_main_module, + "aresponses", + AsyncMock(return_value=_text_only_stream("all good")), + ) + + iterator = _make_lazy_iterator() + chunks = [chunk async for chunk in iterator] + + assert all(getattr(c, "type", None) != ResponsesAPIStreamEvents.ERROR for c in chunks) + completed = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED] + assert len(completed) == 1 + assert iterator._initial_creation_error is None From 70656be89e77330e3579a050b20eecebd30d3f59 Mon Sep 17 00:00:00 2001 From: Thibault Serot Date: Thu, 9 Jul 2026 14:44:36 +1000 Subject: [PATCH 2/2] fix(responses): emit terminal error event on MCP tool-execution / follow-up failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When tool execution failed as a batch, the stream proceeded to a follow-up call carrying function_call items with no outputs — rejected by the provider with 'No tool output found for function call ...' — and when the follow-up call itself failed, the stream simply ended with no terminal event. In both cases the client received HTTP 200 and a stream that looks like a truncated success: tool events, then silence. - Stash tool-execution and follow-up failures on the iterator. - Skip the doomed follow-up call entirely after a tool-execution failure. - Emit a single terminal OpenAI-style 'error' stream event carrying the mapped failure instead of ending silently. Builds on the initial-call failure handling from the previous commit (shares the _stream_error stash and _make_stream_error_event helper). Co-Authored-By: Claude Fable 5 --- .../responses/mcp/mcp_streaming_iterator.py | 27 +++- .../mcp/test_mcp_streaming_iterator.py | 127 ++++++++++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py index 02a978c9445e..dae7bcbd085f 100644 --- a/litellm/responses/mcp/mcp_streaming_iterator.py +++ b/litellm/responses/mcp/mcp_streaming_iterator.py @@ -306,10 +306,10 @@ def __init__( # Cache the response ID to ensure consistency across all events self._cached_response_id: Optional[str] = None - # Initial-LLM-call failures are stashed here so they can be surfaced - # to the client as an `error` stream event (lazy path) or re-raised - # before any SSE bytes are written (eager path in - # aresponses_api_with_mcp). + # 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 @@ -456,6 +456,12 @@ async def __anext__(self) -> ResponsesAPIStreamingResponse: raise else: 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 @@ -727,11 +733,21 @@ async def _generate_tool_execution_events(self) -> None: traceback.print_exc() self.tool_results = [] + # 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""" if not self.collected_response or not hasattr(self, "tool_results"): return + # 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: + return from litellm.responses.main import aresponses from litellm.responses.mcp.litellm_proxy_mcp_handler import ( @@ -772,6 +788,9 @@ async def _create_follow_up_iterator(self) -> None: traceback.print_exc() self.follow_up_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 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 a9de6ebd4ddf..d2e593b4896f 100644 --- a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py +++ b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py @@ -143,3 +143,130 @@ async def test_initial_call_success_does_not_emit_error_event(monkeypatch): completed = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED] assert len(completed) == 1 assert iterator._initial_creation_error is None + + +import types +from unittest.mock import MagicMock + +from mcp.types import CallToolResult, TextContent + + +def _output_item_added_chunk(): + return SimpleNamespace(type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED) + + +def _function_call(call_id: str, name: str, arguments: str = "{}"): + return {"type": "function_call", "call_id": call_id, "name": name, "arguments": arguments} + + +def _mock_mcp_environment(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: + """Patch the MCP tool-call plumbing so _execute_tool_calls can run in tests.""" + call_tool = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="ok")], isError=False)) + fake_manager = types.SimpleNamespace( + call_tool=call_tool, + _get_mcp_server_from_tool_name=MagicMock(return_value=None), + get_mcp_server_by_name=MagicMock(return_value=None), + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + fake_manager, + ) + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + types.SimpleNamespace(proxy_logging_obj=MagicMock()), + ) + return call_tool + + +def _make_tool_call_iterator() -> MCPEnhancedStreamingIterator: + return MCPEnhancedStreamingIterator( + base_iterator=_FakeAsyncStream( + [ + _output_item_added_chunk(), + _completed_chunk([_function_call("call_1", "read_wiki_contents")]), + ] + ), + mcp_events=[], + tool_server_map={"read_wiki_contents": "deepwiki"}, + mcp_tools_with_litellm_proxy=[{"require_approval": "never"}], + user_api_key_auth=None, + original_request_params={ + "model": "gpt-4", + "input": "what is berriai/litellm?", + "tools": [{"type": "mcp"}], + }, + ) + + +@pytest.mark.asyncio +async def test_tool_execution_failure_emits_error_event_and_skips_follow_up(monkeypatch): + """ + When tool execution blows up as a batch (not a per-tool error string), + the stream used to proceed to a follow-up call carrying function_call + items with no outputs — rejected by the provider with "No tool output + found for function call ..." — and then end silently. It must instead + skip the doomed follow-up and emit a terminal `error` event. + """ + from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler + + monkeypatch.setattr( + LiteLLM_Proxy_MCP_Handler, + "_execute_tool_calls", + AsyncMock(side_effect=RuntimeError("mcp server exploded")), + ) + aresponses_mock = AsyncMock() + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + iterator = _make_tool_call_iterator() + chunks = [chunk async for chunk in iterator] + + error_events = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.ERROR] + assert len(error_events) == 1 + assert "mcp server exploded" in error_events[0].error.message + # The doomed follow-up call was never made. + aresponses_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_follow_up_failure_emits_error_event(monkeypatch): + """ + When the follow-up LLM call after successful tool execution fails, the + stream used to end with no terminal event (the client saw tool events + and then... nothing). It must emit a terminal `error` event carrying the + mapped provider failure. + """ + _mock_mcp_environment(monkeypatch) + + boom = litellm.BadRequestError( + message="No tool output found for function call call_1.", + model="gpt-4", + llm_provider="openai", + ) + monkeypatch.setattr(responses_main_module, "aresponses", AsyncMock(side_effect=boom)) + + iterator = _make_tool_call_iterator() + chunks = [chunk async for chunk in iterator] + + error_events = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.ERROR] + assert len(error_events) == 1 + assert error_events[0].error.code == "400" + assert "No tool output found" in error_events[0].error.message + # Tool-execution events were still streamed before the error surfaced. + assert any(getattr(c, "type", None) == ResponsesAPIStreamEvents.MCP_CALL_COMPLETED for c in chunks) + + +@pytest.mark.asyncio +async def test_tool_call_happy_path_emits_no_error_event(monkeypatch): + """Regression guard: the tool-call success path must stay error-free.""" + _mock_mcp_environment(monkeypatch) + + aresponses_mock = AsyncMock(return_value=_text_only_stream("final answer")) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + iterator = _make_tool_call_iterator() + chunks = [chunk async for chunk in iterator] + + assert all(getattr(c, "type", None) != ResponsesAPIStreamEvents.ERROR for c in chunks) + completed = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED] + assert completed[-1].response.output[0]["content"][0]["text"] == "final answer"