From 424443c11fe9eeb025b8378415d698c0de37b1bd Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Mon, 13 Jul 2026 23:21:29 -0700 Subject: [PATCH] fix(mcp): search explicit exception links before __context__ when finding the upstream response --- .../mcp_server/faults/list_outcomes.py | 14 +++++++---- .../mcp_server/faults/test_list_outcomes.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py b/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py index c8cf0821428..4a189096e5c 100644 --- a/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py +++ b/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py @@ -63,7 +63,10 @@ class AggregateToolListing(NamedTuple): def _find_upstream_response(exc: BaseException) -> httpx.Response | None: """Walk the exception tree (``__cause__``/``__context__``/ExceptionGroup members) for an - ``httpx.Response``, mirroring how upstream failures surface through the MCP SDK's task groups.""" + ``httpx.Response``, mirroring how upstream failures surface through the MCP SDK's task groups. + Explicit links are searched first: each node's ``raise ... from`` cause, then group members in + raise order, then the incidental ``__context__`` chain, so a response raised while handling the + real failure can never shadow the response on the explicit causal chain.""" seen: set[int] = set() stack = [exc] while stack: @@ -74,12 +77,13 @@ def _find_upstream_response(exc: BaseException) -> httpx.Response | None: response = getattr(current, "response", None) if isinstance(response, httpx.Response): return response + if current.__context__ is not None: + stack.append(current.__context__) exceptions = getattr(current, "exceptions", None) if isinstance(exceptions, tuple): - stack.extend(exceptions) - for link in (current.__cause__, current.__context__): - if link is not None: - stack.append(link) + stack.extend(reversed(exceptions)) + if current.__cause__ is not None: + stack.append(current.__cause__) return None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py index 1a35c9cae2a..c531cd674bc 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py @@ -49,6 +49,31 @@ def test_embedded_401_classifies_auth_required(): assert classify_list_exception(exc).tag == "auth_required" +def test_context_response_does_not_shadow_the_causal_chain_response(): + real_response = httpx.Response(401, request=httpx.Request("POST", "https://mcp.example.com/mcp")) + real = httpx.HTTPStatusError("upstream rejected", request=real_response.request, response=real_response) + incidental_response = httpx.Response(500, request=httpx.Request("POST", "https://hooks.example.com/log")) + incidental = httpx.HTTPStatusError( + "logging hook failed", request=incidental_response.request, response=incidental_response + ) + wrapper = RuntimeError("wrapper") + wrapper.__cause__ = real + wrapper.__context__ = incidental + fault = classify_list_exception(wrapper) + assert fault.tag == "auth_required" + assert fault.status_code == 401 + + +def test_exception_group_members_are_searched_in_raise_order(): + first_response = httpx.Response(502, request=httpx.Request("POST", "https://mcp.example.com/mcp")) + first = httpx.HTTPStatusError("first", request=first_response.request, response=first_response) + second_response = httpx.Response(503, request=httpx.Request("POST", "https://mcp.example.com/mcp")) + second = httpx.HTTPStatusError("second", request=second_response.request, response=second_response) + fault = classify_list_exception(BaseExceptionGroup("task group", [first, second])) + assert fault.tag == "upstream_error" + assert fault.status_code == 502 + + def test_unknown_exception_is_internal(): assert classify_list_exception(ValueError("who knows")).tag == "internal"