Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/mcp_debug.py: 26%
266 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2MCP OAuth2 Debug Headers
3========================
5Client-side debugging for MCP authentication flows.
7When a client sends the ``x-litellm-mcp-debug: true`` header, LiteLLM
8returns masked diagnostic headers in the response so operators can
9troubleshoot OAuth2 issues without SSH access to the gateway.
11Response headers returned (all values are masked for safety):
13 x-mcp-debug-inbound-auth
14 Which inbound auth headers were present and how they were classified.
15 Example: ``x-litellm-api-key=Bearer sk-12****1234``
17 x-mcp-debug-oauth2-token
18 The OAuth2 token extracted from the Authorization header (masked).
19 Shows ``(none)`` if absent, or flags ``SAME_AS_LITELLM_KEY`` when
20 the LiteLLM API key is accidentally leaking to the MCP server.
22 x-mcp-debug-auth-resolution
23 Which auth priority was used for the outbound MCP call:
24 ``per-request-header``, ``m2m-client-credentials``, ``static-token``,
25 ``oauth2-passthrough``, ``stored-user-token``, ``token-exchange``,
26 ``id-jag``, ``aws-sigv4``, ``extra-headers``, or ``no-auth``.
27 ``unresolved`` means no outcome was available before the first response
28 frame; ``multiple`` means several servers resolved credentials;
29 ``not-applicable`` covers stdio; ``resolution-failed`` is a resolver error.
31 x-mcp-debug-auth-resolutions
32 For multiple servers, a JSON map of server IDs to resolution labels.
33 At most 32 entries are included; x-mcp-debug-auth-resolutions-truncated
34 is true when additional servers were omitted. No credentials are included.
36 x-mcp-debug-outbound-url
37 The upstream MCP server URL that will receive the request.
39 x-mcp-debug-server-auth-type
40 The ``auth_type`` configured on the MCP server (e.g. ``oauth2``,
41 ``bearer_token``, ``none``).
43Debugging Guide
44---------------
46**Common issue: LiteLLM API key leaking to the MCP server**
48Symptom: ``x-mcp-debug-oauth2-token`` shows ``SAME_AS_LITELLM_KEY``.
50This means the ``Authorization`` header carries the LiteLLM API key and
51it's being forwarded to the upstream MCP server instead of an OAuth2 token.
53Fix: Move the LiteLLM key to ``x-litellm-api-key`` so the ``Authorization``
54header is free for OAuth2 discovery::
56 # WRONG — blocks OAuth2 discovery
57 claude mcp add --transport http my_server http://proxy/mcp/server \\
58 --header "Authorization: Bearer sk-..."
60 # CORRECT — LiteLLM key in dedicated header, Authorization free for OAuth2
61 claude mcp add --transport http my_server http://proxy/mcp/server \\
62 --header "x-litellm-api-key: Bearer sk-..." \\
63 --header "x-litellm-mcp-debug: true"
65**Common issue: No OAuth2 token present**
67Symptom: ``x-mcp-debug-oauth2-token`` shows ``(none)`` and
68``x-mcp-debug-auth-resolution`` shows ``no-auth``.
70``no-auth`` means the resolved upstream client carries no authentication.
71An absent inbound OAuth2 token does not imply the user skipped OAuth: the gateway
72can retrieve a stored per-user token, reported as ``stored-user-token``.
73``unresolved`` is used when a stream starts before credential resolution, or a
74request (such as initialization or a cached tool listing) resolves no credential.
75Debug reporting does not fetch credentials or delay a streaming frame to resolve them.
76``extra-headers`` identifies supplied headers that won over the resolver or were
77the only headers supplied; their values are never inspected to guess a scheme.
78``per-request-header`` denotes a legacy credential override, including a BYOK
79credential supplied by the gateway; it does not imply a caller-supplied token.
81**Common issue: M2M token used instead of user token**
83Symptom: ``x-mcp-debug-auth-resolution`` shows ``m2m-client-credentials``.
85This means the server has ``client_id``/``client_secret``/``token_url``
86configured and LiteLLM is fetching a machine-to-machine token instead of
87using the per-user OAuth2 token. For gateway-stored per-user tokens,
88configure ``oauth2_flow: authorization_code``.
90Usage from Claude Code::
92 claude mcp add --transport http my_server http://proxy/mcp/server \\
93 --header "x-litellm-api-key: Bearer sk-..." \\
94 --header "x-litellm-mcp-debug: true"
96Usage with curl::
98 curl -H "x-litellm-mcp-debug: true" \\
99 -H "x-litellm-api-key: Bearer sk-..." \\
100 http://localhost:4000/mcp/atlassian_mcp
101"""
103from __future__ import annotations
105import asyncio
106import base64
107import io
108import json
109import re
110from collections.abc import AsyncIterator, Callable, Mapping
111from http.cookies import CookieError, SimpleCookie
112from itertools import islice
113from types import MappingProxyType
114from typing import TYPE_CHECKING, Final
115from urllib.parse import parse_qsl, quote, quote_plus, unquote_plus, urlencode
117import httpx
118import httpx2
119from pydantic import JsonValue, TypeAdapter
120from starlette.requests import HTTPConnection
121from starlette.types import Message, Send
123from litellm.litellm_core_utils.secret_redaction import REDACTED, redact_string
124from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
126if TYPE_CHECKING: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 from litellm.proxy._experimental.mcp_server.outbound_credentials.types import AuthResolution
129# Header the client sends to opt into debug mode
130MCP_DEBUG_REQUEST_HEADER: Final = "x-litellm-mcp-debug"
132# Prefix for all debug response headers
133_RESPONSE_HEADER_PREFIX: Final = "x-mcp-debug"
136MCP_AUTH_DIAGNOSTICS_SCOPE_KEY: Final = "litellm.mcp.auth_diagnostics"
139def record_auth_resolution(server_id: str, source: AuthResolution) -> None:
140 from litellm.proxy._experimental.mcp_server.mcp_context import get_active_mcp_request_ctx
142 context: Final[object] = get_active_mcp_request_ctx()
143 request: Final[object] = getattr(context, "request", None)
144 if isinstance(request, HTTPConnection): 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 diagnostics: Final[object] = request.scope.get(MCP_AUTH_DIAGNOSTICS_SCOPE_KEY)
146 if isinstance(diagnostics, MCPAuthDiagnostics):
147 diagnostics.record(server_id, source)
150class MCPAuthDiagnostics:
151 def __init__(self) -> None:
152 self._outcomes: tuple[tuple[str, AuthResolution], ...] = ()
154 def record(self, server_id: str, resolution: AuthResolution) -> None:
155 self._outcomes = tuple(item for item in self._outcomes if item[0] != server_id) + ((server_id, resolution),)
157 def resolution(self) -> str:
158 from litellm.proxy._experimental.mcp_server.outbound_credentials.types import AuthResolution
160 match self._outcomes:
161 case ():
162 return AuthResolution.unresolved.value
163 case ((_, source),):
164 return source.value
165 case _:
166 return AuthResolution.multiple.value
168 def headers(self) -> Mapping[str, str]:
169 from litellm.proxy._experimental.mcp_server.outbound_credentials.types import AuthResolution
171 if len(self._outcomes) <= 1:
172 return MappingProxyType({"x-mcp-debug-auth-resolution": self.resolution()})
173 return MappingProxyType(
174 {
175 "x-mcp-debug-auth-resolution": AuthResolution.multiple.value,
176 "x-mcp-debug-auth-resolutions": json.dumps(
177 {server_id: source.value for server_id, source in self._outcomes[:32]},
178 separators=(",", ":"),
179 ensure_ascii=True,
180 ),
181 **(
182 MappingProxyType({"x-mcp-debug-auth-resolutions-truncated": "true"})
183 if len(self._outcomes) > 32
184 else MappingProxyType({})
185 ),
186 }
187 )
190class _DiagnosticSend:
191 def __init__(self, send: Send, headers: Mapping[str, str], resolution: Callable[[], Mapping[str, str]]) -> None:
192 self._send = send
193 self._headers = headers
194 self._resolution = resolution
195 self._start: Message | None = None
197 async def __call__(self, message: Message) -> None:
198 if message["type"] == "http.response.start":
199 self._start = message
200 return
201 if self._start is not None:
202 start: Final = self._start
203 self._start = None
204 headers: Final = MappingProxyType({**self._headers, **self._resolution()})
205 await self._send(
206 { # mutable-ok: ASGI send consumes a mutable message mapping
207 **start,
208 "headers": tuple(start.get("headers", ()))
209 + tuple((key.encode(), value.encode()) for key, value in headers.items()),
210 }
211 )
212 await self._send(message)
215class MCPDebug:
216 """
217 Static helper class for MCP OAuth2 debug headers.
219 Provides opt-in client-side diagnostics by injecting masked
220 authentication info into HTTP response headers.
221 """
223 # Masker: show first 6 and last 4 chars so you can distinguish token types
224 # e.g. "Bearer****ef01" vs "sk-123****cdef"
225 _masker = SensitiveDataMasker(
226 sensitive_patterns={
227 "authorization",
228 "token",
229 "key",
230 "secret",
231 "auth",
232 "bearer",
233 },
234 visible_prefix=6,
235 visible_suffix=4,
236 )
238 @staticmethod
239 def _mask(value: str | None) -> str:
240 """Mask a single value for safe display in headers."""
241 return MCPDebug.mask_secret(value)
243 @staticmethod
244 def mask_secret(value: str | None) -> str:
245 if not value:
246 return "(none)"
247 return MCPDebug._masker._mask_value(value)
249 @staticmethod
250 def is_debug_enabled(headers: dict[str, str]) -> bool:
251 """
252 Check if the client opted into MCP debug mode.
254 Looks for ``x-litellm-mcp-debug: true`` (case-insensitive) in the
255 request headers.
256 """
257 for key, val in headers.items():
258 if key.lower() == MCP_DEBUG_REQUEST_HEADER: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 return val.strip().lower() in ("true", "1", "yes")
260 return False
262 @staticmethod
263 def build_debug_headers(
264 *,
265 inbound_headers: dict[str, str],
266 oauth2_headers: dict[str, str] | None,
267 litellm_api_key: str | None,
268 auth_resolution: str,
269 server_url: str | None,
270 server_auth_type: str | None,
271 ) -> dict[str, str]:
272 """
273 Build masked debug response headers.
275 Parameters
276 ----------
277 inbound_headers : dict
278 Raw headers received from the MCP client.
279 oauth2_headers : dict or None
280 Extracted OAuth2 headers (``{"Authorization": "Bearer ..."}``).
281 litellm_api_key : str or None
282 The LiteLLM API key extracted from ``x-litellm-api-key`` or
283 ``Authorization`` header.
284 auth_resolution : str
285 Which auth priority was selected for the outbound call.
286 server_url : str or None
287 Upstream MCP server URL.
288 server_auth_type : str or None
289 The ``auth_type`` configured on the server (e.g. ``oauth2``).
291 Returns
292 -------
293 dict
294 Headers to include in the response (all values masked).
295 """
296 debug: Final[dict[str, str]] = {}
298 # --- Inbound auth summary ---
299 inbound_parts: Final = []
300 for hdr_name in ("x-litellm-api-key", "authorization", "x-mcp-auth"):
301 for k, v in inbound_headers.items():
302 if k.lower() == hdr_name:
303 inbound_parts.append(f"{hdr_name}={MCPDebug._mask(v)}")
304 break
305 debug[f"{_RESPONSE_HEADER_PREFIX}-inbound-auth"] = "; ".join(inbound_parts) if inbound_parts else "(none)"
307 # --- OAuth2 token ---
308 oauth2_token: Final = (oauth2_headers or {}).get("Authorization")
309 if oauth2_token and litellm_api_key:
310 oauth2_raw: Final = oauth2_token.removeprefix("Bearer ").strip()
311 litellm_raw: Final = litellm_api_key.removeprefix("Bearer ").strip()
312 if oauth2_raw == litellm_raw:
313 debug[f"{_RESPONSE_HEADER_PREFIX}-oauth2-token"] = (
314 f"{MCPDebug._mask(oauth2_token)} (SAME_AS_LITELLM_KEY - likely misconfigured)"
315 )
316 else:
317 debug[f"{_RESPONSE_HEADER_PREFIX}-oauth2-token"] = MCPDebug._mask(oauth2_token)
318 else:
319 debug[f"{_RESPONSE_HEADER_PREFIX}-oauth2-token"] = MCPDebug._mask(oauth2_token)
321 # --- Auth resolution ---
322 debug[f"{_RESPONSE_HEADER_PREFIX}-auth-resolution"] = auth_resolution
324 # --- Server info ---
325 debug[f"{_RESPONSE_HEADER_PREFIX}-outbound-url"] = server_url or "(unknown)"
326 debug[f"{_RESPONSE_HEADER_PREFIX}-server-auth-type"] = server_auth_type or "(none)"
328 return debug
330 @staticmethod
331 def wrap_send_with_debug_headers(
332 send: Send,
333 debug_headers: Mapping[str, str],
334 resolution: Callable[[], Mapping[str, str]] | None = None,
335 *,
336 request_method: str | None = None,
337 ) -> Send:
338 """
339 Return a new ASGI ``send`` callable that injects *debug_headers*
340 into the ``http.response.start`` message.
341 """
343 if resolution is not None and request_method == "POST":
344 return _DiagnosticSend(send, debug_headers, resolution)
346 async def _send_with_debug(message: Message) -> None:
347 if message["type"] == "http.response.start":
348 headers: Final = list(message.get("headers", []))
349 for k, v in debug_headers.items():
350 headers.append((k.encode(), v.encode()))
351 message = {**message, "headers": headers}
352 await send(message)
354 return _send_with_debug
356 @staticmethod
357 def maybe_build_debug_headers(
358 *,
359 raw_headers: dict[str, str] | None,
360 scope: dict,
361 mcp_servers: list[str] | None,
362 oauth2_headers: dict[str, str] | None,
363 client_ip: str | None,
364 ) -> dict[str, str]:
365 """
366 Build debug headers if debug mode is enabled, otherwise return empty dict.
368 This is the single entry point called from the MCP request handler.
369 """
370 if not raw_headers or not MCPDebug.is_debug_enabled(raw_headers): 370 ↛ 373line 370 didn't jump to line 373 because the condition on line 370 was always true
371 return {}
373 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
374 MCPRequestHandler,
375 )
376 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
377 global_mcp_server_manager,
378 )
380 server_url: str | None = None
381 server_auth_type: str | None = None
382 from litellm.proxy._experimental.mcp_server.outbound_credentials.types import AuthResolution
384 auth_resolution: Final = AuthResolution.unresolved.value
386 for server_name in mcp_servers or []:
387 server = global_mcp_server_manager.get_mcp_server_by_name(server_name, client_ip=client_ip)
388 if server:
389 server_url = server.url
390 server_auth_type = server.auth_type
391 break
393 scope_headers: Final = MCPRequestHandler._safe_get_headers_from_scope(scope)
394 litellm_key: Final = MCPRequestHandler.get_litellm_api_key_from_headers(scope_headers)
396 return MCPDebug.build_debug_headers(
397 inbound_headers=raw_headers,
398 oauth2_headers=oauth2_headers,
399 litellm_api_key=litellm_key,
400 auth_resolution=auth_resolution,
401 server_url=server_url,
402 server_auth_type=server_auth_type,
403 )
406_BODY_PREVIEW_CHARS: Final = 512
407_BODY_CAPTURE_BYTES: Final = 16384
408_CAPTURE_TIMEOUT_SECONDS: Final = 1.0
409_CAPTURE_EXTENSION: Final = "litellm_mcp_error_preview"
410_SAFE_HEADER_NAMES: Final = frozenset({"content-type", "content-length", "accept"})
411_PUBLIC_HEADER_NAMES: Final = _SAFE_HEADER_NAMES | frozenset(("host", "user-agent", "accept-encoding", "connection"))
412_JSON_BODY: Final = TypeAdapter(JsonValue)
413_LOG_MASKER: Final = SensitiveDataMasker(visible_prefix=0, visible_suffix=0)
416def _safe_text(value: str, limit: int = _BODY_PREVIEW_CHARS) -> str:
417 escaped: Final = "".join(json.dumps(char)[1:-1] if ord(char) < 32 or ord(char) == 127 else char for char in value)
418 return escaped if len(escaped) <= limit else f"{escaped[:limit]}...(truncated)"
421def safe_upstream_url(url: httpx.URL | httpx2.URL) -> str:
422 return _safe_text(str(url.copy_with(username="", password="", path="/", query=None, fragment=None)))
425def _sensitive_field(key: str) -> bool:
426 normalized: Final = re.sub(r"[^a-z0-9]", "", key.casefold())
427 return normalized in ("code", "clientassertion") or any(
428 pattern in normalized for pattern in _LOG_MASKER.sensitive_patterns
429 )
432def _redact_object(
433 fields: Mapping[str, JsonValue],
434) -> dict[str, JsonValue]: # mutable-ok: the standard JSON encoder requires dict objects
435 return { # mutable-ok: construct the JSON object once for the standard parser and encoder
436 key: REDACTED if _sensitive_field(key) else value for key, value in fields.items()
437 }
440def _header_secret_values(name: str, value: str) -> tuple[str, ...]:
441 if name == "cookie":
442 cookie: Final = SimpleCookie[str]()
443 try:
444 cookie.load(value)
445 except CookieError:
446 return (value,)
447 return (value, *(item.value for item in cookie.values()))
448 if name not in ("authorization", "proxy-authorization"):
449 return (value,)
450 scheme, _, credential = value.partition(" ")
451 if scheme.lower() != "basic":
452 return (value, credential)
453 try:
454 decoded: Final = base64.b64decode(credential, validate=True).decode("utf-8")
455 except ValueError:
456 return (value, credential)
457 password: Final = decoded.partition(":")[2]
458 return (value, credential, decoded, password, unquote_plus(password))
461def _body_secret_values(request: httpx.Request | httpx2.Request) -> tuple[str, ...] | None:
462 try:
463 raw: Final = request.content
464 except (httpx.RequestNotRead, httpx2.RequestNotRead):
465 return None
466 if not raw:
467 return ()
468 if len(raw) > _BODY_CAPTURE_BYTES:
469 return None
470 if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() == "application/x-www-form-urlencoded":
471 return tuple(value for key, value in parse_qsl(raw.decode("utf-8", errors="replace")) if _sensitive_field(key))
472 try:
473 body: Final = _JSON_BODY.validate_json(raw)
474 except ValueError:
475 return None
476 from litellm.proxy._experimental.mcp_server.utils import ( # noqa: PLC0415 # MCP utils imports clients; inspect bodies only after initialization
477 json_string_leaves,
478 )
480 leaves: Final = json_string_leaves(body)
481 if leaves is None:
482 return None
483 return tuple(
484 value
485 for path, value in leaves
486 if not path or any(isinstance(part, str) and _sensitive_field(part) for part in path)
487 )
490def _request_secret_values(request: httpx.Request | httpx2.Request) -> tuple[str, ...] | None:
491 body_values: Final = _body_secret_values(request)
492 if body_values is None:
493 return None
494 values: Final = (
495 *body_values,
496 request.url.password,
497 *(value for _, value in request.url.params.multi_items()),
498 *(
499 secret
500 for name, value in request.headers.items()
501 if name not in _PUBLIC_HEADER_NAMES
502 for secret in _header_secret_values(name, value)
503 ),
504 )
505 return tuple(sorted(frozenset(value for value in values if value), key=len, reverse=True))
508def _mask_known_values(value: str, secrets: tuple[str, ...]) -> str:
509 variants: Final = tuple(
510 sorted(
511 frozenset(
512 variant
513 for secret in secrets
514 for variant in (secret, json.dumps(secret)[1:-1], quote(secret, safe=""), quote_plus(secret))
515 ),
516 key=len,
517 reverse=True,
518 )
519 )
520 return re.sub("|".join(re.escape(secret) for secret in variants), REDACTED, value) if variants else value
523def _preview(raw: bytes, content_type: str = "", secrets: tuple[str, ...] = ()) -> str:
524 if not raw:
525 return "(empty)"
526 if len(raw) > _BODY_CAPTURE_BYTES:
527 return "(omitted: body exceeds capture limit)"
528 try:
529 parsed: Final = _JSON_BODY.validate_python(json.loads(raw, object_hook=_redact_object))
530 except (ValueError, RecursionError):
531 text: Final = raw.decode("utf-8", errors="replace")
532 if (
533 content_type.split(";", 1)[0].strip().lower() != "application/x-www-form-urlencoded"
534 or "=" not in text
535 or any(char in text for char in "<>\n\r")
536 ):
537 return "(omitted: unstructured body)"
538 fields: Final = parse_qsl(text, keep_blank_values=True)
539 return _safe_text(
540 _mask_known_values(
541 urlencode(tuple((key, REDACTED if _sensitive_field(key) else value) for key, value in fields)), secrets
542 )
543 )
544 if not isinstance(parsed, (dict, list)):
545 return "(omitted: unstructured body)"
546 return _safe_text(redact_string(_mask_known_values(json.dumps(parsed, separators=(",", ":")), secrets)))
549def _masked_headers(headers: httpx.Headers | httpx2.Headers) -> str:
550 return _safe_text(", ".join(f"{name}={value}" for name, value in headers.items() if name in _SAFE_HEADER_NAMES))
553def _request_body_preview(request: httpx.Request | httpx2.Request, secrets: tuple[str, ...] | None) -> str:
554 try:
555 return _preview(request.content, request.headers.get("content-type", ""), secrets or ())
556 except (httpx.RequestNotRead, httpx2.RequestNotRead):
557 return "(streamed, not captured)"
560def _response_body_preview(response: httpx.Response | httpx2.Response, secrets: tuple[str, ...] | None) -> str:
561 if secrets is None:
562 return "(omitted: request credentials unavailable)"
563 captured: Final = response.extensions.get(_CAPTURE_EXTENSION)
564 if isinstance(captured, str):
565 return captured
566 try:
567 return _preview(response.content, response.headers.get("content-type", ""), secrets)
568 except (httpx.ResponseNotRead, httpx2.ResponseNotRead):
569 return "(not read)"
572async def _read_error_prefix(chunks: AsyncIterator[bytes], limit: int) -> bytes:
573 buffer: Final = io.BytesIO()
574 async for chunk in chunks:
575 buffer.write(chunk[: limit - buffer.tell()])
576 if buffer.tell() >= limit:
577 break
578 return buffer.getvalue()
581async def capture_upstream_error_response(response: httpx.Response | httpx2.Response) -> None:
582 if not response.is_error: 582 ↛ 584line 582 didn't jump to line 584 because the condition on line 582 was always true
583 return
584 try:
585 prefix: Final = await asyncio.wait_for(
586 _read_error_prefix(response.aiter_bytes(chunk_size=4096), _BODY_CAPTURE_BYTES + 1),
587 timeout=_CAPTURE_TIMEOUT_SECONDS,
588 )
589 response._content = prefix # pyright: ignore[reportPrivateUsage] # rebind-ok: httpx has no public setter to retain consumed bytes for auth retries
590 secrets: Final = _request_secret_values(response.request)
591 preview: Final = (
592 _preview(prefix, response.headers.get("content-type", ""), secrets)
593 if secrets is not None
594 else "(omitted: request credentials unavailable)"
595 )
596 except (asyncio.TimeoutError, httpx.HTTPError, httpx.StreamError, httpx2.HTTPError, httpx2.StreamError):
597 response._content = b"" # pyright: ignore[reportPrivateUsage] # rebind-ok: httpx auth retries must survive diagnostic read failures
598 response.extensions[_CAPTURE_EXTENSION] = "(unavailable: error body read failed)"
599 return
600 response.extensions[_CAPTURE_EXTENSION] = preview # rebind-ok: httpx response hooks communicate through extensions
603def describe_upstream_response(response: httpx.Response | httpx2.Response) -> str:
604 try:
605 request: Final = response.request
606 except RuntimeError:
607 return f"HTTP {response.status_code} | request unavailable"
608 secrets: Final = _request_secret_values(request)
609 return (
610 f"{_safe_text(request.method)} {safe_upstream_url(request.url)} -> HTTP {response.status_code}"
611 f" | request headers: {_masked_headers(request.headers)}"
612 f" | request body: {_request_body_preview(request, secrets)}"
613 f" | response body: {_response_body_preview(response, secrets)}"
614 )
617def describe_upstream_http_failure(exc: BaseException) -> str | None:
618 from litellm.proxy._experimental.mcp_server.faults.traversal import ( # noqa: PLC0415 # fault package initialization imports the credential resolver
619 iter_exception_tree,
620 )
622 lines: Final = tuple(
623 describe_upstream_response(response)
624 for current in islice(iter_exception_tree(exc), 16)
625 for response in (getattr(current, "response", None),)
626 if isinstance(response, (httpx.Response, httpx2.Response))
627 )
628 return " | ".join(lines) or None