Replace httpx and httpx-sse with httpx2 (#2972)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-07-14 18:05:08 +02:00 • committed by GitHub
commit 2713b53b12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1029 additions and 948 deletions

View file

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

View file

@ -1,4 +1,4 @@
"""OAuth2 Authentication implementation for HTTPX.
"""OAuth2 Authentication implementation for httpx2.
Implements authorization code flow with PKCE and automatic token refresh.
"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:

View file

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

View file

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

View file

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