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

1""" 

2MCP OAuth2 Debug Headers 

3======================== 

4 

5Client-side debugging for MCP authentication flows. 

6 

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. 

10 

11Response headers returned (all values are masked for safety): 

12 

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`` 

16 

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. 

21 

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. 

30 

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. 

35 

36 x-mcp-debug-outbound-url 

37 The upstream MCP server URL that will receive the request. 

38 

39 x-mcp-debug-server-auth-type 

40 The ``auth_type`` configured on the MCP server (e.g. ``oauth2``, 

41 ``bearer_token``, ``none``). 

42 

43Debugging Guide 

44--------------- 

45 

46**Common issue: LiteLLM API key leaking to the MCP server** 

47 

48Symptom: ``x-mcp-debug-oauth2-token`` shows ``SAME_AS_LITELLM_KEY``. 

49 

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. 

52 

53Fix: Move the LiteLLM key to ``x-litellm-api-key`` so the ``Authorization`` 

54header is free for OAuth2 discovery:: 

55 

56 # WRONG — blocks OAuth2 discovery 

57 claude mcp add --transport http my_server http://proxy/mcp/server \\ 

58 --header "Authorization: Bearer sk-..." 

59 

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" 

64 

65**Common issue: No OAuth2 token present** 

66 

67Symptom: ``x-mcp-debug-oauth2-token`` shows ``(none)`` and 

68``x-mcp-debug-auth-resolution`` shows ``no-auth``. 

69 

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. 

80 

81**Common issue: M2M token used instead of user token** 

82 

83Symptom: ``x-mcp-debug-auth-resolution`` shows ``m2m-client-credentials``. 

84 

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``. 

89 

90Usage from Claude Code:: 

91 

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" 

95 

96Usage with curl:: 

97 

98 curl -H "x-litellm-mcp-debug: true" \\ 

99 -H "x-litellm-api-key: Bearer sk-..." \\ 

100 http://localhost:4000/mcp/atlassian_mcp 

101""" 

102 

103from __future__ import annotations 

104 

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 

116 

117import httpx 

118import httpx2 

119from pydantic import JsonValue, TypeAdapter 

120from starlette.requests import HTTPConnection 

121from starlette.types import Message, Send 

122 

123from litellm.litellm_core_utils.secret_redaction import REDACTED, redact_string 

124from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker 

125 

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 

128 

129# Header the client sends to opt into debug mode 

130MCP_DEBUG_REQUEST_HEADER: Final = "x-litellm-mcp-debug" 

131 

132# Prefix for all debug response headers 

133_RESPONSE_HEADER_PREFIX: Final = "x-mcp-debug" 

134 

135 

136MCP_AUTH_DIAGNOSTICS_SCOPE_KEY: Final = "litellm.mcp.auth_diagnostics" 

137 

138 

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 

141 

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) 

148 

149 

150class MCPAuthDiagnostics: 

151 def __init__(self) -> None: 

152 self._outcomes: tuple[tuple[str, AuthResolution], ...] = () 

153 

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),) 

156 

157 def resolution(self) -> str: 

158 from litellm.proxy._experimental.mcp_server.outbound_credentials.types import AuthResolution 

159 

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 

167 

168 def headers(self) -> Mapping[str, str]: 

169 from litellm.proxy._experimental.mcp_server.outbound_credentials.types import AuthResolution 

170 

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 ) 

188 

189 

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 

196 

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) 

213 

214 

215class MCPDebug: 

216 """ 

217 Static helper class for MCP OAuth2 debug headers. 

218 

219 Provides opt-in client-side diagnostics by injecting masked 

220 authentication info into HTTP response headers. 

221 """ 

222 

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 ) 

237 

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) 

242 

243 @staticmethod 

244 def mask_secret(value: str | None) -> str: 

245 if not value: 

246 return "(none)" 

247 return MCPDebug._masker._mask_value(value) 

248 

249 @staticmethod 

250 def is_debug_enabled(headers: dict[str, str]) -> bool: 

251 """ 

252 Check if the client opted into MCP debug mode. 

253 

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 

261 

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. 

274 

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``). 

290 

291 Returns 

292 ------- 

293 dict 

294 Headers to include in the response (all values masked). 

295 """ 

296 debug: Final[dict[str, str]] = {} 

297 

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)" 

306 

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) 

320 

321 # --- Auth resolution --- 

322 debug[f"{_RESPONSE_HEADER_PREFIX}-auth-resolution"] = auth_resolution 

323 

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)" 

327 

328 return debug 

329 

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 """ 

342 

343 if resolution is not None and request_method == "POST": 

344 return _DiagnosticSend(send, debug_headers, resolution) 

345 

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) 

353 

354 return _send_with_debug 

355 

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. 

367 

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 {} 

372 

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 ) 

379 

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 

383 

384 auth_resolution: Final = AuthResolution.unresolved.value 

385 

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 

392 

393 scope_headers: Final = MCPRequestHandler._safe_get_headers_from_scope(scope) 

394 litellm_key: Final = MCPRequestHandler.get_litellm_api_key_from_headers(scope_headers) 

395 

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 ) 

404 

405 

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) 

414 

415 

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)" 

419 

420 

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))) 

423 

424 

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 ) 

430 

431 

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 } 

438 

439 

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)) 

459 

460 

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 ) 

479 

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 ) 

488 

489 

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)) 

506 

507 

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 

521 

522 

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))) 

547 

548 

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)) 

551 

552 

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)" 

558 

559 

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)" 

570 

571 

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() 

579 

580 

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 

601 

602 

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 ) 

615 

616 

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 ) 

621 

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