Skip to content

Commit 2713b53

Browse files
Kludexmaxisbey
andauthored
Replace httpx and httpx-sse with httpx2 (#2972)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent 1216c53 commit 2713b53

110 files changed

Lines changed: 1029 additions & 948 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/conformance/client.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from typing import Any, cast
3939
from urllib.parse import parse_qs, urlparse
4040

41-
import httpx
41+
import httpx2
4242
import mcp_types as types
4343
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
4444
from pydantic import AnyUrl
@@ -151,7 +151,7 @@ async def handle_redirect(self, authorization_url: str) -> None:
151151
"""Fetch the authorization URL and extract the auth code from the redirect."""
152152
logger.debug(f"Fetching authorization URL: {authorization_url}")
153153

154-
async with httpx.AsyncClient() as client:
154+
async with httpx2.AsyncClient() as client:
155155
response = await client.get(
156156
authorization_url,
157157
follow_redirects=False,
@@ -486,13 +486,13 @@ async def run_enterprise_managed_authorization(server_url: str) -> None:
486486
# learn it from the harness's PRM document (RFC 9728); production
487487
# deployments would supply it as static configuration instead.
488488
prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0]
489-
async with httpx.AsyncClient(timeout=30.0) as http:
489+
async with httpx2.AsyncClient(timeout=30.0) as http:
490490
prm = (await http.get(prm_url)).raise_for_status().json()
491491
as_issuer = prm["authorization_servers"][0]
492492

493493
async def fetch_id_jag(audience: str, resource: str) -> str:
494494
"""Leg 1 - RFC 8693 token-exchange at the enterprise IdP."""
495-
async with httpx.AsyncClient(timeout=30.0) as http:
495+
async with httpx2.AsyncClient(timeout=30.0) as http:
496496
resp = await http.post(
497497
idp_token_endpoint,
498498
data={
@@ -563,9 +563,9 @@ async def run_auth_code_client(server_url: str) -> None:
563563
await _run_auth_session(server_url, oauth_auth)
564564

565565

566-
async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None:
566+
async def _run_auth_session(server_url: str, oauth_auth: httpx2.Auth) -> None:
567567
"""Common session logic for all OAuth flows."""
568-
http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
568+
http_client = httpx2.AsyncClient(auth=oauth_auth, timeout=30.0)
569569
transport = streamable_http_client(url=server_url, http_client=http_client)
570570
async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
571571
logger.debug("Initialized successfully")

docs/client/identity-assertion.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Everything below is the second request: the client that sends it and the authori
1919

2020
## The client
2121

22-
**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport.
22+
**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx2.Auth`: construct one, put it on `auth=`, hand the `httpx2.AsyncClient` to the transport.
2323

2424
```python title="client.py" hl_lines="49-50 53-61"
2525
--8<-- "docs_src/identity_assertion/tutorial001.py"
@@ -139,7 +139,7 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I
139139

140140
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**.
141141
* Obtaining the ID-JAG is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant, and the SDK does both sides of that.
142-
* `IdentityAssertionOAuthProvider` is another `httpx.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token.
142+
* `IdentityAssertionOAuthProvider` is another `httpx2.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token.
143143
* The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character.
144144
* Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's.
145145

docs/client/oauth-clients.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`.
44

5-
**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it.
5+
**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx2.Auth`, the standard httpx2 hook for "do something to every request". You attach it to an `httpx2.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it.
66

77
This page is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**.
88

@@ -68,9 +68,9 @@ A real client runs a small local HTTP server on the redirect URI instead of call
6868

6969
### Into the `Client`
7070

71-
Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`.
71+
Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`.
7272

73-
`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](transports.md)**.
73+
`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx2.AsyncClient` you bring. That layering is **[Client transports](transports.md)**.
7474

7575
## What the provider does for you
7676

@@ -103,7 +103,7 @@ The URL must be HTTPS with a non-root path; anything else is a `ValueError` at c
103103

104104
A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow.
105105

106-
`ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the human:
106+
`ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the human:
107107

108108
```python title="client.py" hl_lines="4 27-33"
109109
--8<-- "docs_src/oauth_clients/tutorial002.py"
@@ -113,7 +113,7 @@ What changed:
113113

114114
* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely.
115115
* `scopes` is a space-separated string, the OAuth wire format.
116-
* Everything downstream is identical: the same `TokenStorage`, the same `httpx.AsyncClient(auth=...)`, the same `streamable_http_client`.
116+
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
117117

118118
By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two.
119119

@@ -133,11 +133,11 @@ There is one more no-human situation: the client belongs to an enterprise whose
133133

134134
When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange.
135135

136-
Not everything is a flow error. The network can still fail; those are ordinary `httpx` exceptions and pass through untouched.
136+
Not everything is a flow error. The network can still fail; those are ordinary `httpx2` exceptions and pass through untouched.
137137

138138
## Recap
139139

140-
* `OAuthClientProvider` is an `httpx.Auth`. Put it on an `httpx.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened.
140+
* `OAuthClientProvider` is an `httpx2.Auth`. Put it on an `httpx2.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened.
141141
* You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair.
142142
* `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens.
143143
* Discovery, registration (dynamic, or via a **Client ID Metadata Document**), PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours.

docs/client/transports.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
2929
--8<-- "docs_src/client_transports/tutorial002.py"
3030
```
3131

32-
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
32+
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
3333

3434
!!! check
3535
A `Client` you have constructed is **not** connected. Construction only picks the transport;
@@ -41,19 +41,26 @@ That is the whole production client. `Client` wraps the URL in `streamable_http_
4141

4242
Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free.
4343

44-
### Bring your own `httpx.AsyncClient`
44+
### Bring your own `httpx2.AsyncClient`
4545

46-
The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`:
46+
The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx2.AsyncClient` yourself and hand it to `streamable_http_client`:
4747

4848
```python title="client.py" hl_lines="8-14"
4949
--8<-- "docs_src/client_transports/tutorial003.py"
5050
```
5151

5252
Two things to notice:
5353

54-
* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
54+
* You own the `httpx2.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
5555
* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else.
5656

57+
One TLS note: `httpx2` verifies certificates against the operating system trust store (via
58+
[`truststore`](https://pypi.org/project/truststore/)), not a bundled CA list. In an environment with
59+
no usable system CA store (some minimal containers), set the standard `SSL_CERT_FILE`/`SSL_CERT_DIR`
60+
environment variables or pass an explicit `verify=ssl_context` to your `httpx2.AsyncClient`
61+
(background in
62+
[`httpx` and `httpx-sse` replaced by `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)).
63+
5764
!!! warning
5865
`streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more:
5966
its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out
@@ -63,12 +70,13 @@ Two things to notice:
6370
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
6471
```
6572

66-
Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in.
73+
Everything HTTP-shaped now lives on the one `httpx2.AsyncClient` you pass in.
6774

6875
!!! info
69-
If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection
70-
limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in:
71-
`httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
76+
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth,
77+
proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes
78+
nothing away. It is also where OAuth plugs in:
79+
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
7280

7381
## stdio
7482

@@ -106,7 +114,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair
106114

107115
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
108116
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
109-
* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
117+
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
110118
* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
111119
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
112120
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol.

docs/get-started/installation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ You don't need to know any of this to use the SDK, but if you're wondering what
3535
* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`.
3636
* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation.
3737
* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files.
38-
* [`httpx`](https://www.python-httpx.org/) and [`httpx-sse`](https://pypi.org/project/httpx-sse/): the HTTP client behind the Streamable HTTP and SSE *client* transports.
38+
* [`httpx2`](https://pypi.org/project/httpx2/): the HTTP client behind the Streamable HTTP and SSE *client* transports, with server-sent events support built in.
3939
* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), and [`python-multipart`](https://pypi.org/project/python-multipart/): the HTTP *server* transports.
4040
* [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema.
4141
* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization.

0 commit comments

Comments
 (0)