Replace httpx and httpx-sse with httpx2 (#2972)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
1216c53693
commit
2713b53b12
12 changed files with 1029 additions and 948 deletions
|
|
@ -89,7 +89,7 @@ async def negotiate_auto(session: ClientSession) -> None:
|
|||
version = mutual[-1]
|
||||
continue
|
||||
return
|
||||
# any other exception (httpx.TransportError, ConnectionError, anyio errors,
|
||||
# any other exception (httpx2.TransportError, ConnectionError, anyio errors,
|
||||
# RuntimeError from adopt) → propagate
|
||||
try:
|
||||
result = types.DiscoverResult.model_validate(raw)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""OAuth2 Authentication implementation for HTTPX.
|
||||
"""OAuth2 Authentication implementation for httpx2.
|
||||
|
||||
Implements authorization code flow with PKCE and automatic token refresh.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from collections.abc import Awaitable, Callable
|
|||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import jwt
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -83,11 +83,11 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
|||
self.context.client_info = self._fixed_client_info
|
||||
self._initialized = True
|
||||
|
||||
async def _perform_authorization(self) -> httpx.Request:
|
||||
async def _perform_authorization(self) -> httpx2.Request:
|
||||
"""Perform client_credentials authorization."""
|
||||
return await self._exchange_token_client_credentials()
|
||||
|
||||
async def _exchange_token_client_credentials(self) -> httpx.Request:
|
||||
async def _exchange_token_client_credentials(self) -> httpx2.Request:
|
||||
"""Build token exchange request for client_credentials grant."""
|
||||
token_data: dict[str, Any] = {
|
||||
"grant_type": "client_credentials",
|
||||
|
|
@ -105,7 +105,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
|||
token_data["scope"] = self.context.client_metadata.scope
|
||||
|
||||
token_url = self._get_token_endpoint()
|
||||
return httpx.Request("POST", token_url, data=token_data, headers=headers)
|
||||
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
|
||||
|
||||
|
||||
def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]:
|
||||
|
|
@ -297,7 +297,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
self.context.client_info = self._fixed_client_info
|
||||
self._initialized = True
|
||||
|
||||
async def _perform_authorization(self) -> httpx.Request:
|
||||
async def _perform_authorization(self) -> httpx2.Request:
|
||||
"""Perform client_credentials authorization with private_key_jwt."""
|
||||
return await self._exchange_token_client_credentials()
|
||||
|
||||
|
|
@ -315,7 +315,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
token_data["client_assertion"] = assertion
|
||||
token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
|
||||
async def _exchange_token_client_credentials(self) -> httpx.Request:
|
||||
async def _exchange_token_client_credentials(self) -> httpx2.Request:
|
||||
"""Build token exchange request for client_credentials grant with private_key_jwt."""
|
||||
token_data: dict[str, Any] = {
|
||||
"grant_type": "client_credentials",
|
||||
|
|
@ -333,7 +333,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
token_data["scope"] = self.context.client_metadata.scope
|
||||
|
||||
token_url = self._get_token_endpoint()
|
||||
return httpx.Request("POST", token_url, data=token_data, headers=headers)
|
||||
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
|
||||
|
||||
|
||||
class JWTParameters(BaseModel):
|
||||
|
|
@ -421,14 +421,14 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
|
|||
|
||||
async def _exchange_token_authorization_code(
|
||||
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None
|
||||
) -> httpx.Request: # pragma: no cover
|
||||
) -> httpx2.Request: # pragma: no cover
|
||||
"""Build token exchange request for authorization_code flow."""
|
||||
token_data = token_data or {}
|
||||
if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
|
||||
self._add_client_authentication_jwt(token_data=token_data)
|
||||
return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data)
|
||||
|
||||
async def _perform_authorization(self) -> httpx.Request: # pragma: no cover
|
||||
async def _perform_authorization(self) -> httpx2.Request: # pragma: no cover
|
||||
"""Perform the authorization flow."""
|
||||
if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
|
||||
token_request = await self._exchange_token_jwt_bearer()
|
||||
|
|
@ -455,7 +455,7 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
|
|||
# it represents the resource server that will validate the token
|
||||
token_data["audience"] = self.context.get_resource_url()
|
||||
|
||||
async def _exchange_token_jwt_bearer(self) -> httpx.Request:
|
||||
async def _exchange_token_jwt_bearer(self) -> httpx2.Request:
|
||||
"""Build token exchange request for JWT bearer grant."""
|
||||
if not self.context.client_info:
|
||||
raise OAuthFlowError("Missing client info") # pragma: no cover
|
||||
|
|
@ -481,6 +481,6 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
|
|||
token_data["scope"] = self.context.client_metadata.scope
|
||||
|
||||
token_url = self._get_token_endpoint()
|
||||
return httpx.Request(
|
||||
return httpx2.Request(
|
||||
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from typing import Literal
|
|||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
from mcp.client.auth import OAuthFlowError, OAuthTokenError, TokenStorage
|
||||
from mcp.client.auth.utils import (
|
||||
|
|
@ -56,8 +56,8 @@ def _origin(url: str) -> tuple[str, str, int | None]:
|
|||
return (parsed.scheme, parsed.hostname or "", port)
|
||||
|
||||
|
||||
class IdentityAssertionOAuthProvider(httpx.Auth):
|
||||
"""`httpx.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS.
|
||||
class IdentityAssertionOAuthProvider(httpx2.Auth):
|
||||
"""`httpx2.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS.
|
||||
|
||||
The authorization server `issuer` is fixed at construction; metadata is fetched from its
|
||||
RFC 8414 well-known and the ID-JAG and client secret are sent only to that issuer's token
|
||||
|
|
@ -136,7 +136,7 @@ class IdentityAssertionOAuthProvider(httpx.Auth):
|
|||
self._lock = anyio.Lock()
|
||||
self._initialized = False
|
||||
|
||||
def _build_token_request(self, scope: str | None, assertion: str) -> httpx.Request:
|
||||
def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Request:
|
||||
"""Build the RFC 7523 jwt-bearer token request, applying confidential-client auth."""
|
||||
assert self._token_endpoint is not None
|
||||
assert self._client.client_id is not None and self._client.client_secret is not None
|
||||
|
|
@ -157,9 +157,9 @@ class IdentityAssertionOAuthProvider(httpx.Auth):
|
|||
headers["Authorization"] = f"Basic {credentials}"
|
||||
else:
|
||||
data["client_secret"] = self._client.client_secret
|
||||
return httpx.Request("POST", self._token_endpoint, data=data, headers=headers)
|
||||
return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers)
|
||||
|
||||
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]:
|
||||
async with self._lock:
|
||||
if not self._initialized:
|
||||
self._tokens = await self._storage.get_tokens()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""OAuth2 Authentication implementation for HTTPX.
|
||||
"""OAuth2 Authentication implementation for httpx2.
|
||||
|
||||
Implements authorization code flow with PKCE and automatic token refresh.
|
||||
"""
|
||||
|
|
@ -15,7 +15,7 @@ from typing import Any, Protocol
|
|||
from urllib.parse import quote, urlencode, urljoin, urlparse
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp_types.version import is_version_at_least
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
|
|
@ -218,8 +218,8 @@ class OAuthContext:
|
|||
return data, headers
|
||||
|
||||
|
||||
class OAuthClientProvider(httpx.Auth):
|
||||
"""OAuth2 authentication for httpx.
|
||||
class OAuthClientProvider(httpx2.Auth):
|
||||
"""OAuth2 authentication for httpx2.
|
||||
|
||||
Handles OAuth flow with automatic client registration and token storage.
|
||||
"""
|
||||
|
|
@ -277,7 +277,7 @@ class OAuthClientProvider(httpx.Auth):
|
|||
self._validate_resource_url_callback = validate_resource_url
|
||||
self._initialized = False
|
||||
|
||||
async def _handle_protected_resource_response(self, response: httpx.Response) -> bool:
|
||||
async def _handle_protected_resource_response(self, response: httpx2.Response) -> bool:
|
||||
"""Handle protected resource metadata discovery response.
|
||||
|
||||
Per SEP-985, supports fallback when discovery fails at one URL.
|
||||
|
|
@ -308,7 +308,7 @@ class OAuthClientProvider(httpx.Auth):
|
|||
f"Protected Resource Metadata request failed: {response.status_code}"
|
||||
) # pragma: no cover
|
||||
|
||||
async def _perform_authorization(self) -> httpx.Request:
|
||||
async def _perform_authorization(self) -> httpx2.Request:
|
||||
"""Perform the authorization flow."""
|
||||
auth_code, code_verifier = await self._perform_authorization_code_grant()
|
||||
token_request = await self._exchange_token_authorization_code(auth_code, code_verifier)
|
||||
|
|
@ -385,7 +385,7 @@ class OAuthClientProvider(httpx.Auth):
|
|||
|
||||
async def _exchange_token_authorization_code(
|
||||
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {}
|
||||
) -> httpx.Request:
|
||||
) -> httpx2.Request:
|
||||
"""Build token exchange request for authorization_code flow."""
|
||||
if self.context.client_metadata.redirect_uris is None:
|
||||
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
|
||||
|
|
@ -412,9 +412,9 @@ class OAuthClientProvider(httpx.Auth):
|
|||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
token_data, headers = self.context.prepare_token_auth(token_data, headers)
|
||||
|
||||
return httpx.Request("POST", token_url, data=token_data, headers=headers)
|
||||
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
|
||||
|
||||
async def _handle_token_response(self, response: httpx.Response) -> None:
|
||||
async def _handle_token_response(self, response: httpx2.Response) -> None:
|
||||
"""Handle token exchange response."""
|
||||
if response.status_code not in {200, 201}:
|
||||
body = await response.aread()
|
||||
|
|
@ -436,7 +436,7 @@ class OAuthClientProvider(httpx.Auth):
|
|||
self.context.update_token_expiry(token_response)
|
||||
await self.context.storage.set_tokens(token_response)
|
||||
|
||||
async def _refresh_token(self) -> httpx.Request:
|
||||
async def _refresh_token(self) -> httpx2.Request:
|
||||
"""Build token refresh request."""
|
||||
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
|
||||
raise OAuthTokenError("No refresh token available") # pragma: no cover
|
||||
|
|
@ -464,9 +464,9 @@ class OAuthClientProvider(httpx.Auth):
|
|||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers)
|
||||
|
||||
return httpx.Request("POST", token_url, data=refresh_data, headers=headers)
|
||||
return httpx2.Request("POST", token_url, data=refresh_data, headers=headers)
|
||||
|
||||
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
|
||||
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
|
||||
"""Handle token refresh response. Returns True if successful."""
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Token refresh failed: {response.status_code}")
|
||||
|
|
@ -503,12 +503,12 @@ class OAuthClientProvider(httpx.Auth):
|
|||
self.context.client_info = await self.context.storage.get_client_info()
|
||||
self._initialized = True
|
||||
|
||||
def _add_auth_header(self, request: httpx.Request) -> None:
|
||||
def _add_auth_header(self, request: httpx2.Request) -> None:
|
||||
"""Add authorization header to request if we have valid tokens."""
|
||||
if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch
|
||||
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
|
||||
|
||||
async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None:
|
||||
async def _handle_oauth_metadata_response(self, response: httpx2.Response) -> None:
|
||||
content = await response.aread()
|
||||
metadata = OAuthMetadata.model_validate_json(content)
|
||||
self.context.oauth_metadata = metadata
|
||||
|
|
@ -527,8 +527,8 @@ class OAuthClientProvider(httpx.Auth):
|
|||
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
|
||||
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
|
||||
|
||||
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
"""HTTPX auth flow integration."""
|
||||
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
|
||||
"""httpx2 auth flow integration."""
|
||||
async with self.context.lock:
|
||||
if not self._initialized:
|
||||
await self._initialize()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from httpx import Request, Response
|
||||
from httpx2 import Request, Response
|
||||
from mcp_types import LATEST_PROTOCOL_VERSION
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from types import TracebackType
|
|||
from typing import Any, Literal, TypeAlias, overload
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
import mcp_types as types
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Self
|
||||
|
|
@ -324,7 +324,7 @@ class ClientSessionGroup:
|
|||
else:
|
||||
httpx_client = create_mcp_http_client(
|
||||
headers=server_params.headers,
|
||||
timeout=httpx.Timeout(
|
||||
timeout=httpx2.Timeout(
|
||||
server_params.timeout,
|
||||
read=server_params.sse_read_timeout,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ from typing import Any
|
|||
from urllib.parse import parse_qs, urljoin, urlparse
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
import mcp_types as types
|
||||
from anyio.abc import TaskStatus
|
||||
from httpx_sse import SSEError, aconnect_sse
|
||||
from httpx2 import SSEError
|
||||
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared._context_streams import create_context_streams
|
||||
|
|
@ -34,7 +34,7 @@ async def sse_client(
|
|||
timeout: float = 5.0,
|
||||
sse_read_timeout: float = 300.0,
|
||||
httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
|
||||
auth: httpx.Auth | None = None,
|
||||
auth: httpx2.Auth | None = None,
|
||||
on_session_created: Callable[[str], None] | None = None,
|
||||
):
|
||||
"""Client transport for SSE.
|
||||
|
|
@ -47,15 +47,15 @@ async def sse_client(
|
|||
headers: Optional headers to include in requests.
|
||||
timeout: HTTP timeout for regular operations (in seconds).
|
||||
sse_read_timeout: Timeout for SSE read operations (in seconds).
|
||||
httpx_client_factory: Factory function for creating the HTTPX client.
|
||||
auth: Optional HTTPX authentication handler.
|
||||
httpx_client_factory: Factory function for creating the httpx2 client.
|
||||
auth: Optional httpx2 authentication handler.
|
||||
on_session_created: Optional callback invoked with the session ID when received.
|
||||
"""
|
||||
logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}")
|
||||
async with httpx_client_factory(
|
||||
headers=headers, auth=auth, timeout=httpx.Timeout(timeout, read=sse_read_timeout)
|
||||
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
|
||||
) as client:
|
||||
async with aconnect_sse(client, "GET", url) as event_source:
|
||||
async with client.sse(url) as event_source:
|
||||
event_source.response.raise_for_status()
|
||||
logger.debug("SSE connection established")
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ async def sse_client(
|
|||
|
||||
async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED):
|
||||
try:
|
||||
async for sse in event_source.aiter_sse(): # pragma: no branch
|
||||
async for sse in event_source: # pragma: no branch
|
||||
logger.debug(f"Received SSE event: {sse.event}")
|
||||
match sse.event:
|
||||
case "endpoint":
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ from contextlib import asynccontextmanager
|
|||
from dataclasses import dataclass
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
from anyio.abc import TaskGroup
|
||||
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
|
||||
from httpx2 import EventSource, ServerSentEvent
|
||||
from mcp_types import (
|
||||
CONNECTION_CLOSED,
|
||||
INTERNAL_ERROR,
|
||||
|
|
@ -66,7 +66,7 @@ class ResumptionError(StreamableHTTPError):
|
|||
class RequestContext:
|
||||
"""Context for a request operation."""
|
||||
|
||||
client: httpx.AsyncClient
|
||||
client: httpx2.AsyncClient
|
||||
session_id: str | None
|
||||
session_message: SessionMessage
|
||||
metadata: ClientMessageMetadata | None
|
||||
|
|
@ -113,7 +113,7 @@ class StreamableHTTPTransport:
|
|||
def _prepare_headers(self) -> dict[str, str]:
|
||||
"""Build MCP-specific request headers for any outbound HTTP request.
|
||||
|
||||
These are merged with the ``httpx.AsyncClient`` defaults (these take
|
||||
These are merged with the ``httpx2.AsyncClient`` defaults (these take
|
||||
precedence). The cached ``MCP-Protocol-Version`` is included whenever
|
||||
present so messages that don't pass through the session's stamp —
|
||||
response/error POSTs, legacy cancel frames, transport-internal
|
||||
|
|
@ -138,7 +138,7 @@ class StreamableHTTPTransport:
|
|||
"""Check if the message is an initialized notification."""
|
||||
return isinstance(message, JSONRPCNotification) and message.method == "notifications/initialized"
|
||||
|
||||
def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> None:
|
||||
def _maybe_extract_session_id_from_response(self, response: httpx2.Response) -> None:
|
||||
"""Extract and store session ID from response headers."""
|
||||
new_session_id = response.headers.get(MCP_SESSION_ID)
|
||||
if new_session_id:
|
||||
|
|
@ -195,7 +195,7 @@ class StreamableHTTPTransport:
|
|||
logger.warning(f"Unknown SSE event: {sse.event}")
|
||||
return False
|
||||
|
||||
async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: StreamWriter) -> None:
|
||||
async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer: StreamWriter) -> None:
|
||||
"""Handle GET stream for server-initiated messages with auto-reconnect."""
|
||||
last_event_id: str | None = None
|
||||
retry_interval_ms: int | None = None
|
||||
|
|
@ -210,11 +210,11 @@ class StreamableHTTPTransport:
|
|||
if last_event_id:
|
||||
headers[LAST_EVENT_ID] = last_event_id
|
||||
|
||||
async with aconnect_sse(client, "GET", self.url, headers=headers) as event_source:
|
||||
async with client.sse(self.url, headers=headers) as event_source:
|
||||
event_source.response.raise_for_status()
|
||||
logger.debug("GET SSE connection established")
|
||||
|
||||
async for sse in event_source.aiter_sse():
|
||||
async for sse in event_source:
|
||||
# Track last event ID for reconnection
|
||||
if sse.id:
|
||||
last_event_id = sse.id
|
||||
|
|
@ -253,11 +253,11 @@ class StreamableHTTPTransport:
|
|||
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
|
||||
original_request_id = ctx.session_message.message.id
|
||||
|
||||
async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source:
|
||||
async with ctx.client.sse(self.url, headers=headers) as event_source:
|
||||
event_source.response.raise_for_status()
|
||||
logger.debug("Resumption GET SSE connection established")
|
||||
|
||||
async for sse in event_source.aiter_sse(): # pragma: no branch
|
||||
async for sse in event_source: # pragma: no branch
|
||||
is_complete = await self._handle_sse_event(
|
||||
sse,
|
||||
ctx.read_stream_writer,
|
||||
|
|
@ -355,7 +355,7 @@ class StreamableHTTPTransport:
|
|||
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
|
||||
await ctx.read_stream_writer.send(SessionMessage(reply))
|
||||
return
|
||||
except (httpx.StreamError, ValidationError):
|
||||
except (httpx2.StreamError, ValidationError):
|
||||
pass
|
||||
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
|
||||
if response.status_code == 404:
|
||||
|
|
@ -391,7 +391,7 @@ class StreamableHTTPTransport:
|
|||
|
||||
async def _handle_json_response(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
response: httpx2.Response,
|
||||
read_stream_writer: StreamWriter,
|
||||
*,
|
||||
request_id: RequestId,
|
||||
|
|
@ -402,7 +402,7 @@ class StreamableHTTPTransport:
|
|||
message = jsonrpc_message_adapter.validate_json(content, by_name=False)
|
||||
session_message = SessionMessage(message)
|
||||
await read_stream_writer.send(session_message)
|
||||
except (httpx.StreamError, ValidationError) as exc:
|
||||
except (httpx2.StreamError, ValidationError) as exc:
|
||||
logger.exception("Error parsing JSON response")
|
||||
error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse JSON response: {exc}")
|
||||
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data))
|
||||
|
|
@ -410,7 +410,7 @@ class StreamableHTTPTransport:
|
|||
|
||||
async def _handle_sse_response(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
response: httpx2.Response,
|
||||
ctx: RequestContext,
|
||||
) -> None:
|
||||
"""Handle SSE response from the server."""
|
||||
|
|
@ -424,7 +424,7 @@ class StreamableHTTPTransport:
|
|||
|
||||
try:
|
||||
event_source = EventSource(response)
|
||||
async for sse in event_source.aiter_sse(): # pragma: no branch
|
||||
async for sse in event_source: # pragma: no branch
|
||||
# Track last event ID for potential reconnection
|
||||
if sse.id:
|
||||
last_event_id = sse.id
|
||||
|
|
@ -501,7 +501,7 @@ class StreamableHTTPTransport:
|
|||
headers[LAST_EVENT_ID] = last_event_id
|
||||
|
||||
try:
|
||||
async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source:
|
||||
async with ctx.client.sse(self.url, headers=headers) as event_source:
|
||||
event_source.response.raise_for_status()
|
||||
logger.info("Reconnected to SSE stream")
|
||||
|
||||
|
|
@ -509,7 +509,7 @@ class StreamableHTTPTransport:
|
|||
reconnect_last_event_id: str = last_event_id
|
||||
reconnect_retry_ms = retry_interval_ms
|
||||
|
||||
async for sse in event_source.aiter_sse():
|
||||
async for sse in event_source:
|
||||
if sse.id: # pragma: no branch
|
||||
reconnect_last_event_id = sse.id
|
||||
if sse.retry is not None:
|
||||
|
|
@ -535,7 +535,7 @@ class StreamableHTTPTransport:
|
|||
|
||||
async def post_writer(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
client: httpx2.AsyncClient,
|
||||
write_stream_reader: StreamReader,
|
||||
read_stream_writer: StreamWriter,
|
||||
write_stream: ContextSendStream[SessionMessage],
|
||||
|
|
@ -619,7 +619,7 @@ class StreamableHTTPTransport:
|
|||
except Exception: # pragma: lax no cover
|
||||
logger.exception("Error in post_writer")
|
||||
|
||||
async def terminate_session(self, client: httpx.AsyncClient) -> None:
|
||||
async def terminate_session(self, client: httpx2.AsyncClient) -> None:
|
||||
"""Terminate the session by sending a DELETE request."""
|
||||
if not self.session_id:
|
||||
return # pragma: no cover
|
||||
|
|
@ -648,16 +648,16 @@ class StreamableHTTPTransport:
|
|||
async def streamable_http_client(
|
||||
url: str,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
http_client: httpx2.AsyncClient | None = None,
|
||||
terminate_on_close: bool = True,
|
||||
) -> AsyncGenerator[TransportStreams, None]:
|
||||
"""Client transport for StreamableHTTP.
|
||||
|
||||
Args:
|
||||
url: The MCP server endpoint URL.
|
||||
http_client: Optional pre-configured httpx.AsyncClient. If None, a default
|
||||
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
|
||||
client with recommended MCP timeouts will be created. To configure headers,
|
||||
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
|
||||
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
|
||||
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
|
||||
|
||||
Yields:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ contribution kinds: tools, resources, new request methods, and one `tools/call`
|
|||
interceptor. The server never hands itself to an extension; the extension
|
||||
declares what it adds, and the server consumes it.
|
||||
|
||||
The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class whose
|
||||
The shape follows the httpx2 `Transport`/`Auth` pattern: a narrow base class whose
|
||||
methods have sensible defaults, so an extension overrides only what it needs. A
|
||||
purely additive extension (Apps) overrides `tools`/`resources`; an interceptive
|
||||
one overrides `methods`/`intercept_tool_call`.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
|||
|
||||
import anyio
|
||||
import anyio.to_thread
|
||||
import httpx
|
||||
import httpx2
|
||||
import pydantic
|
||||
import pydantic_core
|
||||
from mcp_types import Annotations, Icon, InputRequiredResult
|
||||
|
|
@ -170,7 +170,7 @@ class HttpResource(Resource):
|
|||
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the HTTP content."""
|
||||
async with httpx.AsyncClient() as client: # pragma: no cover
|
||||
async with httpx2.AsyncClient() as client: # pragma: no cover
|
||||
response = await client.get(self.url)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Utilities for creating standardized httpx AsyncClient instances."""
|
||||
"""Utilities for creating standardized httpx2 AsyncClient instances."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"]
|
||||
|
||||
|
|
@ -15,28 +15,28 @@ class McpHttpClientFactory(Protocol): # pragma: no branch
|
|||
def __call__( # pragma: no branch
|
||||
self,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient: ...
|
||||
timeout: httpx2.Timeout | None = None,
|
||||
auth: httpx2.Auth | None = None,
|
||||
) -> httpx2.AsyncClient: ...
|
||||
|
||||
|
||||
def create_mcp_http_client(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
"""Create a standardized httpx AsyncClient with MCP defaults.
|
||||
timeout: httpx2.Timeout | None = None,
|
||||
auth: httpx2.Auth | None = None,
|
||||
) -> httpx2.AsyncClient:
|
||||
"""Create a standardized httpx2 AsyncClient with MCP defaults.
|
||||
|
||||
Always enables follow_redirects and applies an SSE-friendly default timeout.
|
||||
|
||||
Args:
|
||||
headers: Optional headers to include with all requests.
|
||||
timeout: Request timeout as httpx.Timeout object. Defaults to 30s for
|
||||
timeout: Request timeout as httpx2.Timeout object. Defaults to 30s for
|
||||
connect/write/pool and 300s for read (for long-lived SSE streams).
|
||||
auth: Optional authentication handler.
|
||||
|
||||
Returns:
|
||||
Configured httpx.AsyncClient instance with MCP defaults.
|
||||
Configured httpx2.AsyncClient instance with MCP defaults.
|
||||
|
||||
Note:
|
||||
The returned AsyncClient must be used as a context manager to ensure
|
||||
|
|
@ -61,7 +61,7 @@ def create_mcp_http_client(
|
|||
With both custom headers and timeout:
|
||||
|
||||
```python
|
||||
timeout = httpx.Timeout(60.0, read=300.0)
|
||||
timeout = httpx2.Timeout(60.0, read=300.0)
|
||||
async with create_mcp_http_client(headers, timeout) as client:
|
||||
response = await client.get("/long-request")
|
||||
```
|
||||
|
|
@ -69,7 +69,7 @@ def create_mcp_http_client(
|
|||
With authentication:
|
||||
|
||||
```python
|
||||
from httpx import BasicAuth
|
||||
from httpx2 import BasicAuth
|
||||
auth = BasicAuth(username="user", password="pass")
|
||||
async with create_mcp_http_client(headers, timeout, auth) as client:
|
||||
response = await client.get("/protected-endpoint")
|
||||
|
|
@ -80,7 +80,7 @@ def create_mcp_http_client(
|
|||
|
||||
# Handle timeout
|
||||
if timeout is None:
|
||||
kwargs["timeout"] = httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
|
||||
kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
|
||||
else:
|
||||
kwargs["timeout"] = timeout
|
||||
|
||||
|
|
@ -92,4 +92,4 @@ def create_mcp_http_client(
|
|||
if auth is not None: # pragma: no cover
|
||||
kwargs["auth"] = auth
|
||||
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
return httpx2.AsyncClient(**kwargs)
|
||||
|
|
|
|||
Loading…
Reference in a new issue