diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index 56ee5f30d02..5e5dd3cf3f9 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -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) diff --git a/litellm/experimental_mcp_client/tools.py b/litellm/experimental_mcp_client/tools.py index 51d2139ef3b..a9ee851d529 100644 --- a/litellm/experimental_mcp_client/tools.py +++ b/litellm/experimental_mcp_client/tools.py @@ -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: diff --git a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py index bbd1c9aaf1e..57d2d86d506 100644 --- a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py +++ b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py @@ -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", diff --git a/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py b/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py index 42b2d29cd52..b96a7a74e4a 100644 --- a/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py +++ b/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py @@ -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") diff --git a/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py b/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py index c0235077ecd..01c8e73cad3 100644 --- a/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py +++ b/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py @@ -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"] diff --git a/litellm/proxy/_experimental/mcp_server/mcp_debug.py b/litellm/proxy/_experimental/mcp_server/mcp_debug.py index 1f157aefdc3..b0228ffe9f9 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_debug.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_debug.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 469ea86ad4b..36ecb05208b 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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( diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py index 43d97abe4db..3a8e2b3840a 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py @@ -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") diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/httpx_auth.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/httpx_auth.py index e4d8fd25748..aa04469a502 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/httpx_auth.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/httpx_auth.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index 85c7f68719d..41224e9ba2b 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -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")) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py index d186724fd9f..33c3a854058 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py @@ -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. diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 6a0ab5bdec5..7fb88d5cb10 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/sampling_handler.py b/litellm/proxy/_experimental/mcp_server/sampling_handler.py index fec2a1f9ee6..2e0e3bce60d 100644 --- a/litellm/proxy/_experimental/mcp_server/sampling_handler.py +++ b/litellm/proxy/_experimental/mcp_server/sampling_handler.py @@ -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: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index ad886c66de7..d88c96fef4a 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -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: """ diff --git a/litellm/proxy/_experimental/mcp_server/tool_search.py b/litellm/proxy/_experimental/mcp_server/tool_search.py index e921ab0331e..e6dce446751 100644 --- a/litellm/proxy/_experimental/mcp_server/tool_search.py +++ b/litellm/proxy/_experimental/mcp_server/tool_search.py @@ -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) diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py index fb3eb06fd15..6bd080f5216 100644 --- a/litellm/proxy/_experimental/mcp_server/utils.py +++ b/litellm/proxy/_experimental/mcp_server/utils.py @@ -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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/cisco_ai_defense/cisco_ai_defense_mcp.py b/litellm/proxy/guardrails/guardrail_hooks/cisco_ai_defense/cisco_ai_defense_mcp.py index 5a6be1089b6..777db999672 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/cisco_ai_defense/cisco_ai_defense_mcp.py +++ b/litellm/proxy/guardrails/guardrail_hooks/cisco_ai_defense/cisco_ai_defense_mcp.py @@ -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 diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py index 1b19bf77a7d..16e8ac93d59 100644 --- a/litellm/responses/mcp/mcp_streaming_iterator.py +++ b/litellm/responses/mcp/mcp_streaming_iterator.py @@ -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 diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index a59fcb1bcb5..c944c1a0200 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -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]