refactor(mcp): port MCP client and server helpers to MCP SDK 2

McpError -> MCPError (new code/message/data constructor), camelCase model
attributes and constructor kwargs -> snake_case, RequestResponder ->
ClientSession message handler receiving ServerNotification | Exception,
RequestContext -> ClientRequestContext, read_timeout_seconds -> float,
server_capabilities property, JSONRPCMessage union parsed via TypeAdapter,
and httpx -> httpx2 for every object handed to the SDK transports
(MCPSigV4Auth, the httpx client factory, outbound_credentials auth
classes and resolver return types). Helpers that serve both litellm httpx
clients and the SDK's httpx2 transport accept both response types.

The SDK read-timeout code is now the JSON-RPC REQUEST_TIMEOUT (-32001)
instead of HTTP 408; as_mcp_read_timeout keeps the TimeoutError context
discriminator. Upstream transport exceptions and responses found in
exception trees are matched as httpx2 alongside httpx.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
joshua 2026-09-18 22:13:04 +00:00
parent 4bc3f1d0fc
commit 5dc01319d7
19 changed files with 201 additions and 221 deletions

View file

@ -9,19 +9,17 @@ import json
import os
from collections.abc import Awaitable, Callable, Generator
from contextlib import AbstractAsyncContextManager
from datetime import timedelta
from functools import partial
from importlib import metadata
from types import MappingProxyType
from typing import Any, Final, Protocol, TypeAlias, TypeVar
from typing import Any, Final, TypeAlias, TypeVar
import httpx
import httpx2
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp import ClientSession, McpError, ReadResourceResult, Resource, StdioServerParameters
from mcp import ClientSession, MCPError, ReadResourceResult, Resource, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from typing_extensions import Unpack
_TransportStreams: TypeAlias = tuple[
@ -32,34 +30,9 @@ _TransportStreams: TypeAlias = tuple[
_TransportContext: TypeAlias = AbstractAsyncContextManager[_TransportStreams]
class _StreamableHttpClientFactory(Protocol):
"""The ``streamable_http_client`` entry point this module calls on the installed MCP SDK."""
def __call__(self, *, url: str, http_client: httpx.AsyncClient | None) -> _TransportContext: ...
streamable_http_client: _StreamableHttpClientFactory | None = None
try:
import mcp.client.streamable_http as streamable_http_module
streamable_http_client = getattr(streamable_http_module, "streamable_http_client", None)
except ImportError:
pass
MCP_STREAMABLE_HTTP_REQUIREMENT: Final = "mcp>=1.28.1"
def missing_streamable_http_client_error() -> ImportError:
return ImportError(
f"MCP streamable HTTP transport requires {MCP_STREAMABLE_HTTP_REQUIREMENT}, but the installed "
f"mcp {metadata.version('mcp')} does not provide streamable_http_client. "
"Fix with: pip install 'litellm[mcp]' (or upgrade mcp directly: pip install -U mcp)"
)
from mcp.types import (
METHOD_NOT_FOUND,
ClientResult,
REQUEST_TIMEOUT,
GetPromptRequestParams,
GetPromptResult,
ListPromptsResult,
@ -68,7 +41,6 @@ from mcp.types import (
Prompt,
ResourceTemplate,
ServerNotification,
ServerRequest,
TextContent,
)
from mcp.types import CallToolRequestParams as MCPCallToolRequestParams
@ -153,23 +125,21 @@ def _first_non_cancelled_cause(exc: BaseException) -> BaseException | None:
return None
_SDK_READ_TIMEOUT_CODE: Final = int(httpx.codes.REQUEST_TIMEOUT)
"""The code the MCP SDK puts on its own elapsed read timeout, an HTTP status in a field that
otherwise carries JSON-RPC error codes."""
_SDK_READ_TIMEOUT_CODE: Final = REQUEST_TIMEOUT
"""The code the MCP SDK puts on its own elapsed read timeout."""
def as_mcp_read_timeout(exc: BaseException) -> TimeoutError | None:
"""Normalize an MCP SDK read timeout for client and gateway diagnostics, or return ``None``.
The SDK reports its own elapsed read timeout as ``McpError`` carrying an HTTP status code in a
field that otherwise holds JSON-RPC error codes, and it relays an upstream's JSON-RPC error
through that same class and field. The numeric code alone therefore cannot separate the two, and
an upstream answering with application code 408 would be reported as a gateway timeout it never
caused. The SDK raises its own from inside an ``except TimeoutError``, so the elapsed timeout is
The SDK reports its own elapsed read timeout as ``MCPError`` carrying ``REQUEST_TIMEOUT`` in a
field that also carries relayed upstream JSON-RPC errors. The numeric code alone therefore
cannot separate the two, and an upstream answering with the same application code would be
reported as a gateway timeout it never caused. The SDK raises its own from inside an ``except TimeoutError``, so the elapsed timeout is
on the context chain, while a relayed error is built from a received message and has no such
chain; that is the discriminator.
"""
if not isinstance(exc, McpError) or exc.error.code != _SDK_READ_TIMEOUT_CODE:
if not isinstance(exc, MCPError) or exc.error.code != _SDK_READ_TIMEOUT_CODE:
return None
if not isinstance(exc.__context__, TimeoutError):
return None
@ -179,9 +149,9 @@ def as_mcp_read_timeout(exc: BaseException) -> TimeoutError | None:
TSessionResult = TypeVar("TSessionResult")
class MCPSigV4Auth(httpx.Auth):
class MCPSigV4Auth(httpx2.Auth):
"""
httpx Auth class that signs each request with AWS SigV4.
httpx2 Auth class that signs each request with AWS SigV4.
This is used for MCP servers that require AWS SigV4 authentication,
such as AWS Bedrock AgentCore MCP servers. httpx calls auth_flow()
for every outgoing request, enabling per-request signature computation.
@ -270,7 +240,7 @@ class MCPSigV4Auth(httpx.Auth):
token=sts_creds["SessionToken"],
)
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]:
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
@ -314,8 +284,8 @@ class MCPClient:
stdio_config: MCPStdioConfig | None = None,
extra_headers: dict[str, str] | None = None,
ssl_verify: VerifyTypes | None = None,
aws_auth: httpx.Auth | None = None,
resolved_auth: httpx.Auth | None = None,
aws_auth: httpx2.Auth | None = None,
resolved_auth: httpx2.Auth | None = None,
sampling_callback: Callable | None = None,
elicitation_callback: Callable | None = None,
logging_callback: Callable | None = None,
@ -333,10 +303,10 @@ class MCPClient:
self.stdio_config: MCPStdioConfig | None = stdio_config
self.extra_headers: dict[str, str] | None = extra_headers
self.ssl_verify: VerifyTypes | None = ssl_verify
self._aws_auth: httpx.Auth | None = aws_auth
# A pre-resolved httpx.Auth (e.g. from the v2 credential resolver) attached to the
self._aws_auth: httpx2.Auth | None = aws_auth
# A pre-resolved httpx2.Auth (e.g. from the v2 credential resolver) attached to the
# upstream client's auth= slot, taking precedence over the SigV4 aws_auth.
self._resolved_auth: httpx.Auth | None = resolved_auth
self._resolved_auth: httpx2.Auth | None = resolved_auth
self._last_initialize_instructions: str | None = None
self._sampling_callback: Callable | None = sampling_callback
self._elicitation_callback: Callable | None = elicitation_callback
@ -348,9 +318,9 @@ class MCPClient:
async def discovery_auth_fingerprint(self) -> str:
return self._hash_discovery_auth(await self.prepare_request_auth())
async def prepare_request_auth(self) -> httpx.Request:
async def prepare_request_auth(self) -> httpx2.Request:
"""Preview the authenticated request without sending it, closing the auth flow afterwards."""
request: Final = httpx.Request("POST", self.server_url or "http://localhost/", headers=self._get_auth_headers())
request: Final = httpx2.Request("POST", self.server_url or "http://localhost/", headers=self._get_auth_headers())
if self._resolved_auth is None:
return request
flow: Final = self._resolved_auth.async_auth_flow(request)
@ -361,20 +331,20 @@ class MCPClient:
await flow.aclose()
@staticmethod
def _hash_discovery_auth(request: httpx.Request) -> str:
def _hash_discovery_auth(request: httpx2.Request) -> str:
material: Final = json.dumps((str(request.url), tuple(sorted(request.headers.multi_items()))))
return hashlib.sha256(material.encode()).hexdigest()
def _create_transport_context(
self,
) -> tuple[_TransportContext, httpx.AsyncClient | None]:
) -> tuple[_TransportContext, httpx2.AsyncClient | None]:
"""
Create the appropriate transport context based on transport type.
Returns:
Tuple of (transport_context, http_client).
http_client is only set for HTTP transport and needs cleanup.
"""
http_client: httpx.AsyncClient | None = None
http_client: httpx2.AsyncClient | None = None
if self.transport_type == MCPTransport.stdio:
if not self.stdio_config:
raise ValueError("stdio_config is required for stdio transport")
@ -397,14 +367,12 @@ class MCPClient:
None,
)
# HTTP transport (default)
if streamable_http_client is None:
raise missing_streamable_http_client_error()
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
verbose_logger.debug("litellm headers for streamable_http_client: %s", headers)
http_client = httpx_client_factory(
headers=headers,
timeout=httpx.Timeout(self.timeout),
timeout=httpx2.Timeout(self.timeout),
)
transport_ctx: Final = streamable_http_client(
url=self.server_url,
@ -477,9 +445,9 @@ class MCPClient:
stream_error: Final[asyncio.Future[Exception]] = asyncio.get_running_loop().create_future()
async def receive_message(
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
message: ServerNotification | Exception,
) -> None:
if not isinstance(message, (ValueError, httpx.RequestError, OSError)):
if not isinstance(message, (ValueError, httpx2.RequestError, OSError)):
return
if not stream_error.done():
stream_error.set_result(message)
@ -499,7 +467,7 @@ class MCPClient:
session_ctx: Final = ClientSession(
read_stream,
write_stream,
read_timeout_seconds=timedelta(seconds=self.timeout),
read_timeout_seconds=self.timeout,
message_handler=receive_message,
**session_kwargs,
)
@ -512,7 +480,7 @@ class MCPClient:
if isinstance(ins, str) and ins.strip():
self._last_initialize_instructions = ins.strip()
return await operation(session)
except McpError:
except MCPError:
if stream_error.done():
raise stream_error.result()
raise
@ -544,7 +512,7 @@ class MCPClient:
quiet_on_error demotes the failure line to debug for callers that own the exception
(call_tool / list_tools under raise_on_error), so an expected pass-through re-auth does
not emit a warning per call; every other caller keeps the operator-visible warning."""
http_client: httpx.AsyncClient | None = None
http_client: httpx2.AsyncClient | None = None
try:
self._last_initialize_instructions = None
transport_ctx, http_client = self._create_transport_context()
@ -609,7 +577,7 @@ class MCPClient:
elif isinstance(self._mcp_auth_value, dict):
headers.update(self._mcp_auth_value)
# Note: aws_sigv4 auth is not handled here — SigV4 requires per-request
# signing (including the body hash), so it uses httpx.Auth flow instead
# signing (including the body hash), so it uses httpx2.Auth flow instead
# of static headers. See MCPSigV4Auth and _create_httpx_client_factory().
# update the headers with the extra headers
if self.extra_headers:
@ -623,9 +591,9 @@ class MCPClient:
headers.update(injected or {})
return _strip_header_whitespace(headers)
def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]:
def _create_httpx_client_factory(self) -> Callable[..., httpx2.AsyncClient]:
"""
Create a custom httpx client factory that uses LiteLLM's SSL configuration.
Create a custom httpx2 client factory that uses LiteLLM's SSL configuration.
This factory follows the same CA bundle path logic as http_handler.py:
1. Check ssl_verify parameter (can be SSLContext, bool, or path to CA bundle)
2. Check SSL_VERIFY environment variable
@ -636,10 +604,10 @@ class MCPClient:
def factory(
*,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
"""Create an httpx.AsyncClient with LiteLLM's SSL configuration."""
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
"""Create an httpx2.AsyncClient with LiteLLM's SSL configuration."""
# Get unified SSL configuration using the same logic as http_handler.py
ssl_config: Final = get_ssl_configuration(self.ssl_verify)
verbose_logger.debug("MCP client using SSL configuration: %s", type(ssl_config).__name__)
@ -649,7 +617,7 @@ class MCPClient:
fallback_auth: Final = self._resolved_auth if self._resolved_auth is not None else self._aws_auth
effective_auth: Final = auth if auth is not None else fallback_auth
guard: Final = credential_redirect_hook(self.server_url, self._credential_slot)
return httpx.AsyncClient(
return httpx2.AsyncClient(
headers=headers,
timeout=timeout,
auth=effective_auth,
@ -723,7 +691,7 @@ class MCPClient:
"""The error result ``call_tool`` returns when it swallows a failure (no re-execution)."""
return MCPCallToolResult(
content=[TextContent(type="text", text=f"{type(exc).__name__}: {exc}")],
isError=True,
is_error=True,
)
async def call_tool(
@ -808,12 +776,12 @@ class MCPClient:
verbose_logger.debug("MCP client listing tools from %s", self.server_url or "stdio")
async def _list_prompts_operation(session: ClientSession) -> ListPromptsResult:
capabilities: Final = session.get_server_capabilities()
capabilities: Final = session.server_capabilities
if capabilities is not None and capabilities.prompts is None:
return ListPromptsResult(prompts=[])
try:
return await session.list_prompts()
except McpError as error:
except MCPError as error:
if error.error.code != METHOD_NOT_FOUND:
raise
verbose_logger.debug(
@ -898,12 +866,12 @@ class MCPClient:
verbose_logger.debug("MCP client listing resources from %s", self.server_url or "stdio")
async def _list_resources_operation(session: ClientSession) -> ListResourcesResult:
capabilities: Final = session.get_server_capabilities()
capabilities: Final = session.server_capabilities
if capabilities is not None and capabilities.resources is None:
return ListResourcesResult(resources=[])
try:
return await session.list_resources()
except McpError as error:
except MCPError as error:
if error.error.code != METHOD_NOT_FOUND:
raise
verbose_logger.debug(
@ -947,30 +915,30 @@ class MCPClient:
verbose_logger.debug("MCP client listing resource templates from %s", self.server_url or "stdio")
async def _list_resource_templates_operation(session: ClientSession) -> ListResourceTemplatesResult:
capabilities: Final = session.get_server_capabilities()
capabilities: Final = session.server_capabilities
if capabilities is not None and capabilities.resources is None:
return ListResourceTemplatesResult(resourceTemplates=[])
return ListResourceTemplatesResult(resource_templates=[])
try:
return await session.list_resource_templates()
except McpError as error:
except MCPError as error:
if error.error.code != METHOD_NOT_FOUND:
raise
verbose_logger.debug(
"MCP client list_resource_templates is unsupported by %s: %s", self.server_url or "stdio", error
)
return ListResourceTemplatesResult(resourceTemplates=[])
return ListResourceTemplatesResult(resource_templates=[])
try:
result: Final = await self.run_with_session(_list_resource_templates_operation)
resource_template_count: Final = len(result.resourceTemplates)
resource_template_names: Final = [resourceTemplate.name for resourceTemplate in result.resourceTemplates]
resource_template_count: Final = len(result.resource_templates)
resource_template_names: Final = [resource_template.name for resource_template in result.resource_templates]
verbose_logger.info(
"MCP client listed %s resource templates from %s: %s",
resource_template_count,
self.server_url or "stdio",
resource_template_names,
)
return result.resourceTemplates
return result.resource_templates
except asyncio.CancelledError:
verbose_logger.warning("MCP client list_resource_templates was cancelled")
raise
@ -1000,7 +968,7 @@ class MCPClient:
async def _read_resource_operation(session: ClientSession):
verbose_logger.debug("MCP client sending read_resource request to session")
return await session.read_resource(url)
return await session.read_resource(str(url))
try:
read_resource_result: Final = await self.run_with_session(_read_resource_operation)

View file

@ -26,7 +26,7 @@ from litellm.types.utils import ChatCompletionMessageToolCall
########################################################
def transform_mcp_tool_to_openai_tool(mcp_tool: MCPTool) -> ChatCompletionToolParam:
"""Convert an MCP tool to an OpenAI tool."""
normalized_parameters: Final = _normalize_mcp_input_schema(mcp_tool.inputSchema)
normalized_parameters: Final = _normalize_mcp_input_schema(mcp_tool.input_schema)
return ChatCompletionToolParam(
type="function",
@ -73,7 +73,7 @@ def transform_mcp_tool_to_openai_responses_api_tool(
mcp_tool: MCPTool,
) -> FunctionToolParam:
"""Convert an MCP tool to an OpenAI Responses API tool."""
normalized_parameters: Final = _normalize_mcp_input_schema(mcp_tool.inputSchema)
normalized_parameters: Final = _normalize_mcp_input_schema(mcp_tool.input_schema)
return FunctionToolParam(
name=mcp_tool.name,
@ -93,7 +93,7 @@ def transform_mcp_tool_to_anthropic_tool(mcp_tool: MCPTool) -> AnthropicMessages
return AnthropicMessagesTool(
name=mcp_tool.name,
description=mcp_tool.description or "",
input_schema=sanitize_input_schema_for_anthropic(mcp_tool.inputSchema),
input_schema=sanitize_input_schema_for_anthropic(mcp_tool.input_schema),
type="custom",
)
@ -129,7 +129,7 @@ async def list_tools_with_pagination(
)
tools.extend(result.tools)
next_cursor = getattr(result, "nextCursor", None)
next_cursor = getattr(result, "next_cursor", None)
if not isinstance(next_cursor, str) or not next_cursor:
return tools
if next_cursor in seen_cursors:

View file

@ -42,9 +42,9 @@ class _DownstreamElicitSession(Protocol):
async def elicit_url(self, message: str, url: str, elicitation_id: str) -> "ElicitResult": ...
async def elicit_form(self, message: str, requestedSchema: dict[str, object]) -> "ElicitResult": ...
async def elicit_form(self, message: str, requested_schema: dict[str, object]) -> "ElicitResult": ...
async def elicit(self, message: str, requestedSchema: dict[str, object]) -> "ElicitResult": ...
async def elicit(self, message: str, requested_schema: dict[str, object]) -> "ElicitResult": ...
async def handle_elicitation_request(
@ -145,22 +145,22 @@ async def _relay_elicitation_to_downstream(
result = await downstream_session.elicit_url(
message=params.message,
url=params.url,
elicitation_id=params.elicitationId,
elicitation_id=params.elicitation_id,
)
elif isinstance(params, ElicitRequestFormParams):
# Form mode: relay structured form to client
verbose_logger.info("MCP elicitation: relaying form mode to downstream")
result = await downstream_session.elicit_form(
message=params.message,
requestedSchema=params.requestedSchema,
requested_schema=params.requested_schema,
)
else:
# Fallback for generic ElicitRequestParams — pass an empty schema
# since elicit() requires requestedSchema as a positional arg.
# since elicit() requires requested_schema as a positional arg.
verbose_logger.info("MCP elicitation: relaying generic elicitation to downstream")
result = await downstream_session.elicit(
message=getattr(params, "message", ""),
requestedSchema=getattr(params, "requestedSchema", {}),
requested_schema=getattr(params, "requested_schema", {}),
)
verbose_logger.info(
"MCP elicitation: downstream responded with action=%s",

View file

@ -14,6 +14,7 @@ from collections.abc import Iterator
from typing import Final, Literal, NamedTuple, NoReturn, TypeAlias
import httpx
import httpx2
from mcp.types import Tool as MCPTool
from pydantic import BaseModel, ConfigDict
from typing_extensions import assert_never
@ -63,8 +64,8 @@ class AggregateToolListing(NamedTuple):
outcomes: dict[str, ServerOutcome]
def _iter_upstream_responses(exc: BaseException) -> Iterator[httpx.Response]:
"""Yield every ``httpx.Response`` in the exception tree, in the shared traversal's deliberate
def _iter_upstream_responses(exc: BaseException) -> Iterator[httpx.Response | httpx2.Response]:
"""Yield every upstream ``httpx``/``httpx2`` ``Response`` in the exception tree, in the shared traversal's deliberate
order (explicit causes first, ExceptionGroup members in raise order, the incidental
``__context__`` chain last), so a response raised while handling the real failure can never
shadow one on the explicit causal chain. Consumers apply their own predicate over the stream:
@ -72,11 +73,11 @@ def _iter_upstream_responses(exc: BaseException) -> Iterator[httpx.Response]:
behind an unrelated earlier one."""
for current in iter_exception_tree(exc):
response = getattr(current, "response", None)
if isinstance(response, httpx.Response):
if isinstance(response, (httpx.Response, httpx2.Response)):
yield response
def _find_upstream_response(exc: BaseException) -> httpx.Response | None:
def _find_upstream_response(exc: BaseException) -> httpx.Response | httpx2.Response | None:
return next(_iter_upstream_responses(exc), None)
@ -136,9 +137,9 @@ def classify_list_exception(exc: BaseException) -> ServerListFault:
response: Final = _find_upstream_response(exc)
if response is not None:
return ServerListFault(tag="upstream_error", status_code=response.status_code)
if isinstance(exc, (httpx.TimeoutException,)):
if isinstance(exc, (httpx.TimeoutException, httpx2.TimeoutException)):
return ServerListFault(tag="timeout")
if isinstance(exc, httpx.TransportError):
if isinstance(exc, (httpx.TransportError, httpx2.TransportError)):
return ServerListFault(tag="unreachable")
return ServerListFault(tag="internal")

View file

@ -135,7 +135,7 @@ class MCPGuardrailTranslationHandler(BaseTranslation):
mcp_tool: Final = MCPTool(
name=mcp_tool_name,
description=mcp_tool_description or "",
inputSchema={}, # Call payload has no schema; guardrail gets args from request_data
input_schema={}, # Call payload has no schema; guardrail gets args from request_data
)
openai_tool: Final = transform_mcp_tool_to_openai_tool(mcp_tool)
fn: Final = openai_tool["function"]

View file

@ -113,6 +113,7 @@ from typing import Final
from urllib.parse import parse_qsl, quote, quote_plus, unquote_plus, urlencode
import httpx
import httpx2
from pydantic import JsonValue, TypeAdapter
from starlette.requests import HTTPConnection
from starlette.types import Message, Send
@ -409,7 +410,7 @@ def _safe_text(value: str, limit: int = _BODY_PREVIEW_CHARS) -> str:
return escaped if len(escaped) <= limit else f"{escaped[:limit]}...(truncated)"
def safe_upstream_url(url: httpx.URL) -> str:
def safe_upstream_url(url: httpx.URL | httpx2.URL) -> str:
return _safe_text(str(url.copy_with(username="", password="", path="/", query=None, fragment=None)))
@ -449,10 +450,10 @@ def _header_secret_values(name: str, value: str) -> tuple[str, ...]:
return (value, credential, decoded, password, unquote_plus(password))
def _body_secret_values(request: httpx.Request) -> tuple[str, ...] | None:
def _body_secret_values(request: httpx.Request | httpx2.Request) -> tuple[str, ...] | None:
try:
raw: Final = request.content
except httpx.RequestNotRead:
except (httpx.RequestNotRead, httpx2.RequestNotRead):
return None
if not raw:
return ()
@ -478,7 +479,7 @@ def _body_secret_values(request: httpx.Request) -> tuple[str, ...] | None:
)
def _request_secret_values(request: httpx.Request) -> tuple[str, ...] | None:
def _request_secret_values(request: httpx.Request | httpx2.Request) -> tuple[str, ...] | None:
body_values: Final = _body_secret_values(request)
if body_values is None:
return None
@ -537,18 +538,18 @@ def _preview(raw: bytes, content_type: str = "", secrets: tuple[str, ...] = ())
return _safe_text(redact_string(_mask_known_values(json.dumps(parsed, separators=(",", ":")), secrets)))
def _masked_headers(headers: httpx.Headers) -> str:
def _masked_headers(headers: httpx.Headers | httpx2.Headers) -> str:
return _safe_text(", ".join(f"{name}={value}" for name, value in headers.items() if name in _SAFE_HEADER_NAMES))
def _request_body_preview(request: httpx.Request, secrets: tuple[str, ...] | None) -> str:
def _request_body_preview(request: httpx.Request | httpx2.Request, secrets: tuple[str, ...] | None) -> str:
try:
return _preview(request.content, request.headers.get("content-type", ""), secrets or ())
except httpx.RequestNotRead:
except (httpx.RequestNotRead, httpx2.RequestNotRead):
return "(streamed, not captured)"
def _response_body_preview(response: httpx.Response, secrets: tuple[str, ...] | None) -> str:
def _response_body_preview(response: httpx.Response | httpx2.Response, secrets: tuple[str, ...] | None) -> str:
if secrets is None:
return "(omitted: request credentials unavailable)"
captured: Final = response.extensions.get(_CAPTURE_EXTENSION)
@ -556,7 +557,7 @@ def _response_body_preview(response: httpx.Response, secrets: tuple[str, ...] |
return captured
try:
return _preview(response.content, response.headers.get("content-type", ""), secrets)
except httpx.ResponseNotRead:
except (httpx.ResponseNotRead, httpx2.ResponseNotRead):
return "(not read)"
@ -569,7 +570,7 @@ async def _read_error_prefix(chunks: AsyncIterator[bytes], limit: int) -> bytes:
return buffer.getvalue()
async def capture_upstream_error_response(response: httpx.Response) -> None:
async def capture_upstream_error_response(response: httpx.Response | httpx2.Response) -> None:
if not response.is_error:
return
try:
@ -584,7 +585,7 @@ async def capture_upstream_error_response(response: httpx.Response) -> None:
if secrets is not None
else "(omitted: request credentials unavailable)"
)
except (asyncio.TimeoutError, httpx.HTTPError, httpx.StreamError):
except (asyncio.TimeoutError, httpx.HTTPError, httpx.StreamError, httpx2.HTTPError, httpx2.StreamError):
response._content = b"" # pyright: ignore[reportPrivateUsage] # rebind-ok: httpx auth retries must survive diagnostic read failures
response.extensions[_CAPTURE_EXTENSION] = (
"(unavailable: error body read failed)" # rebind-ok: httpx response hooks communicate through extensions
@ -593,7 +594,7 @@ async def capture_upstream_error_response(response: httpx.Response) -> None:
response.extensions[_CAPTURE_EXTENSION] = preview # rebind-ok: httpx response hooks communicate through extensions
def describe_upstream_response(response: httpx.Response) -> str:
def describe_upstream_response(response: httpx.Response | httpx2.Response) -> str:
try:
request: Final = response.request
except RuntimeError:
@ -616,6 +617,6 @@ def describe_upstream_http_failure(exc: BaseException) -> str | None:
describe_upstream_response(response)
for current in islice(iter_exception_tree(exc), 16)
for response in (getattr(current, "response", None),)
if isinstance(response, httpx.Response)
if isinstance(response, (httpx.Response, httpx2.Response))
)
return " | ".join(lines) or None

View file

@ -34,6 +34,7 @@ from urllib.parse import ParseResult, urlparse
import anyio
import httpx
import httpx2
from fastapi import HTTPException
from httpx import HTTPStatusError
from mcp import ReadResourceResult, Resource
@ -194,8 +195,7 @@ from litellm.types.mcp_server.mcp_server_manager import (
from litellm.types.utils import CallTypes
if TYPE_CHECKING:
from mcp.client.session import ClientSession
from mcp.shared.context import RequestContext
from mcp.client.session import ClientRequestContext
from mcp.types import CreateMessageRequestParams
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -1297,8 +1297,8 @@ def _passthrough_token_from_mcp_auth_header(
return None
async def _materialize_auth_headers(auth: httpx.Auth | None) -> dict[str, str] | None:
"""Extract the header a resolved ``httpx.Auth`` would set, as a plain dict, or None.
async def _materialize_auth_headers(auth: httpx2.Auth | None) -> dict[str, str] | None:
"""Extract the header a resolved ``httpx2.Auth`` would set, as a plain dict, or None.
OpenAPI tool closures egress through ``AsyncHTTPHandler`` methods that accept headers but no
``auth``, so a resolved credential must be materialized into a header value. Driving one step
@ -1313,7 +1313,7 @@ async def _materialize_auth_headers(auth: httpx.Auth | None) -> dict[str, str] |
header_name: Final = getattr(auth, "header_name", None)
if not isinstance(header_name, str) or not header_name:
return None
probe: Final = httpx.Request("GET", "http://localhost/")
probe: Final = httpx2.Request("GET", "http://localhost/")
flow: Final = auth.async_auth_flow(probe)
try:
first_request: Final = await flow.__anext__()
@ -1587,7 +1587,7 @@ def _create_sampling_callback(user_api_key_auth: UserAPIKeyAuth | None = None):
return None
async def _sampling_callback(
context: "RequestContext[ClientSession, object]",
context: "ClientRequestContext",
params: "CreateMessageRequestParams",
):
import litellm
@ -4012,7 +4012,7 @@ class MCPServerManager:
subject_token: str | None,
user_api_key_auth: UserAPIKeyAuth | None,
extra_headers: dict[str, str] | None,
) -> tuple[httpx.Auth | None, dict[str, str] | None]:
) -> tuple[httpx2.Auth | None, dict[str, str] | None]:
"""Resolve a v2-owned server's upstream credential into ``(resolved_auth, extra_headers)``.
On a missing/rejected per-user credential this raises the mode's discovery challenge
@ -5552,7 +5552,7 @@ class MCPServerManager:
verbose_logger.error(error_msg)
return CallToolResult(
content=[TextContent(type="text", text=error_msg)],
isError=True,
is_error=True,
)
try:
@ -5563,7 +5563,7 @@ class MCPServerManager:
# Convert the handler result (string response) to CallToolResult format
result: Final = CallToolResult(
content=[TextContent(type="text", text=str(handler_result))],
isError=False,
is_error=False,
)
return result
@ -5579,7 +5579,7 @@ class MCPServerManager:
verbose_logger.error(error_msg)
return CallToolResult(
content=[TextContent(type="text", text=error_msg)],
isError=True,
is_error=True,
)
async def pre_call_tool_check(

View file

@ -34,6 +34,7 @@ from dataclasses import dataclass
from typing import Annotated, Final, Literal
import httpx
import httpx2
from pydantic import BaseModel, ConfigDict, Field, SecretStr, TypeAdapter, ValidationError
from typing_extensions import assert_never
@ -337,7 +338,7 @@ def _identity_key(config: ClientCredentialsConfig) -> str:
return hashlib.sha256(material.encode("utf-8")).hexdigest()
class ClientCredentialsBearerAuth(httpx.Auth):
class ClientCredentialsBearerAuth(httpx2.Auth):
"""Bearer auth that retries an upstream 401 exactly once with a freshly minted token.
The initial token was already resolved (so config/IdP failures surfaced as typed errors
@ -356,7 +357,7 @@ class ClientCredentialsBearerAuth(httpx.Auth):
self._access_token = SecretStr(access_token)
self._refetch = refetch
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
token: Final = self._access_token.get_secret_value()
name, value = self._carrier.header(token)
request.headers[name] = value
@ -371,5 +372,5 @@ class ClientCredentialsBearerAuth(httpx.Auth):
request.headers[fresh_name] = fresh_value
yield request
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
raise RuntimeError("ClientCredentialsBearerAuth only supports async httpx clients")
def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]:
raise RuntimeError("ClientCredentialsBearerAuth only supports async httpx2 clients")

View file

@ -1,29 +1,29 @@
"""Concrete `httpx.Auth` objects the resolver returns for the self-contained modes.
"""Concrete `httpx2.Auth` objects the resolver returns for the self-contained modes.
These are the egress credential as the SDK consumes it: an `httpx.Auth` attached to the
These are the egress credential as the SDK consumes it: an `httpx2.Auth` attached to the
upstream `AsyncClient`. The OAuth-flow modes (`authorization_code`, `client_credentials`,
`token_exchange`) return SDK-provided auth objects instead and land later.
`auth_flow` mutating the outbound request is the `httpx.Auth` contract, not a house-style
violation: the request is httpx's object, and these carry no state of their own.
`auth_flow` mutating the outbound request is the `httpx2.Auth` contract, not a house-style
violation: the request is httpx2's object, and these carry no state of their own.
"""
from __future__ import annotations
from collections.abc import Generator
import httpx
import httpx2
from pydantic import SecretStr
class NoOpAuth(httpx.Auth):
class NoOpAuth(httpx2.Auth):
"""Attaches nothing — the `none` mode (and the seam-level default)."""
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]:
yield request
class StaticHeaderAuth(httpx.Auth):
class StaticHeaderAuth(httpx2.Auth):
"""Sets one fixed header on every request — the `api_key` family and `passthrough`.
The header value is a live credential (a bearer token, an API key, a forwarded user
@ -36,6 +36,6 @@ class StaticHeaderAuth(httpx.Auth):
self.header_name = header_name
self._header_value = SecretStr(header_value)
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]:
request.headers[self.header_name] = self._header_value.get_secret_value()
yield request

View file

@ -1,7 +1,7 @@
"""The one credential resolver: dispatch on the declared mode, fail closed.
`resolve_credentials` selects exactly one arm off the server's typed `config` and either
produces an `httpx.Auth` or returns a typed `CredError`. The `match` is over the `AuthConfig`
produces an `httpx2.Auth` or returns a typed `CredError`. The `match` is over the `AuthConfig`
variant, so each arm receives its own fully-typed config with no field-presence inference and
no precedence cascade. It is wildcard-free with an `assert_never` tail, so adding a mode without
an arm fails the type gate (basedpyright `reportMatchNotExhaustive`); a bypassed gate fails loudly
@ -25,6 +25,7 @@ from functools import partial
from typing import Final
import httpx
import httpx2
from typing_extensions import assert_never
from litellm._logging import verbose_proxy_logger
@ -135,7 +136,7 @@ class UpstreamCredentialProvider:
self._client_credentials_source = client_credentials_source or ClientCredentialsTokenSource()
self._sso_assertion_store: SSOAssertionStore = sso_assertion_store or default_sso_assertion_store()
async def resolve_credentials(self, subject: Subject, server: ServerSpec) -> Result[httpx.Auth, CredError]:
async def resolve_credentials(self, subject: Subject, server: ServerSpec) -> Result[httpx2.Auth, CredError]:
match server.config:
case NoneConfig():
return self._none(server)
@ -155,7 +156,7 @@ class UpstreamCredentialProvider:
return _not_implemented(AuthSpecKind.aws_sigv4)
assert_never(server.config)
def _none(self, server: ServerSpec) -> Result[httpx.Auth, CredError]:
def _none(self, server: ServerSpec) -> Result[httpx2.Auth, CredError]:
try:
resource: Final = httpx.URL(server.resource)
except httpx.InvalidURL:
@ -169,12 +170,12 @@ class UpstreamCredentialProvider:
Reads from the same per-user store as the ``authorization_code`` arm, so the discovery
challenge and the egress agree on whether the user is authorized. Returns a typed ``bool``
(no ``httpx.Auth``), unlike ``resolve_credentials``. A non-per-user mode has no token in the
(no ``httpx2.Auth``), unlike ``resolve_credentials``. A non-per-user mode has no token in the
store, so it reads as False without a per-mode branch here.
"""
return await self._authz_token(subject, server) is not None
def _passthrough(self, subject: Subject) -> Result[httpx.Auth, CredError]:
def _passthrough(self, subject: Subject) -> Result[httpx2.Auth, CredError]:
"""Forward the caller's own upstream credential verbatim; the gateway mints nothing.
The inbound token is the caller's already-disambiguated ``Authorization`` (never the LiteLLM
@ -186,7 +187,7 @@ class UpstreamCredentialProvider:
return Ok(NoOpAuth())
return Ok(StaticHeaderAuth(subject.inbound_token.get_secret_value(), header_name="Authorization"))
def _api_key(self, config: ApiKeyConfig) -> Result[httpx.Auth, CredError]:
def _api_key(self, config: ApiKeyConfig) -> Result[httpx2.Auth, CredError]:
match config.key_source:
case SharedKey() as source:
header_name, header_value = config.header(source.value.get_secret_value())
@ -196,7 +197,7 @@ class UpstreamCredentialProvider:
return Error(CredError.of_not_implemented("api_key BYOK source not implemented yet"))
assert_never(config.key_source)
async def _id_jag(self, subject: Subject, server: ServerSpec, config: IdJagConfig) -> Result[httpx.Auth, CredError]:
async def _id_jag(self, subject: Subject, server: ServerSpec, config: IdJagConfig) -> Result[httpx2.Auth, CredError]:
match await self._id_jag_subject_token(subject):
case Error(err):
return Error(err)
@ -261,7 +262,7 @@ class UpstreamCredentialProvider:
async def _id_jag_exchange(
self, subject: Subject, token: str, server: ServerSpec, config: IdJagConfig
) -> Result[httpx.Auth, CredError]:
) -> Result[httpx2.Auth, CredError]:
slot: Final = _id_jag_slot_key(subject, server)
fingerprint: Final = _id_jag_fingerprint(token, server.server_id, config)
@ -313,7 +314,7 @@ class UpstreamCredentialProvider:
async def _client_credentials(
self, server_id: str, config: ClientCredentialsConfig
) -> Result[httpx.Auth, CredError]:
) -> Result[httpx2.Auth, CredError]:
"""The M2M arm: resolve a cached (or freshly minted) gateway token; no user context.
The token is resolved here, before any upstream request, so a misconfigured grant or an
@ -448,7 +449,7 @@ def _client_auth_fingerprint(client_auth: ClientAuth) -> str:
assert_never(client_auth)
def _not_implemented(kind: AuthSpecKind) -> Result[httpx.Auth, CredError]:
def _not_implemented(kind: AuthSpecKind) -> Result[httpx2.Auth, CredError]:
return Error(CredError.of_not_implemented(f"{kind.value}: resolver arm not implemented yet"))

View file

@ -30,7 +30,7 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Annotated, Final, Literal
import httpx
import httpx2
from expression import case, tag, tagged_union
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from typing_extensions import assert_never
@ -66,7 +66,7 @@ class AuthResolution(str, Enum):
@dataclass(frozen=True, slots=True)
class ResolvedCredential:
auth: httpx.Auth = field(repr=False)
auth: httpx2.Auth = field(repr=False)
source: AuthResolution
@ -110,7 +110,7 @@ class Unauthorized:
@tagged_union(frozen=True)
class CredError:
"""Why a credential could not be produced. Fail-closed: an arm yields this or an `httpx.Auth`.
"""Why a credential could not be produced. Fail-closed: an arm yields this or an `httpx2.Auth`.
Discriminated on the `Literal` `tag`; consumers `match self.tag` (see `summary`) so the
type checker can prove exhaustiveness. Construct via the `of_*` factories.

View file

@ -10,6 +10,7 @@ from uuid import uuid4
import anyio
import httpx
import httpx2
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import ValidationError
from starlette.datastructures import Headers
@ -120,20 +121,20 @@ def _known_connection_error_message(exc: BaseException, url: str | None, timeout
f"within {timeout_seconds:.0f}s. Check that the LiteLLM proxy can reach this URL "
"from its network (DNS, egress rules, firewalls) and that the server answers MCP requests."
)
if isinstance(exc, httpx.LocalProtocolError):
if isinstance(exc, (httpx.LocalProtocolError, httpx2.LocalProtocolError)):
return (
"Failed to connect to MCP server: a request header is malformed. "
"Check static headers for leading/trailing spaces or illegal characters."
)
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout, httpx2.ConnectError, httpx2.ConnectTimeout)):
return (
"Failed to connect to MCP server: the server is unreachable. Check the URL and that the server is running."
)
if isinstance(exc, httpx.TimeoutException):
if isinstance(exc, (httpx.TimeoutException, httpx2.TimeoutException)):
return "Failed to connect to MCP server: the connection timed out."
if isinstance(exc, httpx.HTTPStatusError):
if isinstance(exc, (httpx.HTTPStatusError, httpx2.HTTPStatusError)):
return f"Failed to connect to MCP server: it returned HTTP {exc.response.status_code}."
if isinstance(exc, (httpx.NetworkError, httpx.RemoteProtocolError, ConnectionError)):
if isinstance(exc, (httpx.NetworkError, httpx.RemoteProtocolError, httpx2.NetworkError, httpx2.RemoteProtocolError, ConnectionError)):
return (
"Failed to connect to MCP server: the connection was interrupted. "
"Check the server and network connection, then retry."
@ -148,7 +149,7 @@ def _known_connection_error_message(exc: BaseException, url: str | None, timeout
"Failed to connect to MCP server: the endpoint returned invalid JSON or an invalid MCP response. "
"Check the MCP endpoint URL and the server's protocol implementation."
)
if MCP_AVAILABLE and isinstance(exc, McpError):
if MCP_AVAILABLE and isinstance(exc, MCPError):
if exc.error.code == -32000 and exc.error.message == "Connection closed":
return (
"Failed to connect to MCP server: the connection was closed before the request completed. "
@ -168,7 +169,7 @@ def _known_connection_error_message(exc: BaseException, url: str | None, timeout
if MCP_AVAILABLE:
from mcp.shared.exceptions import McpError
from mcp.shared.exceptions import MCPError
from mcp.types import Tool as MCPTool
from litellm.experimental_mcp_client.client import MCPClient, as_mcp_read_timeout
@ -517,7 +518,7 @@ if MCP_AVAILABLE:
ListMCPToolsRestAPIResponseObject(
name=tool.name,
description=tool.description,
inputSchema=tool.inputSchema,
inputSchema=tool.input_schema,
mcp_info=enriched_mcp_info,
)
for tool in tools
@ -1481,7 +1482,7 @@ if MCP_AVAILABLE:
effective_timeout: Final = (
min(request.timeout if request.timeout is not None else MCP_CLIENT_TIMEOUT, timeout_seconds)
if any(
isinstance(cause, McpError) and as_mcp_read_timeout(cause) is not None
isinstance(cause, MCPError) and as_mcp_read_timeout(cause) is not None
for cause in iter_exception_tree(e)
)
else timeout_seconds

View file

@ -18,8 +18,7 @@ if typing.TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from fastapi import Request
from mcp.client.session import ClientSession
from mcp.shared.context import RequestContext
from mcp.client.session import ClientRequestContext
from mcp.types import (
ContentBlock,
CreateMessageResult,
@ -333,14 +332,14 @@ def _convert_single_content(
return {"type": "text", "text": content.text}
elif content_type == "image":
image_data: Final[str] = getattr(content, "data", "")
image_mime_type: Final[str] = getattr(content, "mimeType", "image/png")
image_mime_type: Final[str] = getattr(content, "mime_type", "image/png")
return {
"type": "image_url",
"image_url": {"url": f"data:{image_mime_type};base64,{image_data}"},
}
elif content_type == "audio":
audio_data: Final[str] = getattr(content, "data", "")
audio_mime_type: Final[str] = getattr(content, "mimeType", "audio/wav")
audio_mime_type: Final[str] = getattr(content, "mime_type", "audio/wav")
# Map MIME type to OpenAI audio format
format_map: Final = {
"audio/wav": "wav",
@ -573,7 +572,7 @@ def _convert_mcp_tools_to_openai(
"function": {
"name": tool.name,
"description": tool.description or "",
"parameters": tool.inputSchema
"parameters": tool.input_schema
or {
"type": "object",
"properties": {},
@ -718,7 +717,7 @@ def _convert_openai_response_to_mcp_result(
role="assistant",
content=content_parts,
model=actual_model,
stopReason=stop_reason,
stop_reason=stop_reason,
)
# Simple text response
text: Final = message.content or ""
@ -726,7 +725,7 @@ def _convert_openai_response_to_mcp_result(
role="assistant",
content=TextContent(type="text", text=text),
model=actual_model,
stopReason=stop_reason,
stop_reason=stop_reason,
)
@ -1075,8 +1074,8 @@ async def _build_completion_kwargs(
}
if params.temperature is not None:
completion_kwargs["temperature"] = params.temperature
if params.stopSequences:
completion_kwargs["stop"] = params.stopSequences
if params.stop_sequences:
completion_kwargs["stop"] = params.stop_sequences
openai_tools: Final = _convert_mcp_tools_to_openai(params.tools)
if openai_tools:
completion_kwargs["tools"] = openai_tools
@ -1137,7 +1136,7 @@ async def _run_guardrails_and_call_llm(
async def handle_sampling_create_message(
context: "RequestContext[ClientSession, object]",
context: "ClientRequestContext",
params: "CreateMessageRequestParams",
default_model: str | None = None,
user_api_key_auth: "UserAPIKeyAuth | None" = None,
@ -1180,13 +1179,13 @@ async def handle_sampling_create_message(
try:
model: Final = _resolve_model_from_preferences(
model_preferences=params.modelPreferences,
model_preferences=params.model_preferences,
default_model=default_model,
)
verbose_logger.info(
"MCP sampling: resolved model=%s from preferences=%s",
model,
params.modelPreferences,
params.model_preferences,
)
access_denial: Final = await _check_model_access(model, user_api_key_auth)
@ -1228,7 +1227,7 @@ async def handle_sampling_create_message(
verbose_logger.info(
"MCP sampling: completed successfully, model=%s, stopReason=%s",
getattr(result, "model", "unknown"),
getattr(result, "stopReason", "unknown"),
getattr(result, "stop_reason", "unknown"),
)
return result
except Exception as e:

View file

@ -524,7 +524,7 @@ if MCP_AVAILABLE:
normalized.append(
ReadResourceContents(
content=content.text,
mime_type=content.mimeType,
mime_type=content.mime_type,
meta=meta,
)
)
@ -532,7 +532,7 @@ if MCP_AVAILABLE:
normalized.append(
ReadResourceContents(
content=content.blob,
mime_type=content.mimeType,
mime_type=content.mime_type,
meta=meta,
)
)
@ -877,10 +877,10 @@ if MCP_AVAILABLE:
}
return ListToolsResult.model_validate({"tools": listing.tools, "_meta": outcome_meta})
except HTTPException as e:
from mcp.shared.exceptions import McpError
from mcp.types import INVALID_REQUEST, ErrorData
from mcp.shared.exceptions import MCPError
from mcp.types import INVALID_REQUEST
raise McpError(ErrorData(code=INVALID_REQUEST, message=_http_detail_message(e.detail))) from e
raise MCPError(code=INVALID_REQUEST, message=_http_detail_message(e.detail)) from e
except Exception as e:
verbose_logger.exception("Error in list_tools endpoint: %s", e)
# Return empty list instead of failing completely
@ -906,7 +906,7 @@ if MCP_AVAILABLE:
if not (host_ctx and hasattr(host_ctx, "meta") and host_ctx.meta):
return None
host_token: Final = getattr(host_ctx.meta, "progressToken", None)
host_token: Final = getattr(host_ctx.meta, "progress_token", None)
if host_token is None or not (hasattr(host_ctx, "session") and host_ctx.session):
return None
host_session: Final = host_ctx.session
@ -927,10 +927,10 @@ if MCP_AVAILABLE:
return forward_progress
def _reject_mcp_proxy_operation() -> NoReturn:
from mcp.shared.exceptions import McpError
from mcp.types import METHOD_NOT_FOUND, ErrorData
from mcp.shared.exceptions import MCPError
from mcp.types import METHOD_NOT_FOUND
raise McpError(ErrorData(code=METHOD_NOT_FOUND, message="Operation unavailable on /mcp/proxy"))
raise MCPError(code=METHOD_NOT_FOUND, message="Operation unavailable on /mcp/proxy")
async def _build_virtual_call_logging_obj(
name: str,
@ -1005,7 +1005,7 @@ if MCP_AVAILABLE:
content=[ # mutable-ok: MCP result content
TextContent(type="text", text=f"Tool {name} is unavailable on /mcp/proxy")
],
isError=True,
is_error=True,
)
if _mcp_proxy_mode.get() and name in MCP_PROXY_TOOL_NAMES:
@ -1087,7 +1087,7 @@ if MCP_AVAILABLE:
text=f"Tool {name} requires mcp_tool_search_enabled on the key",
)
],
isError=True,
is_error=True,
)
args: Final = arguments or {}
@ -1256,7 +1256,7 @@ if MCP_AVAILABLE:
)
return CallToolResult(
content=[TextContent(text=str(e), type="text")],
isError=True,
is_error=True,
)
except BlockedPiiEntityError as e:
verbose_logger.error("BlockedPiiEntityError in MCP tool call: %s", e)
@ -1267,19 +1267,19 @@ if MCP_AVAILABLE:
type="text",
)
],
isError=True,
is_error=True,
)
except GuardrailRaisedException as e:
verbose_logger.error("GuardrailRaisedException in MCP tool call: %s", e)
return CallToolResult(
content=[TextContent(text=f"Error: Guardrail violation - {e}", type="text")],
isError=True,
is_error=True,
)
except HTTPException as e:
verbose_logger.error("HTTPException in MCP tool call: %s", e)
return CallToolResult(
content=[TextContent(text=f"Error: {_http_detail_message(e.detail)}", type="text")],
isError=True,
is_error=True,
)
except MCPUpstreamAuthError as e:
# The MCP session manager serializes handler exceptions as JSON-RPC errors, so a
@ -1295,13 +1295,13 @@ if MCP_AVAILABLE:
type="text",
)
],
isError=True,
is_error=True,
)
except Exception as e:
verbose_logger.exception("MCP mcp_server_tool_call - error: %s", e)
return CallToolResult(
content=[TextContent(text=f"Error: {e}", type="text")],
isError=True,
is_error=True,
)
return response
@ -3290,11 +3290,11 @@ if MCP_AVAILABLE:
Guardrails run before the success/failure logging so the masked text, not
the raw one, is what gets logged.
A result with ``isError=True`` is logged as a failure (``status="failure"``
A result with ``is_error=True`` is logged as a failure (``status="failure"``
payload, so OTel marks the span ERROR) while the HTTP wire behavior stays
200 + ``isError: true`` per the MCP spec. The error check runs after
``async_post_mcp_tool_call_hook`` because guardrails may flip the result
to ``isError=True`` in that hook. Raised exceptions never reach here (the
to ``is_error=True`` in that hook. Raised exceptions never reach here (the
``@client`` wrapper and ``call_mcp_tool``'s except path log those), so
this cannot double-log a failure.
@ -3629,10 +3629,10 @@ if MCP_AVAILABLE:
"""Execute a local-registry tool and report whether it succeeded.
Returns the result rather than bare content because the verdict is part of it: the content
alone cannot say whether the handler failed, so callers used to stamp isError=False on every
alone cannot say whether the handler failed, so callers used to stamp is_error=False on every
outcome and an upstream rejection was served as tool output.
A failure is reported as ``isError=True`` here rather than raised, because the REST surface
A failure is reported as ``is_error=True`` here rather than raised, because the REST surface
turns an unrecognized exception into a 500 and an upstream 403 or 429 is not a gateway crash.
``MCPUpstreamAuthError`` is the exception: it propagates so the caller is told to
re-authenticate, which both renderers already know how to say.
@ -3654,8 +3654,8 @@ if MCP_AVAILABLE:
raise
except Exception as e:
verbose_logger.exception("Error executing local tool %s: %s", name, e)
return CallToolResult(content=[TextContent(text=f"Error: {e}", type="text")], isError=True)
return CallToolResult(content=[TextContent(text=str(result), type="text")], isError=False)
return CallToolResult(content=[TextContent(text=f"Error: {e}", type="text")], is_error=True)
return CallToolResult(content=[TextContent(text=str(result), type="text")], is_error=False)
def _get_mcp_servers_in_path(path: str) -> list[str] | None:
"""

View file

@ -99,11 +99,11 @@ def mcp_tool_search_settings() -> MCPToolSearchSettings | ValidationError:
def _tool_result(tool: Tool) -> ToolSearchResult:
return {"name": tool.name, "description": tool.description or "", "inputSchema": tool.inputSchema}
return {"name": tool.name, "description": tool.description or "", "inputSchema": tool.input_schema}
def _scored_result(tool: Tool, score: float) -> ToolSearchResult:
return {"name": tool.name, "description": tool.description or "", "inputSchema": tool.inputSchema, "score": score}
return {"name": tool.name, "description": tool.description or "", "inputSchema": tool.input_schema, "score": score}
_MCP_PROXY_IDENTITY_META_KEY: Final[str] = "litellm.ai/proxy_tool_identity"
@ -148,11 +148,11 @@ def _proxy_schema_result(tool: Tool) -> MCPProxySchemaResult:
"tool_id": mcp_proxy_tool_id(tool),
"name": tool.name,
"description": tool.description or "",
"inputSchema": tool.inputSchema,
"inputSchema": tool.input_schema,
}
if tool.outputSchema is None:
if tool.output_schema is None:
return base
return {**base, "outputSchema": tool.outputSchema} # mutable-ok: wire schema payload
return {**base, "outputSchema": tool.output_schema} # mutable-ok: wire schema payload
def _tool_text(tool: Tool) -> str:
@ -372,7 +372,7 @@ def _text_tool_result(text: str, is_error: bool) -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text=text)], # mutable-ok: CallToolResult accepts only list content
isError=is_error,
is_error=is_error,
)
@ -565,7 +565,7 @@ async def handle_mcp_proxy_tool(
if not isinstance(tool_arguments, dict):
return _text_tool_result("arguments must be an object", is_error=True)
try:
validate(instance=tool_arguments, schema=tool.inputSchema)
validate(instance=tool_arguments, schema=tool.input_schema)
except JsonSchemaValidationError as exc:
return _text_tool_result(f"Invalid arguments: {exc.message}", is_error=True)

View file

@ -536,7 +536,11 @@ def extract_mcp_tool_result_error_message(result: object) -> str | None:
Accepts both ``mcp.types.CallToolResult`` objects and their dict
equivalents, duck-typed so the ``mcp`` package is not required.
"""
is_error: Final[object] = result.get("isError") if isinstance(result, Mapping) else getattr(result, "isError", None)
is_error: Final[object] = (
(result.get("isError") if result.get("isError") is not None else result.get("is_error"))
if isinstance(result, Mapping)
else getattr(result, "is_error", None)
)
if is_error is not True:
return None
content: Final[object] = result.get("content") if isinstance(result, Mapping) else getattr(result, "content", None)
@ -870,8 +874,9 @@ def json_unrewritable_labels(value: object, path_depth: int = 0) -> tuple[str, .
def mcp_tool_result_structured_content(result: object) -> object:
"""The ``structuredContent`` of an MCP tool result, or ``None`` when it has none."""
if isinstance(result, Mapping):
return result.get("structuredContent")
return getattr(result, "structuredContent", None)
structured: Final = result.get("structuredContent")
return structured if structured is not None else result.get("structured_content")
return getattr(result, "structured_content", None)
def set_mcp_tool_result_structured_content(result: object, value: object) -> bool:
@ -882,12 +887,12 @@ def set_mcp_tool_result_structured_content(result: object, value: object) -> boo
unmasked value in the spend log and the OTel span.
"""
if isinstance(result, MutableMapping):
result["structuredContent"] = value
result["structured_content" if "structured_content" in result else "structuredContent"] = value
return True
if not hasattr(result, "structuredContent"):
if not hasattr(result, "structured_content"):
return False
try:
setattr(result, "structuredContent", value) # attribute name is fixed by the MCP result shape
setattr(result, "structured_content", value) # attribute name is fixed by the MCP result shape
return True
except (AttributeError, TypeError, ValueError):
return False

View file

@ -219,14 +219,14 @@ class _CiscoAIDefenseMcpMixin:
if isinstance(content, list):
content[:] = replacement
structured_replacement: Final = _CiscoAIDefenseMcpMixin._replacement_structured_content(replacement)
if hasattr(response_obj, "structuredContent"):
if hasattr(response_obj, "structured_content"):
try:
setattr(response_obj, "structuredContent", structured_replacement)
setattr(response_obj, "structured_content", structured_replacement)
except (AttributeError, TypeError, ValueError):
pass
if hasattr(response_obj, "isError"):
if hasattr(response_obj, "is_error"):
try:
setattr(response_obj, "isError", True)
setattr(response_obj, "is_error", True)
except (AttributeError, TypeError, ValueError):
pass
return True
@ -508,7 +508,8 @@ class _CiscoAIDefenseMcpMixin:
) -> dict[str, object]:
result: Final[dict[str, object]] = {"content": [_serialize_mcp_content_item(item) for item in content]}
for key in ("structuredContent", "isError"):
value = source.get(key) if isinstance(source, dict) else getattr(source, key, None)
snake_key: Final = "structured_content" if key == "structuredContent" else "is_error"
value = source.get(key) if isinstance(source, dict) else getattr(source, snake_key, None)
if value is not None and (key != "isError" or isinstance(value, bool)):
result[key] = value
return result
@ -552,17 +553,18 @@ class _CiscoAIDefenseMcpMixin:
if item[0] == "structuredContent":
response_obj[index] = (item[0], replacement)
replaced = True
elif hasattr(response_obj, "structuredContent"):
elif hasattr(response_obj, "structured_content"):
try:
setattr(response_obj, "structuredContent", replacement)
setattr(response_obj, "structured_content", replacement)
replaced = True
except (AttributeError, TypeError, ValueError):
pass
elif isinstance(response_obj, dict):
result: Final = response_obj.get("result")
target: Final[dict[object, object]] = result if isinstance(result, dict) else response_obj
if "structuredContent" in target:
target["structuredContent"] = replacement
structured_key: Final = "structured_content" if "structured_content" in target else "structuredContent"
if structured_key in target:
target[structured_key] = replacement
replaced = True
return replaced

View file

@ -105,8 +105,8 @@ async def create_mcp_list_tools_events(
"description": getattr(tool, "description", ""),
"annotations": {"read_only": False},
**dict.fromkeys(
("input_schema",) if hasattr(tool, "inputSchema") or hasattr(tool, "input_schema") else (),
getattr(tool, "inputSchema", getattr(tool, "input_schema", None)),
("input_schema",) if hasattr(tool, "input_schema") else (),
getattr(tool, "input_schema", None),
),
}
for tool in filtered_mcp_tools

View file

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal
from urllib.parse import urlsplit
import httpx
import httpx2
from pydantic import BaseModel, ConfigDict, Field
from typing_extensions import TypedDict
@ -332,7 +333,7 @@ def custom_credential_slot(headers: Mapping[str, str] | None) -> str | None:
def credential_redirect_hook(
configured_url: str, slot: str | None
) -> Callable[[httpx.Request], Awaitable[None]] | None:
) -> Callable[[httpx.Request | httpx2.Request], Awaitable[None]] | None:
"""An httpx request hook dropping ``slot`` once a redirect leaves ``configured_url``'s origin.
None when no guard is needed, so callers do not each repeat the exemption: HTTP clients already
@ -342,7 +343,7 @@ def credential_redirect_hook(
if not configured_url or not slot or same_header(slot, DEFAULT_CREDENTIAL_HEADER):
return None
async def guard(request: httpx.Request) -> None:
async def guard(request: httpx.Request | httpx2.Request) -> None:
if slot in request.headers and crosses_origin(configured_url, str(request.url)):
del request.headers[slot]