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
110 changed files with 1029 additions and 948 deletions
12
.github/actions/conformance/client.py
vendored
12
.github/actions/conformance/client.py
vendored
|
|
@ -38,7 +38,7 @@ from collections.abc import Callable, Coroutine
|
|||
from typing import Any, cast
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import mcp_types as types
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import AnyUrl
|
||||
|
|
@ -151,7 +151,7 @@ class ConformanceOAuthCallbackHandler:
|
|||
"""Fetch the authorization URL and extract the auth code from the redirect."""
|
||||
logger.debug(f"Fetching authorization URL: {authorization_url}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx2.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
authorization_url,
|
||||
follow_redirects=False,
|
||||
|
|
@ -486,13 +486,13 @@ async def run_enterprise_managed_authorization(server_url: str) -> None:
|
|||
# learn it from the harness's PRM document (RFC 9728); production
|
||||
# deployments would supply it as static configuration instead.
|
||||
prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0]
|
||||
async with httpx.AsyncClient(timeout=30.0) as http:
|
||||
async with httpx2.AsyncClient(timeout=30.0) as http:
|
||||
prm = (await http.get(prm_url)).raise_for_status().json()
|
||||
as_issuer = prm["authorization_servers"][0]
|
||||
|
||||
async def fetch_id_jag(audience: str, resource: str) -> str:
|
||||
"""Leg 1 - RFC 8693 token-exchange at the enterprise IdP."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as http:
|
||||
async with httpx2.AsyncClient(timeout=30.0) as http:
|
||||
resp = await http.post(
|
||||
idp_token_endpoint,
|
||||
data={
|
||||
|
|
@ -563,9 +563,9 @@ async def run_auth_code_client(server_url: str) -> None:
|
|||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None:
|
||||
async def _run_auth_session(server_url: str, oauth_auth: httpx2.Auth) -> None:
|
||||
"""Common session logic for all OAuth flows."""
|
||||
http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
|
||||
http_client = httpx2.AsyncClient(auth=oauth_auth, timeout=30.0)
|
||||
transport = streamable_http_client(url=server_url, http_client=http_client)
|
||||
async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
|
||||
logger.debug("Initialized successfully")
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Everything below is the second request: the client that sends it and the authori
|
|||
|
||||
## The client
|
||||
|
||||
**`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.
|
||||
**`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.
|
||||
|
||||
```python title="client.py" hl_lines="49-50 53-61"
|
||||
--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
|
|||
|
||||
* [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**.
|
||||
* 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.
|
||||
* `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.
|
||||
* `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.
|
||||
* 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.
|
||||
* 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`.
|
||||
|
||||
**`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.
|
||||
**`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.
|
||||
|
||||
This page is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**.
|
||||
|
||||
|
|
@ -68,9 +68,9 @@ A real client runs a small local HTTP server on the redirect URI instead of call
|
|||
|
||||
### Into the `Client`
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
`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)**.
|
||||
`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)**.
|
||||
|
||||
## What the provider does for you
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ The URL must be HTTPS with a non-root path; anything else is a `ValueError` at c
|
|||
|
||||
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.
|
||||
|
||||
`ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the human:
|
||||
`ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the human:
|
||||
|
||||
```python title="client.py" hl_lines="4 27-33"
|
||||
--8<-- "docs_src/oauth_clients/tutorial002.py"
|
||||
|
|
@ -113,7 +113,7 @@ What changed:
|
|||
|
||||
* 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.
|
||||
* `scopes` is a space-separated string, the OAuth wire format.
|
||||
* Everything downstream is identical: the same `TokenStorage`, the same `httpx.AsyncClient(auth=...)`, the same `streamable_http_client`.
|
||||
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -133,11 +133,11 @@ There is one more no-human situation: the client belongs to an enterprise whose
|
|||
|
||||
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.
|
||||
|
||||
Not everything is a flow error. The network can still fail; those are ordinary `httpx` exceptions and pass through untouched.
|
||||
Not everything is a flow error. The network can still fail; those are ordinary `httpx2` exceptions and pass through untouched.
|
||||
|
||||
## Recap
|
||||
|
||||
* `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.
|
||||
* `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.
|
||||
* You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair.
|
||||
* `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens.
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
|
|||
--8<-- "docs_src/client_transports/tutorial002.py"
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
!!! check
|
||||
A `Client` you have constructed is **not** connected. Construction only picks the transport;
|
||||
|
|
@ -41,9 +41,9 @@ That is the whole production client. `Client` wraps the URL in `streamable_http_
|
|||
|
||||
Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free.
|
||||
|
||||
### Bring your own `httpx.AsyncClient`
|
||||
### Bring your own `httpx2.AsyncClient`
|
||||
|
||||
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`:
|
||||
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`:
|
||||
|
||||
```python title="client.py" hl_lines="8-14"
|
||||
--8<-- "docs_src/client_transports/tutorial003.py"
|
||||
|
|
@ -51,9 +51,16 @@ The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a dif
|
|||
|
||||
Two things to notice:
|
||||
|
||||
* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
|
||||
* You own the `httpx2.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
|
||||
* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else.
|
||||
|
||||
One TLS note: `httpx2` verifies certificates against the operating system trust store (via
|
||||
[`truststore`](https://pypi.org/project/truststore/)), not a bundled CA list. In an environment with
|
||||
no usable system CA store (some minimal containers), set the standard `SSL_CERT_FILE`/`SSL_CERT_DIR`
|
||||
environment variables or pass an explicit `verify=ssl_context` to your `httpx2.AsyncClient`
|
||||
(background in
|
||||
[`httpx` and `httpx-sse` replaced by `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)).
|
||||
|
||||
!!! warning
|
||||
`streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more:
|
||||
its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out
|
||||
|
|
@ -63,12 +70,13 @@ Two things to notice:
|
|||
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
|
||||
```
|
||||
|
||||
Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in.
|
||||
Everything HTTP-shaped now lives on the one `httpx2.AsyncClient` you pass in.
|
||||
|
||||
!!! info
|
||||
If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection
|
||||
limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in:
|
||||
`httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
|
||||
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth,
|
||||
proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes
|
||||
nothing away. It is also where OAuth plugs in:
|
||||
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
|
||||
|
||||
## stdio
|
||||
|
||||
|
|
@ -106,7 +114,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair
|
|||
|
||||
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
|
||||
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
|
||||
* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
|
||||
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
|
||||
* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
|
||||
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ You don't need to know any of this to use the SDK, but if you're wondering what
|
|||
* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`.
|
||||
* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation.
|
||||
* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files.
|
||||
* [`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.
|
||||
* [`httpx2`](https://pypi.org/project/httpx2/): the HTTP client behind the Streamable HTTP and SSE *client* transports, with server-sent events support built in.
|
||||
* [`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.
|
||||
* [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema.
|
||||
* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ v2 raises the minimum versions of several shared dependencies and adds new requi
|
|||
| pywin32 (Windows) | `>=310` (Python <3.14) | `>=311` | floor raised on Python <3.14 |
|
||||
| opentelemetry-api | not a dependency | `>=1.28.0` | new required dependency |
|
||||
| mcp-types | not a dependency | `==<exact mcp version>` | new, exact-pinned |
|
||||
| httpx | `>=0.27.1,<1.0.0` | removed | see [`httpx` and `httpx-sse` replaced by `httpx2`](#httpx-and-httpx-sse-replaced-by-httpx2) |
|
||||
| httpx-sse | `>=0.4` | removed | see [`httpx` and `httpx-sse` replaced by `httpx2`](#httpx-and-httpx-sse-replaced-by-httpx2) |
|
||||
| httpx2 | not a dependency | `>=2.5.0` | new required dependency |
|
||||
| `ws` extra | `websockets>=15.0.1` | removed | see [WebSocket transport removed](#websocket-transport-removed) |
|
||||
|
||||
**Before (v1):**
|
||||
|
|
@ -89,6 +92,68 @@ dependencies = [
|
|||
|
||||
Relax or bump any conflicting pins when upgrading. sse-starlette jumps two majors, so a project that imports `sse_starlette` itself must also work through that library's own breaking changes to co-install with mcp v2. `opentelemetry-api` is a new hard dependency because every outbound request now carries a `_meta` envelope used for OpenTelemetry trace propagation; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default). `mcp-types` is exact-pinned to the SDK version; nothing in a v1 tree can conflict with it, but do not pin `mcp-types` independently of `mcp`.
|
||||
|
||||
### `httpx` and `httpx-sse` replaced by `httpx2`
|
||||
|
||||
The SDK now depends on [`httpx2`](https://pypi.org/project/httpx2/) instead of
|
||||
`httpx` and `httpx-sse`. `httpx2` is the next-generation HTTP client (a fork of
|
||||
`httpx`) with server-sent events support built in, so the separate `httpx-sse`
|
||||
dependency is gone.
|
||||
|
||||
The swap itself does not change any SDK signatures - `streamable_http_client`
|
||||
and `sse_client` accept the same arguments as elsewhere in v2 - but the client
|
||||
type they expect is now `httpx2.AsyncClient`. If you construct your own client to pass as
|
||||
`http_client` (or build an `httpx2.Auth` subclass for `auth`), import from
|
||||
`httpx2`:
|
||||
|
||||
**Before (v1):**
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
http_client = httpx.AsyncClient(follow_redirects=True)
|
||||
```
|
||||
|
||||
**After (v2):**
|
||||
|
||||
```python
|
||||
import httpx2
|
||||
|
||||
http_client = httpx2.AsyncClient(follow_redirects=True)
|
||||
```
|
||||
|
||||
`httpx2` is API-compatible with `httpx`, so usually only the import name
|
||||
changes. To consume SSE directly, use `httpx2.EventSource` (or
|
||||
`AsyncClient.sse()`) instead of the `httpx-sse` helpers.
|
||||
|
||||
Exception handlers need the same rename: the SDK now raises `httpx2`
|
||||
exceptions (`httpx2.ConnectError`, `httpx2.HTTPStatusError`, and so on), and
|
||||
this failure mode is silent. `httpx` usually stays installed as a transitive
|
||||
dependency of other packages, so an old `except httpx.ConnectError:` block
|
||||
keeps importing fine and simply never matches again. Audit `except httpx.`
|
||||
clauses and `isinstance` checks along with the imports. The same identity
|
||||
split applies to objects: `httpx` and `httpx2` types are not interchangeable
|
||||
at runtime, so an `httpx.AsyncClient` passed as `http_client` degrades in
|
||||
subtle ways (server-initiated messages stop arriving) instead of raising
|
||||
immediately.
|
||||
|
||||
The client also identifies itself differently: the default User-Agent is now
|
||||
`python-httpx2/<version>`, and log lines come from the `httpx2` and
|
||||
`httpcore2.*` loggers, so a `logging.getLogger("httpx")` or
|
||||
`logging.getLogger("httpcore")` suppression no longer matches anything.
|
||||
Telemetry integrations keyed to the `httpx` module (such as OpenTelemetry's
|
||||
httpx instrumentation) stop seeing the SDK's traffic as well.
|
||||
|
||||
TLS verification also changes: `httpx` validated certificates against the
|
||||
bundled `certifi` CA list, while `httpx2` validates against the operating
|
||||
system trust store via [`truststore`](https://pypi.org/project/truststore/).
|
||||
If your environment has no usable system CA store (some minimal containers),
|
||||
or you relied on certifi's bundle specifically, point the standard
|
||||
`SSL_CERT_FILE` or `SSL_CERT_DIR` environment variable at a CA bundle
|
||||
(`httpx2` honors these before falling back to the system store), or pass an
|
||||
explicit `verify=ssl_context` to your `httpx2.AsyncClient`. Passing a CA
|
||||
bundle path as `verify="ca.pem"` or using the `cert=` parameter is deprecated
|
||||
in `httpx2`; build an `ssl.SSLContext` and configure it instead.
|
||||
|
||||
### `mcp dev` and `mcp install` pin the spawned environment to your SDK version
|
||||
|
||||
Both commands run your server through a fresh `uv run --with ...` environment. In v1 the
|
||||
|
|
@ -111,7 +176,7 @@ unchanged. Only the `mcp.types` submodule and `mcp.shared.version` were removed.
|
|||
package's API reference is at [`mcp_types`](api/mcp_types/index.md).
|
||||
|
||||
**Why:** keeping the wire types in their own package lets tooling and lightweight clients
|
||||
depend on the protocol schema without pulling in `httpx`, `starlette`, `uvicorn`, and the
|
||||
depend on the protocol schema without pulling in `httpx2`, `starlette`, `uvicorn`, and the
|
||||
rest of the server/transport stack.
|
||||
|
||||
**Before (v1):**
|
||||
|
|
@ -1594,13 +1659,13 @@ async with streamablehttp_client(
|
|||
**After (v2):**
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
# Configure headers, timeout, and auth on the httpx.AsyncClient
|
||||
http_client = httpx.AsyncClient(
|
||||
# Configure headers, timeout, and auth on the httpx2.AsyncClient
|
||||
http_client = httpx2.AsyncClient(
|
||||
headers={"Authorization": "Bearer token"},
|
||||
timeout=httpx.Timeout(30, read=300),
|
||||
timeout=httpx2.Timeout(30, read=300),
|
||||
auth=my_auth,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
|
@ -1613,7 +1678,7 @@ async with http_client:
|
|||
...
|
||||
```
|
||||
|
||||
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
|
||||
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior.
|
||||
|
||||
### `get_session_id` callback removed from `streamable_http_client`
|
||||
|
||||
|
|
@ -1621,7 +1686,7 @@ The `get_session_id` callback (third element of the returned tuple) has been rem
|
|||
|
||||
The `GetSessionIdCallback` type alias is gone as well, so `from mcp.client.streamable_http import GetSessionIdCallback` now raises `ImportError`. Drop the annotation, or inline `Callable[[], str | None]` if your own wrapper code still needs the type.
|
||||
|
||||
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers:
|
||||
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx2 event hooks to capture it from the response headers:
|
||||
|
||||
**Before (v1):**
|
||||
|
||||
|
|
@ -1637,7 +1702,7 @@ async with streamable_http_client(url) as (read_stream, write_stream, get_sessio
|
|||
**After (v2):**
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
# Option 1: Simply ignore if you don't need the session ID
|
||||
|
|
@ -1645,15 +1710,15 @@ async with streamable_http_client(url) as (read_stream, write_stream):
|
|||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Option 2: Capture session ID via httpx event hooks if needed
|
||||
# Option 2: Capture session ID via httpx2 event hooks if needed
|
||||
captured_session_ids: list[str] = []
|
||||
|
||||
async def capture_session_id(response: httpx.Response) -> None:
|
||||
async def capture_session_id(response: httpx2.Response) -> None:
|
||||
session_id = response.headers.get("mcp-session-id")
|
||||
if session_id:
|
||||
captured_session_ids.append(session_id)
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
http_client = httpx2.AsyncClient(
|
||||
event_hooks={"response": [capture_session_id]},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
|
@ -1667,7 +1732,7 @@ async with http_client:
|
|||
|
||||
### `StreamableHTTPTransport` parameters removed
|
||||
|
||||
The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above).
|
||||
The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx2.AsyncClient` instead (see example above).
|
||||
|
||||
Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed.
|
||||
|
||||
|
|
@ -1730,7 +1795,7 @@ async with streamable_http_client(url) as (read, write):
|
|||
raise
|
||||
```
|
||||
|
||||
Move HTTP-status failure handling from around the transport context to around the individual calls, catching `MCPError` (see [`McpError` renamed to `MCPError`](#mcperror-renamed-to-mcperror)). Connect-level failures such as `httpx.ConnectError` still escape the transport context as before; keep context-level handling for those only.
|
||||
Move HTTP-status failure handling from around the transport context to around the individual calls, catching `MCPError` (see [`McpError` renamed to `MCPError`](#mcperror-renamed-to-mcperror)). Connect-level failures such as `httpx2.ConnectError` still escape the transport context as before; keep context-level handling for those only.
|
||||
|
||||
### `terminate_windows_process` removed
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ The renames announce themselves. These do not:
|
|||
* **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**.
|
||||
* **The streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**.
|
||||
* **`mcp dev` and `mcp install` pin the environment they spawn** to your installed SDK version. Both commands run your server in a fresh `uv run --with ...` environment, which used to resolve `mcp` to the newest stable release rather than the version you are developing against. **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**.
|
||||
* **The HTTP client is now `httpx2`, not `httpx`.** The dependency swap changes what your code catches and passes (`httpx2.AsyncClient`, `httpx2.ConnectError`), and it changes how TLS certificates are verified: `httpx2` validates through `truststore` against the operating system trust store instead of certifi's bundled CA list. Most environments never notice; a minimal container with no system CA store, or a private CA that only certifi's bundle knew about, starts failing the TLS handshake. Set `SSL_CERT_FILE`/`SSL_CERT_DIR` or pass `verify=ssl_context` to your client. **[Migration Guide](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**.
|
||||
|
||||
### Removed outright
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import httpx
|
||||
import httpx2
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
async with httpx2.AsyncClient(
|
||||
headers={"Authorization": "Bearer ..."},
|
||||
timeout=httpx.Timeout(30.0, read=300.0),
|
||||
timeout=httpx2.Timeout(30.0, read=300.0),
|
||||
follow_redirects=True,
|
||||
) as http_client:
|
||||
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import jwt
|
||||
|
||||
from mcp import Client
|
||||
|
|
@ -62,7 +62,7 @@ oauth = IdentityAssertionOAuthProvider(
|
|||
|
||||
|
||||
async def main() -> None:
|
||||
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
|
||||
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
|
||||
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
|
||||
async with Client(transport) as client:
|
||||
result = await client.list_tools()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import Client
|
||||
|
|
@ -55,7 +55,7 @@ oauth = OAuthClientProvider(
|
|||
|
||||
|
||||
async def main() -> None:
|
||||
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
|
||||
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
|
||||
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
|
||||
async with Client(transport) as client:
|
||||
result = await client.list_tools()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import httpx
|
||||
import httpx2
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
|
||||
|
|
@ -34,7 +34,7 @@ oauth = ClientCredentialsOAuthProvider(
|
|||
|
||||
|
||||
async def main() -> None:
|
||||
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
|
||||
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
|
||||
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
|
||||
async with Client(transport) as client:
|
||||
result = await client.list_tools()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
|||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp.client._transport import ReadStream, WriteStream
|
||||
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
|
||||
from mcp.client.session import ClientSession
|
||||
|
|
@ -233,7 +233,7 @@ class SimpleAuthClient:
|
|||
await self._run_session(read_stream, write_stream)
|
||||
else:
|
||||
print("📡 Opening StreamableHTTP transport connection with auth...")
|
||||
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
|
||||
async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
|
||||
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import shutil
|
|||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from dotenv import load_dotenv
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
|
@ -227,10 +227,7 @@ class LLMClient:
|
|||
messages: A list of message dictionaries.
|
||||
|
||||
Returns:
|
||||
The LLM's response as a string.
|
||||
|
||||
Raises:
|
||||
httpx.RequestError: If the request to the LLM fails.
|
||||
The LLM's response as a string, or an error message if the request fails.
|
||||
"""
|
||||
url = "https://api.groq.com/openai/v1/chat/completions"
|
||||
|
||||
|
|
@ -249,17 +246,17 @@ class LLMClient:
|
|||
}
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
with httpx2.Client() as client:
|
||||
response = client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
except httpx.RequestError as e:
|
||||
except httpx2.HTTPError as e:
|
||||
error_message = f"Error getting LLM response: {str(e)}"
|
||||
logging.error(error_message)
|
||||
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
if isinstance(e, httpx2.HTTPStatusError):
|
||||
status_code = e.response.status_code
|
||||
logging.error(f"Status code: {status_code}")
|
||||
logging.error(f"Response details: {e.response.text}")
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None:
|
|||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
# Suppress noisy HTTP client logging
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx2").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore2").setLevel(logging.WARNING)
|
||||
|
||||
asyncio.run(run_demo(url, items, checkpoint_every))
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Visit https://surgemsg.com/ and click "Get Started" to obtain these values.
|
|||
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from pydantic import BeforeValidator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ surge_settings = SurgeSettings() # type: ignore
|
|||
@mcp.tool(name="textme", description="Send a text message to me")
|
||||
def text_me(text_content: str) -> str:
|
||||
"""Send a text message to a phone number via https://surgemsg.com/"""
|
||||
with httpx.Client() as client:
|
||||
with httpx2.Client() as client:
|
||||
response = client.post(
|
||||
"https://api.surgemsg.com/messages",
|
||||
headers={
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ requires-python = ">=3.10"
|
|||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "conformance", "testing"]
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp", "starlette", "uvicorn"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-everything-server = "mcp_everything_server.server:main"
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
|
|||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token via introspection endpoint."""
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
# Validate URL to prevent SSRF attacks
|
||||
if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
||||
|
|
@ -41,10 +41,10 @@ class IntrospectionTokenVerifier(TokenVerifier):
|
|||
return None
|
||||
|
||||
# Configure secure HTTP client
|
||||
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
|
||||
timeout = httpx2.Timeout(10.0, connect=5.0)
|
||||
limits = httpx2.Limits(max_connections=10, max_keepalive_connections=5)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
async with httpx2.AsyncClient(
|
||||
timeout=timeout,
|
||||
limits=limits,
|
||||
verify=True, # Enforce SSL verification
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ license = { text = "MIT" }
|
|||
dependencies = [
|
||||
"anyio>=4.5",
|
||||
"click>=8.2.0",
|
||||
"httpx>=0.27",
|
||||
"httpx2>=2.5.0",
|
||||
"mcp",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.5.2",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"]
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-pagination = "mcp_simple_pagination.server:main"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"]
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-prompt = "mcp_simple_prompt.server:main"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue