mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
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:
parent
4bc3f1d0fc
commit
5dc01319d7
19 changed files with 201 additions and 221 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue