From 18b5ab5852fb25e9bbd45ea9d94c62bd4a20baf4 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sat, 18 Jul 2026 15:12:47 -0400
Subject: [PATCH] Migrate to MCP SDK v2.0.0b2 (httpx2) (#4503)
---
docs/clients/auth/bearer.mdx | 2 +-
docs/clients/auth/oauth.mdx | 4 +-
docs/clients/transports.mdx | 2 +-
docs/development/v4-notes/change-register.mdx | 21 +-
.../upgrading/from-fastmcp-2.mdx | 6 +-
.../upgrading/from-fastmcp-3.mdx | 37 +++
docs/integrations/azure.mdx | 4 +-
docs/integrations/openapi.mdx | 10 +-
docs/servers/auth/remote-oauth.mdx | 4 +-
docs/servers/auth/token-verification.mdx | 10 +-
docs/servers/providers/custom.mdx | 4 +-
docs/servers/resources.mdx | 2 +-
docs/tutorials/rest-api.mdx | 10 +-
examples/apps/map/map_server.py | 4 +-
.../src/atproto_mcp/_atproto/_posts.py | 8 +-
examples/diagnostics/server.py | 4 +-
examples/text_me.py | 4 +-
fastmcp_slim/fastmcp/cli/apps_dev.py | 34 +--
fastmcp_slim/fastmcp/client/auth/bearer.py | 4 +-
fastmcp_slim/fastmcp/client/auth/oauth.py | 12 +-
fastmcp_slim/fastmcp/client/client.py | 6 +-
.../fastmcp/client/transports/base.py | 4 +-
.../fastmcp/client/transports/http.py | 32 +--
fastmcp_slim/fastmcp/client/transports/sse.py | 22 +-
fastmcp_slim/fastmcp/mcp_config.py | 6 +-
fastmcp_slim/fastmcp/resources/types.py | 4 +-
.../fastmcp/server/auth/oauth_proxy/proxy.py | 19 +-
.../server/auth/oauth_proxy/upstream.py | 147 ++++++++++
.../fastmcp/server/auth/oidc_proxy.py | 6 +-
.../fastmcp/server/auth/providers/azure.py | 10 +-
.../fastmcp/server/auth/providers/clerk.py | 14 +-
.../fastmcp/server/auth/providers/descope.py | 4 +-
.../fastmcp/server/auth/providers/discord.py | 14 +-
.../fastmcp/server/auth/providers/github.py | 14 +-
.../fastmcp/server/auth/providers/google.py | 14 +-
.../server/auth/providers/huggingface.py | 12 +-
.../server/auth/providers/introspection.py | 12 +-
.../fastmcp/server/auth/providers/jwt.py | 10 +-
.../server/auth/providers/propelauth.py | 6 +-
.../fastmcp/server/auth/providers/scalekit.py | 4 +-
.../fastmcp/server/auth/providers/supabase.py | 4 +-
.../fastmcp/server/auth/providers/workos.py | 16 +-
fastmcp_slim/fastmcp/server/auth/ssrf.py | 12 +-
.../server/providers/openapi/__init__.py | 4 +-
.../server/providers/openapi/components.py | 85 +++---
.../server/providers/openapi/provider.py | 14 +-
.../fastmcp/server/providers/proxy.py | 4 +-
fastmcp_slim/fastmcp/server/server.py | 49 ++--
fastmcp_slim/fastmcp/utilities/exceptions.py | 31 ++-
.../fastmcp/utilities/openapi/director.py | 14 +-
fastmcp_slim/fastmcp/utilities/tests.py | 4 +-
.../fastmcp/utilities/version_check.py | 6 +-
fastmcp_slim/pyproject.toml | 9 +-
pyproject.toml | 3 +-
tests/client/auth/test_oauth_cimd.py | 4 +-
tests/client/auth/test_oauth_client.py | 20 +-
tests/client/auth/test_oauth_static_client.py | 8 +-
tests/client/test_oauth_callback_race.py | 4 +-
tests/client/test_streamable_http.py | 4 +-
tests/client/transports/test_no_redirect.py | 18 +-
tests/client/transports/test_transports.py | 12 +-
tests/conftest.py | 1 +
.../auth/test_github_provider_integration.py | 14 +-
.../test_keycloak_provider_integration.py | 46 ++--
tests/integration_tests/conftest.py | 2 +-
.../oauth_proxy/test_client_registration.py | 6 +-
tests/server/auth/oauth_proxy/test_e2e.py | 4 +-
.../auth/oauth_proxy/test_oauth_proxy.py | 20 +-
.../auth/oauth_proxy/test_upstream_client.py | 183 +++++++++++++
tests/server/auth/providers/test_aws.py | 2 +-
tests/server/auth/providers/test_clerk.py | 6 +-
tests/server/auth/providers/test_descope.py | 2 +-
tests/server/auth/providers/test_discord.py | 2 +-
tests/server/auth/providers/test_github.py | 18 +-
tests/server/auth/providers/test_google.py | 2 +-
.../server/auth/providers/test_http_client.py | 50 ++--
.../server/auth/providers/test_huggingface.py | 2 +-
.../auth/providers/test_introspection.py | 6 +-
.../server/auth/providers/test_propelauth.py | 10 +-
tests/server/auth/providers/test_scalekit.py | 8 +-
tests/server/auth/providers/test_supabase.py | 2 +-
tests/server/auth/providers/test_workos.py | 4 +-
tests/server/auth/test_auth_provider.py | 14 +-
tests/server/auth/test_jwt_provider.py | 2 +-
tests/server/auth/test_jwt_provider_bearer.py | 2 +-
tests/server/auth/test_multi_auth.py | 22 +-
tests/server/auth/test_oauth_mounting.py | 30 +--
tests/server/auth/test_oidc_proxy.py | 6 +-
.../server/auth/test_remote_auth_provider.py | 46 ++--
tests/server/auth/test_ssrf_protection.py | 20 +-
.../server/auth/test_static_token_verifier.py | 10 +-
tests/server/http/test_http_middleware.py | 14 +-
.../providers/openapi/test_comprehensive.py | 54 ++--
.../openapi/test_deepobject_style.py | 12 +-
.../openapi/test_end_to_end_compatibility.py | 10 +-
.../openapi/test_legacy_client_compat.py | 166 ++++++++++++
.../openapi/test_openapi_features.py | 186 +++++++++----
.../openapi/test_openapi_performance.py | 8 +-
.../openapi/test_parameter_collisions.py | 8 +-
.../openapi/test_performance_comparison.py | 10 +-
tests/server/providers/openapi/test_server.py | 16 +-
tests/server/test_auth_integration.py | 30 +--
tests/server/test_auth_integration_errors.py | 26 +-
.../test_streamable_http_no_redirect.py | 6 +-
tests/server/test_tool_transformation.py | 4 +-
tests/test_no_legacy_httpx.py | 57 ++++
tests/utilities/httpx2_mock.py | 250 ++++++++++++++++++
.../openapi/test_circular_references.py | 4 +-
tests/utilities/openapi/test_director.py | 2 +-
.../utilities/openapi/test_nullable_fields.py | 6 +-
tests/utilities/test_version_check.py | 12 +-
uv.lock | 92 ++++---
112 files changed, 1709 insertions(+), 677 deletions(-)
create mode 100644 fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
create mode 100644 tests/server/auth/oauth_proxy/test_upstream_client.py
create mode 100644 tests/server/providers/openapi/test_legacy_client_compat.py
create mode 100644 tests/test_no_legacy_httpx.py
create mode 100644 tests/utilities/httpx2_mock.py
diff --git a/docs/clients/auth/bearer.mdx b/docs/clients/auth/bearer.mdx
index 2e12fbc13..3385eda3b 100644
--- a/docs/clients/auth/bearer.mdx
+++ b/docs/clients/auth/bearer.mdx
@@ -57,7 +57,7 @@ async with Client(transport) as client:
## `BearerAuth` Helper
-If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx.Auth` interface.
+If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx2.Auth` interface.
```python {6}
from fastmcp import Client
diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx
index 84fbe2164..3a237ef00 100644
--- a/docs/clients/auth/oauth.mdx
+++ b/docs/clients/auth/oauth.mdx
@@ -35,7 +35,7 @@ async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client
### `OAuth` Helper
-To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx.Auth` interface.
+To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx2.Auth` interface.
```python {2, 4, 6}
from fastmcp import Client
@@ -61,7 +61,7 @@ You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` —
- **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options
- **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration
- **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port
-- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients
+- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx2 clients
## OAuth Flow
diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx
index efcda3366..44c70c217 100644
--- a/docs/clients/transports.mdx
+++ b/docs/clients/transports.mdx
@@ -126,7 +126,7 @@ client = Client(
### SSL Verification
-By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as [httpx](https://www.python-httpx.org/advanced/ssl/):
+By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as httpx2 (documented in [httpx's SSL guide](https://www.python-httpx.org/advanced/ssl/), which httpx2 follows):
```python
from fastmcp import Client
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index 97259fb3d..7bd6c8b64 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -191,7 +191,7 @@ client = Client("my_mcp_server.py", timeout=30.0) # also works
### `get_session_id` via header sniff — Bridged
-The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx response event hook on the client it owns, capturing the `mcp-session-id` response header. The removal trigger is the upstream TODO.
+The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx2 response event hook on the client it owns, capturing the `mcp-session-id` response header (httpx2 preserves httpx's `event_hooks` API). The removal trigger is the upstream TODO.
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py` (`_capture_session_id`, `get_session_id`).
@@ -261,6 +261,23 @@ FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, w
*Verify:* `fastmcp_slim/fastmcp/server/http.py` (`enable_dns_rebinding_protection=False`, `HostOriginGuardMiddleware`).
+### httpx2 replaces httpx — Breaking (custom client/factory, typing) / Absorbed (everything else)
+
+SDK v2.0.0b2 replaces `httpx` + `httpx-sse` with [httpx2](https://pypi.org/project/httpx2/) (`>=2.5.0`), a next-generation httpx fork with built-in SSE. httpx2 is a near drop-in fork: the public API (`AsyncClient`, `Auth`, `Request`, `Response`, `Timeout`, `MockTransport`, exception hierarchy, `event_hooks`) matches httpx name-for-name. The SDK duck-types the client you hand it — `streamable_http_client(http_client=...)` and `sse_client(httpx_client_factory=...)` are type-hinted `httpx2.AsyncClient` with no `isinstance` gate — but the objects that cross into the SDK must be httpx2.
+
+FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier seam pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too.
+
+User-visible deltas:
+
+- **Custom client factory / client.** `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, and `OAuth(httpx_client_factory=...)` factories must now return `httpx2.AsyncClient`; a custom `httpx.Auth` passed as `Client(auth=...)` should become `httpx2.Auth`. httpx2 is a drop-in fork, so the change is an import swap (`import httpx` → `import httpx2`). This is a typing break; at runtime a duck-compatible httpx client still satisfies the SDK, but mixing `httpx.Timeout`/`httpx.Auth` with an httpx2 client is unsupported.
+- **OpenAPI client.** `FastMCP.from_openapi(client=...)` and `OpenAPIProvider(client=...)` are now type-hinted `httpx2.AsyncClient`. There is no `isinstance` gate, so an existing `httpx.AsyncClient` still works at runtime via duck-typing this release; the typing nudges you to httpx2.
+- **TLS trust store.** httpx2 verifies TLS against the OS trust store via `truststore` (honoring `SSL_CERT_FILE`/`SSL_CERT_DIR` first) instead of the bundled certifi CA set. This now applies to **all** FastMCP HTTP, including server-auth upstream IdP calls — not just the client path. Corporate-CA and certifi-pinned setups may see different trust behavior.
+- **Logger renames.** FastMCP HTTP now logs under `httpx2` and `httpcore2.*` (was `httpx`/`httpcore.*`). Anyone filtering FastMCP HTTP logs by logger name must update the names.
+
+The session-id header hook (below) works unchanged: httpx2 keeps httpx's `event_hooks` API. FastMCP's tool/resource/prompt handlers still map upstream 429/timeout errors to actionable `ToolError`/`ResourceError`; because a user's own tool may raise from either library, `server/server.py` catches both `httpx2` and (if installed) legacy `httpx` `HTTPStatusError`/`TimeoutException` via a defensive `try: import httpx` shim.
+
+*Verify:* `fastmcp_slim/pyproject.toml` (`mcp` extra lists only `httpx2`); no FastMCP source imports `httpx` except the documented defensive shim in `server/server.py`.
+
## Protocol eras
The SDK v2 serves multiple protocol eras from one server, and FastMCP formally embraces this.
@@ -343,7 +360,7 @@ Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard r
- **`fastmcp.experimental.server.openapi`** and **`fastmcp.experimental.utilities.openapi`** shims (deprecated 2.14) — Breaking. Import from `fastmcp.server.providers.openapi` and `fastmcp.utilities.openapi` respectively.
- **`fastmcp.server.apps`** and **`fastmcp.server.app`** shims (deprecated 3.2) — Breaking. Import from `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) instead.
- **`PromptToolMiddleware`** and **`ResourceToolMiddleware`** (deprecated 3.1) — Breaking. Use the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` instead. The non-deprecated `ToolInjectionMiddleware` base class is retained.
-- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`.
+- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx2 client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`.
### `FastMCP` server methods and `mount()` kwargs
diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx
index eef5d4e29..f066b6d0b 100644
--- a/docs/getting-started/upgrading/from-fastmcp-2.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx
@@ -68,7 +68,7 @@ BREAKING CHANGES (will crash at import or runtime):
6. WSTRANSPORT: Removed. Use StreamableHttpTransport.
-7. OPENAPI: timeout parameter removed from OpenAPIProvider. Set timeout on the httpx.AsyncClient instead.
+7. OPENAPI: timeout parameter removed from OpenAPIProvider. Set timeout on the httpx2.AsyncClient instead.
8. METADATA: Namespace changed from "_fastmcp" to "fastmcp" in tool.meta. The include_fastmcp_meta parameter is removed (always included).
@@ -276,14 +276,14 @@ transport = StreamableHttpTransport("http://localhost:8000/mcp")
**OpenAPI `timeout` parameter removed**
-`OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout:
+`OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx2 client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout:
```python
# Before
provider = OpenAPIProvider(spec, client, timeout=60)
# After
-client = httpx.AsyncClient(base_url="https://api.example.com", timeout=60)
+client = httpx2.AsyncClient(base_url="https://api.example.com", timeout=60)
provider = OpenAPIProvider(spec, client)
```
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index 50fe44fc2..eb132ca7c 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -114,6 +114,43 @@ Catching and `err.error.code` are unchanged — only construction moved.
**Raw session access sees v2 objects.** If you reach past FastMCP's client and server surfaces into `client.session`, `ctx.session`, or the internals of `ctx.request_context`, you're now holding raw SDK v2 objects with snake_case fields and the v2 method signatures. FastMCP does not wrap these; code that depends on their v1 shape needs updating.
+**FastMCP now uses httpx2 exclusively.** FastMCP has replaced `httpx` with [httpx2](https://pypi.org/project/httpx2/), a next-generation httpx fork, across its entire HTTP stack — client transports and every server-side path (auth providers, the OpenAPI integration, the version check). `httpx` is no longer a FastMCP dependency. If you pass a custom client or factory into a FastMCP client transport — `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, `OAuth(httpx_client_factory=...)`, or a custom `httpx.Auth` as `Client(auth=...)` — those objects must now be httpx2. httpx2 is a drop-in fork with the same public API, so the change is an import swap:
+
+```python
+# Before
+import httpx
+
+transport = StreamableHttpTransport(
+ "https://example.com/mcp",
+ httpx_client_factory=lambda **kwargs: httpx.AsyncClient(verify=False, **kwargs),
+)
+
+# After
+import httpx2
+
+transport = StreamableHttpTransport(
+ "https://example.com/mcp",
+ httpx_client_factory=lambda **kwargs: httpx2.AsyncClient(verify=False, **kwargs),
+)
+```
+
+The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) is now type-hinted `httpx2.AsyncClient`. FastMCP does not gate on the type, so an existing `httpx.AsyncClient` keeps working at runtime via duck-typing this release — but switching it to `httpx2.AsyncClient` clears the type hint and is the supported path going forward. HTTP made inside your own tools is entirely yours and is unaffected either way.
+
+**The subtlest break is exception handlers, and no type checker will catch it.** `httpx` very likely remains installed in your environment (the Anthropic, OpenAI, and Google SDKs all depend on it), so code that catches old-httpx exceptions around FastMCP calls still imports and still type-checks — it just never matches, because FastMCP now raises `httpx2` exceptions. The handler silently becomes dead code:
+
+```python
+import httpx # still installed transitively — this import works
+
+try:
+ result = await client.call_tool("fetch", {"url": url})
+except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError
+ return fallback()
+```
+
+Grep your codebase for `except httpx.` and move those handlers to `httpx2`. The exception hierarchies match name-for-name, so the fix is an import swap — the hard part is remembering to look. One place you are covered automatically: exceptions raised *inside your tools and resources* (for example, a tool whose own old-httpx call gets a 429) are still mapped to `ToolError`/`ResourceError` by FastMCP's error boundary, which recognizes both libraries' exceptions during the transition.
+
+Two runtime behaviors shift with httpx2, and because the switch is now wholesale they apply to **all** FastMCP HTTP — including server-auth upstream calls, not just the client path. TLS verification uses the operating system's trust store (via `truststore`, honoring `SSL_CERT_FILE`/`SSL_CERT_DIR`) instead of the bundled certifi CA set, so corporate-CA or certifi-pinned setups may verify differently. And the FastMCP HTTP loggers are renamed from `httpx`/`httpcore.*` to `httpx2`/`httpcore2.*` — update any logging filters that select the HTTP stack by logger name.
+
## Deprecation timeline
The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away.
diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx
index cba92349e..ddcef6a2a 100644
--- a/docs/integrations/azure.mdx
+++ b/docs/integrations/azure.mdx
@@ -409,7 +409,7 @@ The `EntraOBOToken` dependency handles the complete OBO flow automatically. Decl
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken
-import httpx
+import httpx2
auth_provider = AzureProvider(
client_id="your-client-id",
@@ -431,7 +431,7 @@ async def get_recent_emails(
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]),
) -> list[dict]:
"""Get the user's recent emails from Microsoft Graph."""
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(
f"https://graph.microsoft.com/v1.0/me/messages?$top={count}",
headers={"Authorization": f"Bearer {graph_token}"},
diff --git a/docs/integrations/openapi.mdx b/docs/integrations/openapi.mdx
index f5f2b3dfa..d2826aa03 100644
--- a/docs/integrations/openapi.mdx
+++ b/docs/integrations/openapi.mdx
@@ -26,14 +26,14 @@ We recommend using the FastAPI integration for bootstrapping and prototyping, no
To convert an OpenAPI specification to an MCP server, use the `FastMCP.from_openapi()` class method:
```python server.py
-import httpx
+import httpx2
from fastmcp import FastMCP
# Create an HTTP client for your API
-client = httpx.AsyncClient(base_url="https://api.example.com")
+client = httpx2.AsyncClient(base_url="https://api.example.com")
# Load your OpenAPI spec
-openapi_spec = httpx.get("https://api.example.com/openapi.json").json()
+openapi_spec = httpx2.get("https://api.example.com/openapi.json").json()
# Create the MCP server
mcp = FastMCP.from_openapi(
@@ -51,11 +51,11 @@ if __name__ == "__main__":
If your API requires authentication, configure it on the HTTP client:
```python
-import httpx
+import httpx2
from fastmcp import FastMCP
# Bearer token authentication
-api_client = httpx.AsyncClient(
+api_client = httpx2.AsyncClient(
base_url="https://api.example.com",
headers={"Authorization": "Bearer YOUR_TOKEN"}
)
diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx
index c2256b052..957cad053 100644
--- a/docs/servers/auth/remote-oauth.mdx
+++ b/docs/servers/auth/remote-oauth.mdx
@@ -147,7 +147,7 @@ When not set, `scopes_supported` defaults to the token verifier's `required_scop
You can extend `RemoteAuthProvider` to add additional endpoints beyond the standard OAuth protected resource metadata. These don't have to be OAuth-specific - you can add any endpoints your authentication integration requires.
```python
-import httpx
+import httpx2
from starlette.responses import JSONResponse
from starlette.routing import Route
@@ -173,7 +173,7 @@ class CompanyAuthProvider(RemoteAuthProvider):
# Add authorization server metadata forwarding for client convenience
async def authorization_server_metadata(request):
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(
"https://auth.yourcompany.com/.well-known/oauth-authorization-server"
)
diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx
index 7e38d9a11..b21e8b54d 100644
--- a/docs/servers/auth/token-verification.mdx
+++ b/docs/servers/auth/token-verification.mdx
@@ -327,21 +327,21 @@ This pattern enables comprehensive testing of JWT validation logic without depen
-All token verifiers that make HTTP calls accept an optional `http_client` parameter. This lets you provide your own `httpx.AsyncClient` for connection pooling, custom TLS configuration, or proxy settings.
+All token verifiers that make HTTP calls accept an optional `http_client` parameter. This lets you provide your own `httpx2.AsyncClient` for connection pooling, custom TLS configuration, or proxy settings.
### Connection Pooling
By default, each token verification call creates a fresh HTTP client. Under high load, this means repeated TCP connections and TLS handshakes. Providing a shared client enables connection pooling across calls:
```python
-import httpx
+import httpx2
from fastmcp import FastMCP
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
# Create a shared client with connection pooling
-http_client = httpx.AsyncClient(
+http_client = httpx2.AsyncClient(
timeout=10,
- limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+ limits=httpx2.Limits(max_connections=20, max_keepalive_connections=10),
)
verifier = IntrospectionTokenVerifier(
@@ -378,7 +378,7 @@ from contextlib import asynccontextmanager
from fastmcp import FastMCP
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
-http_client = httpx.AsyncClient(timeout=10)
+http_client = httpx2.AsyncClient(timeout=10)
verifier = IntrospectionTokenVerifier(
introspection_url="https://auth.example.com/introspect",
diff --git a/docs/servers/providers/custom.mdx b/docs/servers/providers/custom.mdx
index f5673c683..73d244ff7 100644
--- a/docs/servers/providers/custom.mdx
+++ b/docs/servers/providers/custom.mdx
@@ -186,7 +186,7 @@ from contextlib import asynccontextmanager
from collections.abc import AsyncIterator, Sequence
from fastmcp.server.providers import Provider
from fastmcp.resources import Resource
-import httpx
+import httpx2
class ApiResourceProvider(Provider):
"""Provides resources backed by an external API."""
@@ -199,7 +199,7 @@ class ApiResourceProvider(Provider):
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
- self.client = httpx.AsyncClient(
+ self.client = httpx2.AsyncClient(
base_url=self.base_url,
headers={"Authorization": f"Bearer {self.api_key}"}
)
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index 37d8de62c..5e514d39d 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -350,7 +350,7 @@ if data_dir_path.is_dir():
- `TextResource`: For simple string content.
- `BinaryResource`: For raw `bytes` content.
- `FileResource`: Reads content from a local file path. Handles text/binary modes, encoding, and lazy reading.
-- `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx`).
+- `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx2`).
- `DirectoryResource`: Lists files in a local directory (returns JSON).
- (`FunctionResource`: Internal class used by `@mcp.resource`).
diff --git a/docs/tutorials/rest-api.mdx b/docs/tutorials/rest-api.mdx
index 90872c950..6942f20e5 100644
--- a/docs/tutorials/rest-api.mdx
+++ b/docs/tutorials/rest-api.mdx
@@ -32,7 +32,7 @@ For this tutorial, we'll use the [JSONPlaceholder API](https://jsonplaceholder.t
## Step 2: Create the MCP Server
-Now for the magic. We'll use `FastMCP.from_openapi`. This method takes an `httpx.AsyncClient` configured for your API and its OpenAPI specification, and automatically converts **every endpoint** into a callable MCP `Tool`.
+Now for the magic. We'll use `FastMCP.from_openapi`. This method takes an `httpx2.AsyncClient` configured for your API and its OpenAPI specification, and automatically converts **every endpoint** into a callable MCP `Tool`.
Learn more about working with OpenAPI specs in the [OpenAPI integration docs](/integrations/openapi).
@@ -45,11 +45,11 @@ For this tutorial, we'll use a simplified OpenAPI spec directly in the code. In
Create a file named `api_server.py`:
```python api_server.py {31-35}
-import httpx
+import httpx2
from fastmcp import FastMCP
# Create an HTTP client for the target API
-client = httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
+client = httpx2.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
# Define a simplified OpenAPI spec for JSONPlaceholder
openapi_spec = {
@@ -150,13 +150,13 @@ Learn more about route maps in the [OpenAPI integration docs](/integrations/open
Here’s how you can add custom route maps to turn `GET` requests into `Resources` and `ResourceTemplates` (if they have path parameters):
```python api_server_with_resources.py {3, 37-42}
-import httpx
+import httpx2
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import RouteMap, MCPType
# Create an HTTP client for the target API
-client = httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
+client = httpx2.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
# Define a simplified OpenAPI spec for JSONPlaceholder
openapi_spec = {
diff --git a/examples/apps/map/map_server.py b/examples/apps/map/map_server.py
index 5bc868a14..3711a5584 100644
--- a/examples/apps/map/map_server.py
+++ b/examples/apps/map/map_server.py
@@ -11,7 +11,7 @@ from __future__ import annotations
from textwrap import dedent
-import httpx
+import httpx2
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
Badge,
@@ -32,7 +32,7 @@ NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
def _geocode(query: str) -> dict | None:
"""Geocode an address using OpenStreetMap Nominatim (free, no key)."""
- resp = httpx.get(
+ resp = httpx2.get(
NOMINATIM_URL,
params={"q": query, "format": "json", "limit": 1},
headers={"User-Agent": "fastmcp-map-example/1.0"},
diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py
index daf8c340a..58cce2901 100644
--- a/examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py
+++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py
@@ -224,7 +224,7 @@ def _build_quote_with_images_embed(
quote_uri: str, image_urls: list[str], image_alts: list[str] | None, client
):
"""Build quote embed with images."""
- import httpx
+ import httpx2
# Get the quoted post
quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]})
@@ -239,7 +239,7 @@ def _build_quote_with_images_embed(
alts = image_alts or [""] * len(image_urls)
for i, url in enumerate(image_urls[:4]):
- response = httpx.get(url, follow_redirects=True)
+ response = httpx2.get(url, follow_redirects=True)
response.raise_for_status()
# Upload to blob storage
@@ -267,7 +267,7 @@ def _send_images(
client,
):
"""Send post with images using the client's send_images method."""
- import httpx
+ import httpx2
# Ensure alt_texts has same length as images
if image_alts is None:
@@ -279,7 +279,7 @@ def _send_images(
alts = []
for i, url in enumerate(image_urls[:4]): # Max 4 images
# Download image (follow redirects)
- response = httpx.get(url, follow_redirects=True)
+ response = httpx2.get(url, follow_redirects=True)
response.raise_for_status()
image_data.append(response.content)
diff --git a/examples/diagnostics/server.py b/examples/diagnostics/server.py
index 211c5d893..354ba08bb 100644
--- a/examples/diagnostics/server.py
+++ b/examples/diagnostics/server.py
@@ -7,7 +7,7 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
-import httpx
+import httpx2
from fastmcp import FastMCP
from fastmcp.server import create_proxy
@@ -44,7 +44,7 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]:
)
# Wait for server to be ready (async to avoid blocking event loop)
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
for _ in range(50):
try:
await client.get(
diff --git a/examples/text_me.py b/examples/text_me.py
index f45cca688..26a0350ab 100644
--- a/examples/text_me.py
+++ b/examples/text_me.py
@@ -20,7 +20,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
@@ -49,7 +49,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={
diff --git a/fastmcp_slim/fastmcp/cli/apps_dev.py b/fastmcp_slim/fastmcp/cli/apps_dev.py
index 803e0f752..6361db41b 100644
--- a/fastmcp_slim/fastmcp/cli/apps_dev.py
+++ b/fastmcp_slim/fastmcp/cli/apps_dev.py
@@ -42,8 +42,8 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlencode
-import httpcore
-import httpx
+import httpcore2
+import httpx2
import uvicorn
from starlette.applications import Starlette
from starlette.requests import Request
@@ -1300,7 +1300,7 @@ def _fetch_app_bridge_bundle_sync(
# We do this before the (potentially cached) app-bridge download so that
# any network error is surfaced early and clearly.
types_url = f"{sdk_base}/types.js"
- with httpx.Client(timeout=30.0) as client:
+ with httpx2.Client(timeout=30.0) as client:
resp = client.get(types_url, follow_redirects=True)
resp.raise_for_status()
types_content = resp.text
@@ -1314,7 +1314,7 @@ def _fetch_app_bridge_bundle_sync(
zod_wrapper_path = zod_wrapper_match.group(1) # e.g. /zod@^4.3.5/v4?target=es2022
zod_wrapper_url = f"https://esm.sh{zod_wrapper_path}"
- with httpx.Client(timeout=30.0) as client:
+ with httpx2.Client(timeout=30.0) as client:
resp = client.get(zod_wrapper_url, follow_redirects=True)
resp.raise_for_status()
wrapper_content = resp.text
@@ -1344,7 +1344,7 @@ def _fetch_app_bridge_bundle_sync(
return app_bridge_js, import_map_json
npm_url = f"https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-{version}.tgz"
- with httpx.Client(timeout=30.0) as client:
+ with httpx2.Client(timeout=30.0) as client:
resp = client.get(npm_url, follow_redirects=True)
resp.raise_for_status()
data = resp.content
@@ -1545,11 +1545,11 @@ def _make_dev_app(
# Use a reasonable default timeout to prevent the proxy from hanging
# if the backend server is unresponsive.
- client = httpx.AsyncClient(
- timeout=httpx.Timeout(60.0, read=None), trust_env=False
+ client = httpx2.AsyncClient(
+ timeout=httpx2.Timeout(60.0, read=None), trust_env=False
)
- async def _stream_and_cleanup(resp: httpx.Response) -> Any:
+ async def _stream_and_cleanup(resp: httpx2.Response) -> Any:
is_sse = "text/event-stream" in resp.headers.get("content-type", "")
buf: list[bytes] = []
sse_buf = ""
@@ -1574,10 +1574,10 @@ def _make_dev_app(
else:
buf.append(chunk)
except (
- httpx.RemoteProtocolError,
- httpx.ReadError,
- httpx.ReadTimeout,
- httpcore.RemoteProtocolError,
+ httpx2.RemoteProtocolError,
+ httpx2.ReadError,
+ httpx2.ReadTimeout,
+ httpcore2.RemoteProtocolError,
):
pass # Connection closed during shutdown — not an error
finally:
@@ -1617,7 +1617,7 @@ def _make_dev_app(
headers=fwd_headers,
media_type=content_type or "application/octet-stream",
)
- except (httpx.ConnectError, httpx.ConnectTimeout):
+ except (httpx2.ConnectError, httpx2.ConnectTimeout):
await client.aclose()
return Response(
content=json.dumps({"error": "MCP server not reachable"}).encode(),
@@ -1709,15 +1709,15 @@ async def _wait_for_server(url: str, timeout: float = 15.0) -> bool:
"""Poll until the server is accepting connections."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
- async with httpx.AsyncClient(trust_env=False) as client:
+ async with httpx2.AsyncClient(trust_env=False) as client:
while loop.time() < deadline:
try:
await client.get(url, timeout=1.0)
return True
except (
- httpx.ConnectError,
- httpx.RemoteProtocolError,
- httpx.TimeoutException,
+ httpx2.ConnectError,
+ httpx2.RemoteProtocolError,
+ httpx2.TimeoutException,
):
await asyncio.sleep(0.25)
return False
diff --git a/fastmcp_slim/fastmcp/client/auth/bearer.py b/fastmcp_slim/fastmcp/client/auth/bearer.py
index 0c38a11b0..13d9c39bb 100644
--- a/fastmcp_slim/fastmcp/client/auth/bearer.py
+++ b/fastmcp_slim/fastmcp/client/auth/bearer.py
@@ -1,4 +1,4 @@
-import httpx
+import httpx2
from pydantic import SecretStr
from fastmcp.utilities.logging import get_logger
@@ -8,7 +8,7 @@ __all__ = ["BearerAuth"]
logger = get_logger(__name__)
-class BearerAuth(httpx.Auth):
+class BearerAuth(httpx2.Auth):
def __init__(self, token: str):
self.token = SecretStr(token)
diff --git a/fastmcp_slim/fastmcp/client/auth/oauth.py b/fastmcp_slim/fastmcp/client/auth/oauth.py
index 2a15697b6..835b34a9c 100644
--- a/fastmcp_slim/fastmcp/client/auth/oauth.py
+++ b/fastmcp_slim/fastmcp/client/auth/oauth.py
@@ -7,7 +7,7 @@ from contextlib import aclosing
from typing import Any
import anyio
-import httpx
+import httpx2
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.stores.memory import MemoryStore
@@ -60,7 +60,7 @@ async def check_if_auth_required(
Returns:
True if auth appears to be required, False otherwise
"""
- async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
+ async with httpx2.AsyncClient(**(httpx_kwargs or {})) as client:
try:
# Try a simple request to the endpoint
response = await client.get(mcp_url, timeout=5.0)
@@ -76,7 +76,7 @@ async def check_if_auth_required(
# If we get a successful response, auth may not be required
return False
- except httpx.RequestError:
+ except httpx2.RequestError:
# If we can't connect, assume auth might be required
return True
@@ -237,7 +237,7 @@ class OAuth(OAuthClientProvider):
self._client_id = client_id
self._client_secret = client_secret
self._static_client_info = None
- self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
+ self.httpx_client_factory = httpx_client_factory or httpx2.AsyncClient
self._bound = False
if mcp_url is not None:
@@ -405,8 +405,8 @@ class OAuth(OAuthClientProvider):
raise RuntimeError("OAuth callback handler could not be started")
async def async_auth_flow(
- self, request: httpx.Request
- ) -> AsyncGenerator[httpx.Request, httpx.Response]:
+ self, request: httpx2.Request
+ ) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""HTTPX auth flow with automatic retry on stale cached credentials.
If the OAuth flow fails due to invalid/stale client credentials,
diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py
index 2106c38c7..6dfaed6cf 100644
--- a/fastmcp_slim/fastmcp/client/client.py
+++ b/fastmcp_slim/fastmcp/client/client.py
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import anyio
import anyio.lowlevel
-import httpx
+import httpx2
import mcp_types
from exceptiongroup import catch
from mcp import ClientSession, MCPError
@@ -362,7 +362,7 @@ class Client(
auto_initialize: bool = True,
init_timeout: datetime.timedelta | float | int | None = None,
client_info: mcp_types.Implementation | None = None,
- auth: httpx.Auth | Literal["oauth"] | str | None = None,
+ auth: httpx2.Auth | Literal["oauth"] | str | None = None,
verify: ssl.SSLContext | bool | str | None = None,
mode: ConnectMode = "legacy",
prior_discover: mcp_types.DiscoverResult | None = None,
@@ -896,7 +896,7 @@ class Client(
"Session task completed without exception but connection failed"
)
# Preserve specific exception types that clients may want to handle
- if isinstance(exception, httpx.HTTPStatusError | MCPError):
+ if isinstance(exception, httpx2.HTTPStatusError | MCPError):
raise exception
raise RuntimeError(
f"Client failed to connect: {exception}"
diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py
index 87326413c..36683f75d 100644
--- a/fastmcp_slim/fastmcp/client/transports/base.py
+++ b/fastmcp_slim/fastmcp/client/transports/base.py
@@ -3,7 +3,7 @@ import contextlib
from collections.abc import AsyncIterator, Sequence
from typing import Any, Literal, TypeVar
-import httpx
+import httpx2
import mcp_types
from mcp import ClientSession
from mcp.client.extension import NotificationBinding
@@ -78,6 +78,6 @@ class ClientTransport(abc.ABC):
"""Get the session ID for this transport, if available."""
return None
- def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
+ def _set_auth(self, auth: httpx2.Auth | Literal["oauth"] | str | None):
if auth is not None:
raise ValueError("This transport does not support auth")
diff --git a/fastmcp_slim/fastmcp/client/transports/http.py b/fastmcp_slim/fastmcp/client/transports/http.py
index 73caf3cd1..a57379d80 100644
--- a/fastmcp_slim/fastmcp/client/transports/http.py
+++ b/fastmcp_slim/fastmcp/client/transports/http.py
@@ -7,7 +7,7 @@ import ssl
from collections.abc import AsyncIterator
from typing import Any, Literal, cast
-import httpx
+import httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
@@ -27,7 +27,7 @@ class StreamableHttpTransport(ClientTransport):
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
- auth: httpx.Auth | Literal["oauth"] | str | None = None,
+ auth: httpx2.Auth | Literal["oauth"] | str | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
verify: ssl.SSLContext | bool | str | None = None,
):
@@ -36,9 +36,9 @@ class StreamableHttpTransport(ClientTransport):
Args:
url: The MCP server endpoint URL.
headers: Optional headers to include in requests.
- auth: Authentication method - httpx.Auth, "oauth" for OAuth flow,
+ auth: Authentication method - httpx2.Auth, "oauth" for OAuth flow,
or a bearer token string.
- httpx_client_factory: Optional factory for creating httpx.AsyncClient.
+ httpx_client_factory: Optional factory for creating httpx2.AsyncClient.
If provided, must accept keyword arguments: headers, auth,
follow_redirects, and optionally timeout. Using **kwargs is
recommended to ensure forward compatibility.
@@ -82,7 +82,7 @@ class StreamableHttpTransport(ClientTransport):
# client we own (see connect_session / _capture_session_id).
self._session_id: str | None = None
- async def _capture_session_id(self, response: httpx.Response) -> None:
+ async def _capture_session_id(self, response: httpx2.Response) -> None:
"""httpx response event hook: record the server's `mcp-session-id`.
The streamable HTTP server assigns the session id in the response to
@@ -93,8 +93,8 @@ class StreamableHttpTransport(ClientTransport):
if sid:
self._session_id = sid
- def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
- resolved: httpx.Auth | None
+ def _set_auth(self, auth: httpx2.Auth | Literal["oauth"] | str | None):
+ resolved: httpx2.Auth | None
if auth == "oauth":
resolved = OAuth(
self.url,
@@ -105,7 +105,7 @@ class StreamableHttpTransport(ClientTransport):
auth._bind(self.url)
# Only inject the transport's factory into OAuth if OAuth still
# has the bare default — preserve any factory the caller attached
- if auth.httpx_client_factory is httpx.AsyncClient:
+ if auth.httpx_client_factory is httpx2.AsyncClient:
factory = self.httpx_client_factory or self._make_verify_factory()
if factory is not None:
auth.httpx_client_factory = factory
@@ -114,7 +114,7 @@ class StreamableHttpTransport(ClientTransport):
resolved = BearerAuth(auth)
else:
resolved = auth
- self.auth: httpx.Auth | None = resolved
+ self.auth: httpx2.Auth | None = resolved
def _make_verify_factory(self) -> McpHttpClientFactory | None:
if self.verify is None:
@@ -123,11 +123,11 @@ class StreamableHttpTransport(ClientTransport):
def factory(
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:
if timeout is None:
- timeout = httpx.Timeout(30.0, read=300.0)
+ timeout = httpx2.Timeout(30.0, read=300.0)
kwargs: dict[str, Any] = {
"follow_redirects": True,
"timeout": timeout,
@@ -137,7 +137,7 @@ class StreamableHttpTransport(ClientTransport):
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
- return httpx.AsyncClient(**kwargs)
+ return httpx2.AsyncClient(**kwargs)
return cast(McpHttpClientFactory, factory)
@@ -156,10 +156,10 @@ class StreamableHttpTransport(ClientTransport):
# Configure timeout if provided, preserving MCP's 30s connect default.
# SDK v2 session read timeouts are float seconds (see SessionKwargs).
- timeout: httpx.Timeout | None = None
+ timeout: httpx2.Timeout | None = None
read_timeout_seconds = session_kwargs.get("read_timeout_seconds")
if read_timeout_seconds is not None:
- timeout = httpx.Timeout(30.0, read=read_timeout_seconds)
+ timeout = httpx2.Timeout(30.0, read=read_timeout_seconds)
# Create httpx client from factory or use default with MCP-appropriate
# timeouts. Note: create_mcp_http_client enables follow_redirects, but
diff --git a/fastmcp_slim/fastmcp/client/transports/sse.py b/fastmcp_slim/fastmcp/client/transports/sse.py
index 09e7fff5c..25204091c 100644
--- a/fastmcp_slim/fastmcp/client/transports/sse.py
+++ b/fastmcp_slim/fastmcp/client/transports/sse.py
@@ -8,7 +8,7 @@ import ssl
from collections.abc import AsyncIterator
from typing import Any, Literal, cast
-import httpx
+import httpx2
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.shared._httpx_utils import McpHttpClientFactory
@@ -29,7 +29,7 @@ class SSETransport(ClientTransport):
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
- auth: httpx.Auth | Literal["oauth"] | str | None = None,
+ auth: httpx2.Auth | Literal["oauth"] | str | None = None,
sse_read_timeout: datetime.timedelta | float | int | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
verify: ssl.SSLContext | bool | str | None = None,
@@ -65,8 +65,8 @@ class SSETransport(ClientTransport):
self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout)
- def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
- resolved: httpx.Auth | None
+ def _set_auth(self, auth: httpx2.Auth | Literal["oauth"] | str | None):
+ resolved: httpx2.Auth | None
if auth == "oauth":
resolved = OAuth(
self.url,
@@ -77,7 +77,7 @@ class SSETransport(ClientTransport):
auth._bind(self.url)
# Only inject the transport's factory into OAuth if OAuth still
# has the bare default — preserve any factory the caller attached
- if auth.httpx_client_factory is httpx.AsyncClient:
+ if auth.httpx_client_factory is httpx2.AsyncClient:
factory = self.httpx_client_factory or self._make_verify_factory()
if factory is not None:
auth.httpx_client_factory = factory
@@ -86,7 +86,7 @@ class SSETransport(ClientTransport):
resolved = BearerAuth(auth)
else:
resolved = auth
- self.auth: httpx.Auth | None = resolved
+ self.auth: httpx2.Auth | None = resolved
def _make_verify_factory(self) -> McpHttpClientFactory | None:
if self.verify is None:
@@ -95,11 +95,11 @@ class SSETransport(ClientTransport):
def factory(
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:
if timeout is None:
- timeout = httpx.Timeout(30.0, read=300.0)
+ timeout = httpx2.Timeout(30.0, read=300.0)
kwargs: dict[str, Any] = {
"follow_redirects": True,
"timeout": timeout,
@@ -109,7 +109,7 @@ class SSETransport(ClientTransport):
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
- return httpx.AsyncClient(**kwargs)
+ return httpx2.AsyncClient(**kwargs)
return cast(McpHttpClientFactory, factory)
diff --git a/fastmcp_slim/fastmcp/mcp_config.py b/fastmcp_slim/fastmcp/mcp_config.py
index 286e34c56..01416bd2d 100644
--- a/fastmcp_slim/fastmcp/mcp_config.py
+++ b/fastmcp_slim/fastmcp/mcp_config.py
@@ -30,7 +30,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
from urllib.parse import urlparse
-import httpx
+import httpx2
from pydantic import (
AnyUrl,
BaseModel,
@@ -229,9 +229,9 @@ class RemoteMCPServer(BaseModel):
# Authentication
auth: Annotated[
- str | Literal["oauth"] | httpx.Auth | None,
+ str | Literal["oauth"] | httpx2.Auth | None,
Field(
- description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.',
+ description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx2.Auth instance for custom authentication.',
),
] = None
diff --git a/fastmcp_slim/fastmcp/resources/types.py b/fastmcp_slim/fastmcp/resources/types.py
index ed514c5ce..19b3b90a9 100644
--- a/fastmcp_slim/fastmcp/resources/types.py
+++ b/fastmcp_slim/fastmcp/resources/types.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import json
from pathlib import Path
-import httpx
+import httpx2
import pydantic.json
from anyio import Path as AsyncPath
from pydantic import Field, ValidationInfo
@@ -121,7 +121,7 @@ class HttpResource(Resource):
@override
async def read(self) -> ResourceResult:
"""Read the HTTP content."""
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(self.url)
_ = response.raise_for_status()
return ResourceResult(
diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
index 603f8ad0e..1c471db6b 100644
--- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
+++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
@@ -29,9 +29,8 @@ from typing import Any, Literal
from urllib.parse import urlencode, urlparse, urlunparse
import anyio
-import httpx
+import httpx2
from authlib.common.security import generate_token
-from authlib.integrations.httpx_client import AsyncOAuth2Client
from cryptography.fernet import Fernet
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
@@ -92,6 +91,7 @@ from fastmcp.server.auth.oauth_proxy.models import (
_hash_token,
)
from fastmcp.server.auth.oauth_proxy.ui import create_error_html
+from fastmcp.server.auth.oauth_proxy.upstream import AsyncOAuth2Client
from fastmcp.server.auth.redirect_validation import validate_redirect_uri
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
@@ -197,7 +197,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
- Clean up one-time use authorization code
5. Token Refresh:
- - Forward refresh requests to upstream using authlib
+ - Forward refresh requests to upstream
- Handle token rotation if upstream issues new refresh token
- Update local token mappings
@@ -313,7 +313,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
Disable only if upstream provider doesn't support PKCE.
token_endpoint_auth_method: Token endpoint authentication method for upstream server.
Common values: "client_secret_basic", "client_secret_post", "none".
- If None, authlib will use its default (typically "client_secret_basic").
+ Defaults to "client_secret_basic".
extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint.
Useful for provider-specific parameters like Auth0's "audience".
Example: {"audience": "https://api.example.com"}
@@ -1967,7 +1967,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Attempt upstream revocation if endpoint is configured
if self._upstream_revocation_endpoint:
try:
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS
) as http_client:
revocation_data: dict[str, str] = {"token": token.token}
@@ -2272,8 +2272,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
)
# Build token exchange parameters
- token_params = {
- "url": self._upstream_token_endpoint,
+ token_params: dict[str, Any] = {
"code": idp_code,
"redirect_uri": idp_redirect_uri,
}
@@ -2305,8 +2304,12 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Exchange IdP code for tokens (server-side)
async with self._upstream_oauth_client() as oauth_client:
+ # url is passed by keyword: the _create_upstream_oauth_client
+ # override point is duck-typed, and alternative clients may
+ # declare it keyword-only (the refresh_token sites already
+ # call by keyword).
idp_tokens: dict[str, Any] = await oauth_client.fetch_token(
- **token_params
+ url=self._upstream_token_endpoint, **token_params
)
logger.debug(
diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
new file mode 100644
index 000000000..8739aa7a3
--- /dev/null
+++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
@@ -0,0 +1,147 @@
+"""httpx2-based upstream OAuth2 token client.
+
+Replaces `authlib.integrations.httpx_client.AsyncOAuth2Client` for the OAuth
+proxy's upstream token-endpoint calls. authlib's httpx integration imports the
+legacy `httpx` package — which authlib does not declare as a dependency and
+FastMCP no longer ships — so importing it on a clean install fails.
+
+This module reimplements the narrow surface the proxy uses (`fetch_token`,
+`refresh_token`, `client_secret`, `aclose`) on `httpx2.AsyncClient`, preserving
+authlib's wire behavior exactly:
+
+- form-encoded POST token requests with authlib's default headers
+- `client_secret_basic` (latin-1 basic auth, authlib-style), `client_secret_post`,
+ and `none` client authentication methods
+- falsy parameters dropped from the request body
+- `expires_at` computed onto the returned token dict
+- the previous refresh token injected into the response when the server does
+ not rotate it
+- `OAuthError` (authlib's httpx-free core error class) raised for RFC 6749
+ error responses, and 5xx responses raised as HTTP status errors
+"""
+
+from __future__ import annotations
+
+import base64
+import time
+from typing import Any
+
+import httpx2
+from authlib.integrations.base_client import OAuthError
+
+__all__ = ["AsyncOAuth2Client", "OAuthError"]
+
+_DEFAULT_TOKEN_HEADERS = {
+ "Accept": "application/json",
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+}
+
+
+class AsyncOAuth2Client:
+ """Minimal async OAuth2 client for upstream token-endpoint interactions.
+
+ Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that
+ `OAuthProxy` uses. Subclasses of `OAuthProxy` that override
+ `_create_upstream_oauth_client` may return any object with the same
+ `fetch_token`/`refresh_token`/`client_secret`/`aclose` surface (including
+ an authlib client, if legacy httpx is installed in their environment).
+ """
+
+ def __init__(
+ self,
+ client_id: str,
+ client_secret: str | None = None,
+ token_endpoint_auth_method: str | None = None,
+ timeout: float | None = None,
+ ) -> None:
+ self.client_id = client_id
+ self.client_secret = client_secret
+ self.token_endpoint_auth_method = (
+ token_endpoint_auth_method or "client_secret_basic"
+ )
+ self._client = httpx2.AsyncClient(timeout=timeout)
+
+ async def aclose(self) -> None:
+ await self._client.aclose()
+
+ def _apply_client_auth(self, data: dict[str, Any], headers: dict[str, str]) -> None:
+ """Attach client credentials per the configured auth method (RFC 6749 §2.3)."""
+ method = self.token_endpoint_auth_method
+ if method == "client_secret_basic":
+ text = f"{self.client_id}:{self.client_secret}"
+ credential = base64.b64encode(text.encode("latin1")).decode("ascii")
+ headers["Authorization"] = f"Basic {credential}"
+ elif method == "client_secret_post":
+ data["client_id"] = self.client_id
+ data["client_secret"] = self.client_secret or ""
+ elif method == "none":
+ data["client_id"] = self.client_id
+ else:
+ raise ValueError(
+ f"Unsupported token_endpoint_auth_method: {method!r}. "
+ "Supported methods: client_secret_basic, client_secret_post, none."
+ )
+
+ async def _request_token(self, url: str, data: dict[str, Any]) -> dict[str, Any]:
+ headers = dict(_DEFAULT_TOKEN_HEADERS)
+ self._apply_client_auth(data, headers)
+
+ response = await self._client.post(url, data=data, headers=headers)
+ if response.status_code >= 500:
+ response.raise_for_status()
+
+ token: dict[str, Any] = response.json()
+ if "error" in token:
+ raise OAuthError(
+ error=token["error"], description=token.get("error_description")
+ )
+
+ # Mirror authlib's OAuth2Token: derive expires_at from expires_in so
+ # the stored raw token data keeps the same shape as before.
+ if token.get("expires_at") is not None:
+ try:
+ token["expires_at"] = int(token["expires_at"])
+ except ValueError:
+ if token.get("expires_in"):
+ token["expires_at"] = int(time.time()) + int(token["expires_in"])
+ elif token.get("expires_in"):
+ token["expires_at"] = int(time.time()) + int(token["expires_in"])
+
+ return token
+
+ async def fetch_token(
+ self,
+ url: str,
+ *,
+ grant_type: str = "authorization_code",
+ **params: Any,
+ ) -> dict[str, Any]:
+ """Exchange an authorization grant for tokens at the token endpoint.
+
+ Falsy parameters are dropped from the request body, matching authlib.
+ """
+ data: dict[str, Any] = {"grant_type": grant_type}
+ data.update({key: value for key, value in params.items() if value})
+ return await self._request_token(url, data)
+
+ async def refresh_token(
+ self,
+ url: str,
+ *,
+ refresh_token: str | None = None,
+ **params: Any,
+ ) -> dict[str, Any]:
+ """Fetch a new access token using a refresh token.
+
+ If the server does not rotate the refresh token, the previous one is
+ injected into the returned dict, matching authlib.
+ """
+ data: dict[str, Any] = {"grant_type": "refresh_token"}
+ if refresh_token:
+ data["refresh_token"] = refresh_token
+ data.update({key: value for key, value in params.items() if value})
+
+ token = await self._request_token(url, data)
+ if "refresh_token" not in token:
+ token["refresh_token"] = refresh_token
+ return token
diff --git a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py
index ac7bfaed5..c96e7bdb8 100644
--- a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py
+++ b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py
@@ -12,7 +12,7 @@ This implementation is based on:
from collections.abc import Sequence
from typing import Any, Literal
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl, BaseModel, model_validator
from typing_extensions import Self
@@ -162,7 +162,7 @@ class OIDCConfiguration(BaseModel):
get_kwargs["timeout"] = timeout_seconds
try:
- response = httpx.get(str(config_url), **get_kwargs)
+ response = httpx2.get(str(config_url), **get_kwargs)
response.raise_for_status()
config_data = response.json()
@@ -289,7 +289,7 @@ class OIDCProxy(OAuthProxy):
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
token_endpoint_auth_method: Token endpoint authentication method for upstream server.
Common values: "client_secret_basic", "client_secret_post", "none".
- If None, authlib will use its default (typically "client_secret_basic").
+ Defaults to "client_secret_basic".
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to the upstream IdP.
When False, authorization proceeds directly without user confirmation.
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/azure.py b/fastmcp_slim/fastmcp/server/auth/providers/azure.py
index 89853d8b2..3914573d2 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/azure.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/azure.py
@@ -10,7 +10,7 @@ import hashlib
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Literal, cast
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from fastmcp.dependencies import Dependency
@@ -120,7 +120,7 @@ class AzureProvider(OAuthProxy):
token_expiry_threshold_seconds: int = 0,
base_authority: str = "login.microsoftonline.com",
token_issuer: str | None = None,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
) -> None:
"""Initialize Azure OAuth provider.
@@ -176,7 +176,7 @@ class AzureProvider(OAuthProxy):
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
- http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches.
+ http_client: Optional httpx2.AsyncClient for connection pooling in JWKS fetches.
When provided, the client is reused for JWT key fetches and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
@@ -881,13 +881,13 @@ def EntraOBOToken(scopes: list[str]) -> str:
Example:
```python
from fastmcp.server.auth.providers.azure import EntraOBOToken
- import httpx
+ import httpx2
@mcp.tool()
async def get_my_emails(
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"])
):
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
resp = await client.get(
"https://graph.microsoft.com/v1.0/me/messages",
headers={"Authorization": f"Bearer {graph_token}"}
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py
index 21d7c3b48..b6989d370 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py
@@ -31,7 +31,7 @@ from __future__ import annotations
import contextlib
from typing import Literal
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@@ -67,7 +67,7 @@ class ClerkTokenVerifier(TokenVerifier):
client_secret: str | None = None,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the Clerk token verifier.
@@ -77,7 +77,7 @@ class ClerkTokenVerifier(TokenVerifier):
client_secret: Clerk OAuth client secret, used for introspection endpoint authentication
required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"])
timeout_seconds: HTTP request timeout
- http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@@ -107,7 +107,7 @@ class ClerkTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=self.timeout_seconds)
+ else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Step 1: Validate token via introspection (RFC 7662).
# Security-critical checks (active, audience, scopes) come first.
@@ -229,7 +229,7 @@ class ClerkTokenVerifier(TokenVerifier):
logger.debug("Clerk token verified successfully for sub=%s", sub)
return access_token
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
logger.debug("Failed to verify Clerk token: %s", e)
return None
except Exception as e:
@@ -293,7 +293,7 @@ class ClerkProvider(OAuthProxy):
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Clerk OAuth provider.
@@ -331,7 +331,7 @@ class ClerkProvider(OAuthProxy):
consent_csp_policy: Custom CSP policy for the consent page.
extra_authorize_params: Additional parameters to forward to Clerk's authorization
endpoint. Example: {"prompt": "login"} to force re-authentication.
- http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created
per call.
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/descope.py b/fastmcp_slim/fastmcp/server/auth/providers/descope.py
index 3bdccf8d5..3741aeefe 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/descope.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/descope.py
@@ -9,7 +9,7 @@ from __future__ import annotations
from urllib.parse import urlparse
-import httpx
+import httpx2
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
@@ -181,7 +181,7 @@ class DescopeProvider(RemoteAuthProvider):
async def oauth_authorization_server_metadata(request):
"""Forward Descope OAuth authorization server metadata with FastMCP customizations."""
try:
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(
f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server"
)
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/discord.py b/fastmcp_slim/fastmcp/server/auth/providers/discord.py
index da13a074f..dec5e707f 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/discord.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/discord.py
@@ -26,7 +26,7 @@ import time
from datetime import datetime
from typing import Literal
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@@ -52,7 +52,7 @@ class DiscordTokenVerifier(TokenVerifier):
expected_client_id: str,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the Discord token verifier.
@@ -60,7 +60,7 @@ class DiscordTokenVerifier(TokenVerifier):
expected_client_id: Expected Discord OAuth client ID for audience binding
required_scopes: Required OAuth scopes (e.g., ['email'])
timeout_seconds: HTTP request timeout
- http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@@ -75,7 +75,7 @@ class DiscordTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=self.timeout_seconds)
+ else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Use Discord's tokeninfo endpoint to validate the token
headers = {
@@ -154,7 +154,7 @@ class DiscordTokenVerifier(TokenVerifier):
logger.debug("Discord token verified successfully")
return access_token
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
logger.debug("Failed to verify Discord token: %s", e)
return None
except Exception as e:
@@ -210,7 +210,7 @@ class DiscordProvider(OAuthProxy):
fallback_refresh_token_expiry_seconds: int | None = None,
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Discord OAuth provider.
@@ -243,7 +243,7 @@ class DiscordProvider(OAuthProxy):
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
- http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/github.py b/fastmcp_slim/fastmcp/server/auth/providers/github.py
index d14aeb08f..b5f744297 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/github.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/github.py
@@ -24,7 +24,7 @@ from __future__ import annotations
import contextlib
from typing import Literal
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@@ -56,7 +56,7 @@ class GitHubTokenVerifier(TokenVerifier):
timeout_seconds: int = 10,
cache_ttl_seconds: int | None = None,
max_cache_size: int | None = None,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the GitHub token verifier.
@@ -67,7 +67,7 @@ class GitHubTokenVerifier(TokenVerifier):
Caching is disabled by default (None). Set to a positive integer
to enable (e.g., 300 for 5 minutes).
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
- http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@@ -90,7 +90,7 @@ class GitHubTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=self.timeout_seconds)
+ else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Get token info from GitHub API
response = await client.get(
@@ -167,7 +167,7 @@ class GitHubTokenVerifier(TokenVerifier):
self._cache.set(token, result)
return result
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
logger.debug("Failed to verify GitHub token: %s", e)
return None
except Exception as e:
@@ -225,7 +225,7 @@ class GitHubProvider(OAuthProxy):
fallback_refresh_token_expiry_seconds: int | None = None,
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize GitHub OAuth provider.
@@ -259,7 +259,7 @@ class GitHubProvider(OAuthProxy):
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
- http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/google.py b/fastmcp_slim/fastmcp/server/auth/providers/google.py
index 07f757001..28d9a5beb 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/google.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/google.py
@@ -25,7 +25,7 @@ import contextlib
import time
from typing import Literal
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@@ -69,14 +69,14 @@ class GoogleTokenVerifier(TokenVerifier):
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the Google token verifier.
Args:
required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
timeout_seconds: HTTP request timeout
- http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@@ -101,7 +101,7 @@ class GoogleTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=self.timeout_seconds)
+ else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Step 1: Verify token via tokeninfo endpoint.
# Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email.
@@ -193,7 +193,7 @@ class GoogleTokenVerifier(TokenVerifier):
logger.debug("Google token verified successfully")
return access_token
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
logger.debug("Failed to verify Google token: %s", e)
return None
except Exception as e:
@@ -251,7 +251,7 @@ class GoogleProvider(OAuthProxy):
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Google OAuth provider.
@@ -296,7 +296,7 @@ class GoogleProvider(OAuthProxy):
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
refresh tokens are returned. You can override these defaults or add additional parameters.
Example: {"prompt": "select_account"} to let users choose their Google account.
- http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py
index b3ca36363..07cfaf4f3 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py
@@ -7,7 +7,7 @@ from collections.abc import Mapping
from json import JSONDecodeError
from typing import Any, Literal
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@@ -65,7 +65,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
super().__init__(required_scopes=required_scopes)
self.timeout_seconds = timeout_seconds
@@ -77,7 +77,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=self.timeout_seconds)
+ else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
userinfo_response = await client.get(
HUGGINGFACE_USERINFO_ENDPOINT,
@@ -148,7 +148,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
},
)
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
logger.debug("Failed to verify Hugging Face token: %s", e)
return None
except JSONDecodeError as e:
@@ -156,7 +156,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
return None
async def _fetch_whoami(
- self, client: httpx.AsyncClient, token: str
+ self, client: httpx2.AsyncClient, token: str
) -> dict[str, Any] | None:
response = await client.get(
HUGGINGFACE_WHOAMI_ENDPOINT,
@@ -197,7 +197,7 @@ class HuggingFaceProvider(OAuthProxy):
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
extra_token_params: dict[str, str] | None = None,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Hugging Face OAuth provider.
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/introspection.py b/fastmcp_slim/fastmcp/server/auth/providers/introspection.py
index f24b54833..6d071d265 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/introspection.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/introspection.py
@@ -28,7 +28,7 @@ import contextlib
import time
from typing import Any, Literal, get_args
-import httpx
+import httpx2
from pydantic import AnyHttpUrl, SecretStr
from fastmcp.server.auth import AccessToken, TokenVerifier
@@ -89,7 +89,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
base_url: AnyHttpUrl | str | None = None,
cache_ttl_seconds: int | None = None,
max_cache_size: int | None = None,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
"""
Initialize the introspection token verifier.
@@ -109,7 +109,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
(e.g., 300 for 5 minutes).
max_cache_size: Maximum number of tokens to cache when caching is
enabled. Default: 10000.
- http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@@ -203,7 +203,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=self.timeout_seconds)
+ else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Prepare introspection request per RFC 7662
# Build request data with token and token_type_hint
@@ -292,12 +292,12 @@ class IntrospectionTokenVerifier(TokenVerifier):
self._cache.set(token, result)
return result
- except httpx.TimeoutException:
+ except httpx2.TimeoutException:
self.logger.debug(
"Token introspection timed out after %d seconds", self.timeout_seconds
)
return None
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
self.logger.debug("Token introspection request failed: %s", e)
return None
except Exception as e:
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/jwt.py b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py
index 9167eb9d1..aedbee26f 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/jwt.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py
@@ -8,7 +8,7 @@ import time
from dataclasses import dataclass
from typing import Any, TypeAlias, cast
-import httpx
+import httpx2
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc import jwk, jwt
@@ -222,7 +222,7 @@ class JWTVerifier(TokenVerifier):
required_scopes: list[str] | None = None,
base_url: AnyHttpUrl | str | None = None,
ssrf_safe: bool = False,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
"""
Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint.
@@ -239,7 +239,7 @@ class JWTVerifier(TokenVerifier):
public IPs, DNS pinning). Enable when the JWKS URI comes from
untrusted input (e.g. CIMD documents). Defaults to False so
operator-configured JWKS URIs (including localhost) work normally.
- http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused for JWKS fetches and the caller is responsible for
its lifecycle. When None (default), a fresh client is created per fetch.
Cannot be used with ssrf_safe=True.
@@ -408,7 +408,7 @@ class JWTVerifier(TokenVerifier):
except (SSRFError, SSRFFetchError) as e:
self.logger.debug("JWKS fetch blocked by SSRF protection: %s", e)
raise ValueError(f"Failed to fetch JWKS: {e}") from e
- except httpx.HTTPError as e:
+ except httpx2.HTTPError as e:
raise ValueError(f"Failed to fetch JWKS: {e}") from e
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JWKS JSON: {e}") from e
@@ -433,7 +433,7 @@ class JWTVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=httpx.Timeout(10.0))
+ else httpx2.AsyncClient(timeout=httpx2.Timeout(10.0))
) as client:
response = await client.get(self.jwks_uri)
response.raise_for_status()
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/propelauth.py b/fastmcp_slim/fastmcp/server/auth/providers/propelauth.py
index 82e55e172..d49fcf017 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/propelauth.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/propelauth.py
@@ -21,7 +21,7 @@ from __future__ import annotations
from typing import TypedDict
-import httpx
+import httpx2
from pydantic import AnyHttpUrl, SecretStr
from starlette.responses import JSONResponse
from starlette.routing import Route
@@ -37,7 +37,7 @@ class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False):
timeout_seconds: int
cache_ttl_seconds: int | None
max_cache_size: int | None
- http_client: httpx.AsyncClient | None
+ http_client: httpx2.AsyncClient | None
class PropelAuthProvider(RemoteAuthProvider):
@@ -156,7 +156,7 @@ class PropelAuthProvider(RemoteAuthProvider):
async def oauth_authorization_server_metadata(request):
"""Forward PropelAuth OAuth authorization server metadata"""
try:
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(
f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1"
)
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py b/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py
index ffcacc9c5..8e82be441 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py
@@ -7,7 +7,7 @@ authentication for seamless MCP client authentication.
from __future__ import annotations
-import httpx
+import httpx2
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
@@ -181,7 +181,7 @@ class ScalekitProvider(RemoteAuthProvider):
logger.debug(
"Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url
)
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(metadata_url)
response.raise_for_status()
metadata = response.json()
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/supabase.py b/fastmcp_slim/fastmcp/server/auth/providers/supabase.py
index 527d701ee..65c1ea2b3 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/supabase.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/supabase.py
@@ -9,7 +9,7 @@ from __future__ import annotations
from typing import Literal
-import httpx
+import httpx2
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
@@ -153,7 +153,7 @@ class SupabaseProvider(RemoteAuthProvider):
async def oauth_authorization_server_metadata(request):
"""Forward Supabase OAuth authorization server metadata with FastMCP customizations."""
try:
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(
f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server"
)
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/workos.py b/fastmcp_slim/fastmcp/server/auth/providers/workos.py
index 0a3f556b1..7ca3b924a 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/workos.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/workos.py
@@ -13,7 +13,7 @@ from __future__ import annotations
import contextlib
from typing import Literal
-import httpx
+import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
@@ -41,7 +41,7 @@ class WorkOSTokenVerifier(TokenVerifier):
authkit_domain: str,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the WorkOS token verifier.
@@ -49,7 +49,7 @@ class WorkOSTokenVerifier(TokenVerifier):
authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
required_scopes: Required OAuth scopes
timeout_seconds: HTTP request timeout
- http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@@ -64,7 +64,7 @@ class WorkOSTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
- else httpx.AsyncClient(timeout=self.timeout_seconds)
+ else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Use WorkOS AuthKit userinfo endpoint to validate token
response = await client.get(
@@ -115,7 +115,7 @@ class WorkOSTokenVerifier(TokenVerifier):
},
)
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
logger.debug("Failed to verify WorkOS token: %s", e)
return None
except Exception as e:
@@ -180,7 +180,7 @@ class WorkOSProvider(OAuthProxy):
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
- http_client: httpx.AsyncClient | None = None,
+ http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize WorkOS OAuth provider.
@@ -230,7 +230,7 @@ class WorkOSProvider(OAuthProxy):
token_expiry_threshold_seconds: Number of seconds before actual expiry to consider
a token as expired (default 0). Prevents race conditions where a token
passes the expiry check but expires before the next operation completes.
- http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
@@ -432,7 +432,7 @@ class AuthKitProvider(RemoteAuthProvider):
async def oauth_authorization_server_metadata(request):
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
try:
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(
f"{self.authkit_domain}/.well-known/oauth-authorization-server"
)
diff --git a/fastmcp_slim/fastmcp/server/auth/ssrf.py b/fastmcp_slim/fastmcp/server/auth/ssrf.py
index 1e240672c..49de0d33a 100644
--- a/fastmcp_slim/fastmcp/server/auth/ssrf.py
+++ b/fastmcp_slim/fastmcp/server/auth/ssrf.py
@@ -16,7 +16,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import urlparse
-import httpx
+import httpx2
from fastmcp.utilities.logging import get_logger
@@ -333,8 +333,8 @@ async def ssrf_safe_fetch_response(
try:
# Use httpx with streaming to enforce size limit during download
async with (
- httpx.AsyncClient(
- timeout=httpx.Timeout(
+ httpx2.AsyncClient(
+ timeout=httpx2.Timeout(
connect=min(timeout, remaining),
read=min(timeout, remaining),
write=min(timeout, remaining),
@@ -387,15 +387,15 @@ async def ssrf_safe_fetch_response(
headers=dict(response.headers),
)
- except httpx.TimeoutException as e:
+ except httpx2.TimeoutException as e:
last_error = e
continue
- except httpx.RequestError as e:
+ except httpx2.RequestError as e:
last_error = e
continue
if last_error is not None:
- if isinstance(last_error, httpx.TimeoutException):
+ if isinstance(last_error, httpx2.TimeoutException):
raise SSRFFetchError(f"Timeout fetching {url}") from last_error
raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error
diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/__init__.py b/fastmcp_slim/fastmcp/server/providers/openapi/__init__.py
index 3cbdde5e8..34d42d481 100644
--- a/fastmcp_slim/fastmcp/server/providers/openapi/__init__.py
+++ b/fastmcp_slim/fastmcp/server/providers/openapi/__init__.py
@@ -6,9 +6,9 @@ Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
- import httpx
+ import httpx2
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("API Server", providers=[provider])
```
diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/components.py b/fastmcp_slim/fastmcp/server/providers/openapi/components.py
index 45b493ce8..3b981226e 100644
--- a/fastmcp_slim/fastmcp/server/providers/openapi/components.py
+++ b/fastmcp_slim/fastmcp/server/providers/openapi/components.py
@@ -4,9 +4,9 @@ from __future__ import annotations
import json
import re
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
-import httpx
+import httpx2
from mcp_types import ToolAnnotations
from pydantic.networks import AnyUrl
@@ -19,6 +19,11 @@ from fastmcp.resources import (
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool, ToolResult
+from fastmcp.utilities.exceptions import (
+ HTTP_STATUS_ERRORS,
+ REQUEST_ERRORS,
+ TIMEOUT_ERRORS,
+)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector
@@ -41,7 +46,7 @@ _SAFE_HEADERS = frozenset(
)
-def _redact_headers(headers: httpx.Headers) -> dict[str, str]:
+def _redact_headers(headers: httpx2.Headers) -> dict[str, str]:
return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()}
@@ -138,7 +143,7 @@ class OpenAPITool(Tool):
def __init__(
self,
- client: httpx.AsyncClient,
+ client: httpx2.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
name: str,
@@ -169,12 +174,24 @@ class OpenAPITool(Tool):
# not HTTP failures, so we catch them separately.
try:
base_url = str(self._client.base_url) or "http://localhost"
- request = self._director.build(self._route, arguments, base_url)
+ directed_request = self._director.build(self._route, arguments, base_url)
- if self._client.headers:
- for key, value in self._client.headers.items():
- if key not in request.headers:
- request.headers[key] = value
+ # Rebuild through the user's client so the request object comes
+ # from whichever httpx library the client belongs to (a legacy
+ # httpx.AsyncClient cannot send an httpx2.Request). Primitive
+ # values (str/bytes/tuples) cross that boundary safely; client
+ # default headers merge in with directed headers taking priority,
+ # matching the previous manual merge.
+ request = self._client.build_request(
+ method=directed_request.method,
+ url=str(directed_request.url.copy_with(query=None)),
+ params=list(directed_request.url.params.multi_items()),
+ headers=list(directed_request.headers.raw),
+ # read() materializes streaming bodies (multipart files=)
+ # that .content would refuse with RequestNotRead; idempotent
+ # for plain byte bodies.
+ content=directed_request.read(),
+ )
mcp_headers = get_http_headers()
if mcp_headers:
@@ -221,22 +238,24 @@ class OpenAPITool(Tool):
except json.JSONDecodeError:
return ToolResult(content=response.text)
- except httpx.HTTPStatusError as e:
+ except HTTP_STATUS_ERRORS as e:
+ status_error = cast("httpx2.HTTPStatusError", e)
error_message = (
- f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
+ f"HTTP error {status_error.response.status_code}: "
+ f"{status_error.response.reason_phrase}"
)
try:
- error_data = e.response.json()
+ error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
- if e.response.text:
- error_message += f" - {e.response.text}"
+ if status_error.response.text:
+ error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
- except httpx.TimeoutException as e:
+ except TIMEOUT_ERRORS as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
- except httpx.RequestError as e:
+ except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
@@ -247,7 +266,7 @@ class OpenAPIResource(Resource):
def __init__(
self,
- client: httpx.AsyncClient,
+ client: httpx2.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri: str,
@@ -279,13 +298,17 @@ class OpenAPIResource(Resource):
directed_request = self._director.build(
self._route, self._arguments, base_url
)
+ # Primitive values only: a legacy httpx.AsyncClient cannot accept
+ # httpx2 URL/QueryParams/Headers objects.
request = self._client.build_request(
method=directed_request.method,
- url=directed_request.url.copy_with(query=None),
- params=directed_request.url.params,
- headers=directed_request.headers,
- content=directed_request.content,
- extensions=directed_request.extensions,
+ url=str(directed_request.url.copy_with(query=None)),
+ params=list(directed_request.url.params.multi_items()),
+ headers=list(directed_request.headers.raw),
+ # read() materializes streaming bodies (multipart files=)
+ # that .content would refuse with RequestNotRead; idempotent
+ # for plain byte bodies.
+ content=directed_request.read(),
)
mcp_headers = get_http_headers()
if mcp_headers:
@@ -320,22 +343,24 @@ class OpenAPIResource(Resource):
]
)
- except httpx.HTTPStatusError as e:
+ except HTTP_STATUS_ERRORS as e:
+ status_error = cast("httpx2.HTTPStatusError", e)
error_message = (
- f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
+ f"HTTP error {status_error.response.status_code}: "
+ f"{status_error.response.reason_phrase}"
)
try:
- error_data = e.response.json()
+ error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
- if e.response.text:
- error_message += f" - {e.response.text}"
+ if status_error.response.text:
+ error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
- except httpx.TimeoutException as e:
+ except TIMEOUT_ERRORS as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
- except httpx.RequestError as e:
+ except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
@@ -353,7 +378,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
def __init__(
self,
- client: httpx.AsyncClient,
+ client: httpx2.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri_template: str,
diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py
index 72e811d53..4048479a0 100644
--- a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py
+++ b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py
@@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import Any, Literal, cast
-import httpx
+import httpx2
from jsonschema_path import SchemaPath
from fastmcp.prompts import Prompt
@@ -58,9 +58,9 @@ class OpenAPIProvider(Provider):
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
- import httpx
+ import httpx2
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("API Server")
@@ -71,7 +71,7 @@ class OpenAPIProvider(Provider):
def __init__(
self,
openapi_spec: dict[str, Any],
- client: httpx.AsyncClient | None = None,
+ client: httpx2.AsyncClient | None = None,
*,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
@@ -166,19 +166,19 @@ class OpenAPIProvider(Provider):
logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes")
@classmethod
- def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
+ def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx2.AsyncClient:
"""Create a default httpx client from the OpenAPI spec's server URL."""
servers = openapi_spec.get("servers", [])
if not servers or not servers[0].get("url"):
raise ValueError(
"No server URL found in OpenAPI spec. Either add a 'servers' "
- "entry to the spec or provide an httpx.AsyncClient explicitly."
+ "entry to the spec or provide an httpx2.AsyncClient explicitly."
)
base_url = servers[0]["url"]
variables = servers[0].get("variables", {})
for name, var in variables.items():
base_url = base_url.replace(f"{{{name}}}", var.get("default", ""))
- return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
+ return httpx2.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index 29e6d6c19..72f48040f 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -14,7 +14,7 @@ from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any, cast
import anyio
-import httpx
+import httpx2
import mcp_types
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
@@ -127,7 +127,7 @@ class ProxyInitializeMiddleware(Middleware):
except (
RuntimeError,
TimeoutError,
- httpx.HTTPError,
+ httpx2.HTTPError,
anyio.ClosedResourceError,
anyio.EndOfStream,
anyio.BrokenResourceError,
diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py
index 110d5fcc0..13ad8e8d1 100644
--- a/fastmcp_slim/fastmcp/server/server.py
+++ b/fastmcp_slim/fastmcp/server/server.py
@@ -19,7 +19,7 @@ from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
-import httpx
+import httpx2
import mcp_types
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
@@ -85,6 +85,7 @@ from fastmcp.tools.base import Tool, ToolResult
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent, _coerce_version
+from fastmcp.utilities.exceptions import HTTP_STATUS_ERRORS, TIMEOUT_ERRORS
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
from fastmcp.utilities.versions import (
@@ -104,6 +105,11 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
+# Both-library catch tuples for user-supplied code that may still raise legacy
+# httpx exceptions; see fastmcp.utilities.exceptions for the defensive import.
+_ACTIONABLE_HTTP_STATUS_ERRORS = HTTP_STATUS_ERRORS
+_ACTIONABLE_TIMEOUT_ERRORS = TIMEOUT_ERRORS
+
def _version_request_meta(
version: VersionSpec | None,
@@ -1336,12 +1342,15 @@ class FastMCP(
logger.exception(f"Error calling tool {name!r}")
# Handle actionable errors that should reach the LLM
# even when masking is enabled
- if isinstance(e, httpx.HTTPStatusError):
- if e.response.status_code == 429:
+ if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
+ if (
+ cast("httpx2.HTTPStatusError", e).response.status_code
+ == 429
+ ):
raise ToolError(
"Rate limited by upstream API, please retry later"
) from e
- if isinstance(e, httpx.TimeoutException):
+ if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ToolError(
"Upstream request timed out, please retry"
) from e
@@ -1471,12 +1480,15 @@ class FastMCP(
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM
- if isinstance(e, httpx.HTTPStatusError):
- if e.response.status_code == 429:
+ if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
+ if (
+ cast("httpx2.HTTPStatusError", e).response.status_code
+ == 429
+ ):
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
- if isinstance(e, httpx.TimeoutException):
+ if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@@ -1533,12 +1545,15 @@ class FastMCP(
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM
- if isinstance(e, httpx.HTTPStatusError):
- if e.response.status_code == 429:
+ if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
+ if (
+ cast("httpx2.HTTPStatusError", e).response.status_code
+ == 429
+ ):
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
- if isinstance(e, httpx.TimeoutException):
+ if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@@ -2172,7 +2187,7 @@ class FastMCP(
def from_openapi(
cls,
openapi_spec: dict[str, Any],
- client: httpx.AsyncClient | None = None,
+ client: httpx2.AsyncClient | None = None,
name: str = "OpenAPI Server",
route_maps: list[RouteMap] | None = None,
route_map_fn: OpenAPIRouteMapFn | None = None,
@@ -2187,8 +2202,10 @@ class FastMCP(
Args:
openapi_spec: OpenAPI schema as a dictionary
- client: Optional httpx AsyncClient for making HTTP requests.
- If not provided, a default client is created using the first
+ client: Optional httpx2 AsyncClient for making HTTP requests.
+ An httpx (v1) AsyncClient is also accepted and works via
+ duck-typing. If not provided, a default client is created
+ using the first
server URL from the OpenAPI spec with a 30-second timeout.
name: Name for the MCP server
route_maps: Optional list of RouteMap objects defining route mappings
@@ -2242,7 +2259,7 @@ class FastMCP(
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
- httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient.
+ httpx_client_kwargs: Optional kwargs passed to httpx2.AsyncClient.
Use this to configure timeout and other client settings.
tags: Optional set of tags to add to all components
**settings: Additional settings passed to FastMCP
@@ -2256,8 +2273,8 @@ class FastMCP(
httpx_client_kwargs = {}
httpx_client_kwargs.setdefault("base_url", "http://fastapi")
- client = httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ client = httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
**httpx_client_kwargs,
)
diff --git a/fastmcp_slim/fastmcp/utilities/exceptions.py b/fastmcp_slim/fastmcp/utilities/exceptions.py
index dfdda672e..f9166a2b6 100644
--- a/fastmcp_slim/fastmcp/utilities/exceptions.py
+++ b/fastmcp_slim/fastmcp/utilities/exceptions.py
@@ -1,12 +1,37 @@
from collections.abc import Callable, Iterable, Mapping
from typing import Any
-import httpx
+import httpx2
from exceptiongroup import BaseExceptionGroup
from mcp import MCPError
import fastmcp
+# FastMCP uses httpx2 internally, but user-supplied code (tools, resources, and
+# clients handed to the OpenAPI integration) may still raise exceptions from the
+# legacy httpx package. These catch tuples include both families when httpx is
+# installed, so user errors keep their specific handling without making httpx a
+# FastMCP dependency. The two libraries' exception hierarchies match name-for-name.
+try:
+ import httpx
+
+ HTTP_STATUS_ERRORS: tuple[type[BaseException], ...] = (
+ httpx2.HTTPStatusError,
+ httpx.HTTPStatusError,
+ )
+ TIMEOUT_ERRORS: tuple[type[BaseException], ...] = (
+ httpx2.TimeoutException,
+ httpx.TimeoutException,
+ )
+ REQUEST_ERRORS: tuple[type[BaseException], ...] = (
+ httpx2.RequestError,
+ httpx.RequestError,
+ )
+except ImportError:
+ HTTP_STATUS_ERRORS = (httpx2.HTTPStatusError,)
+ TIMEOUT_ERRORS = (httpx2.TimeoutException,)
+ REQUEST_ERRORS = (httpx2.RequestError,)
+
def iter_exc(group: BaseExceptionGroup):
for exc in group.exceptions:
@@ -18,9 +43,9 @@ def iter_exc(group: BaseExceptionGroup):
def _exception_handler(group: BaseExceptionGroup):
for leaf in iter_exc(group):
- if isinstance(leaf, httpx.ConnectTimeout):
+ if isinstance(leaf, httpx2.ConnectTimeout):
raise MCPError(
- code=httpx.codes.REQUEST_TIMEOUT,
+ code=httpx2.codes.REQUEST_TIMEOUT,
message="Timed out while waiting for response.",
)
raise leaf
diff --git a/fastmcp_slim/fastmcp/utilities/openapi/director.py b/fastmcp_slim/fastmcp/utilities/openapi/director.py
index 9c8a52695..2f3e11ae2 100644
--- a/fastmcp_slim/fastmcp/utilities/openapi/director.py
+++ b/fastmcp_slim/fastmcp/utilities/openapi/director.py
@@ -5,7 +5,7 @@ import json as _json
from typing import Any, ClassVar
from urllib.parse import quote, urljoin
-import httpx
+import httpx2
from jsonschema_path import SchemaPath
from fastmcp.utilities.logging import get_logger
@@ -27,7 +27,7 @@ def _query_scalar_to_str(value: Any) -> str:
class RequestDirector:
- """Builds httpx.Request objects from HTTPRoute and arguments using openapi-core."""
+ """Builds httpx2.Request objects from HTTPRoute and arguments using openapi-core."""
def __init__(self, spec: SchemaPath):
"""Initialize with a parsed SchemaPath object."""
@@ -38,9 +38,9 @@ class RequestDirector:
route: HTTPRoute,
flat_args: dict[str, Any],
base_url: str = "http://localhost",
- ) -> httpx.Request:
+ ) -> httpx2.Request:
"""
- Constructs a final httpx.Request object, handling all OpenAPI serialization.
+ Constructs a final httpx2.Request object, handling all OpenAPI serialization.
Args:
route: HTTPRoute containing OpenAPI operation details
@@ -48,7 +48,7 @@ class RequestDirector:
base_url: Base URL for the request
Returns:
- httpx.Request: Properly formatted HTTP request
+ httpx2.Request: Properly formatted HTTP request
"""
logger.debug(
f"Building request for {route.method} {route.path} with args: {flat_args}"
@@ -140,8 +140,8 @@ class RequestDirector:
else:
content = body
- # Step 7: Create httpx.Request
- return httpx.Request(
+ # Step 7: Create httpx2.Request
+ return httpx2.Request(
method=method,
url=url,
params=params,
diff --git a/fastmcp_slim/fastmcp/utilities/tests.py b/fastmcp_slim/fastmcp/utilities/tests.py
index 0ca29c146..77b31ba36 100644
--- a/fastmcp_slim/fastmcp/utilities/tests.py
+++ b/fastmcp_slim/fastmcp/utilities/tests.py
@@ -9,7 +9,7 @@ from contextlib import asynccontextmanager, contextmanager, suppress
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import parse_qs, urlparse
-import httpx
+import httpx2
import uvicorn
from mcp.shared.auth import AuthorizationCodeResult
@@ -238,7 +238,7 @@ class HeadlessOAuth(OAuth):
async def redirect_handler(self, authorization_url: str) -> None:
"""Make HTTP request to authorization URL and store response for callback handler."""
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
response = await client.get(authorization_url, follow_redirects=False)
self._stored_response = response
diff --git a/fastmcp_slim/fastmcp/utilities/version_check.py b/fastmcp_slim/fastmcp/utilities/version_check.py
index 5c3aba634..1b1c9178a 100644
--- a/fastmcp_slim/fastmcp/utilities/version_check.py
+++ b/fastmcp_slim/fastmcp/utilities/version_check.py
@@ -6,7 +6,7 @@ import json
import time
from pathlib import Path
-import httpx
+import httpx2
from packaging.version import Version
from fastmcp.utilities.logging import get_logger
@@ -66,7 +66,7 @@ def _fetch_latest_version(include_prereleases: bool = False) -> str | None:
The latest version string, or None if the fetch failed.
"""
try:
- response = httpx.get(PYPI_URL, timeout=REQUEST_TIMEOUT_SECONDS)
+ response = httpx2.get(PYPI_URL, timeout=REQUEST_TIMEOUT_SECONDS)
response.raise_for_status()
data = response.json()
@@ -91,7 +91,7 @@ def _fetch_latest_version(include_prereleases: bool = False) -> str | None:
return str(max(versions))
- except (httpx.HTTPError, json.JSONDecodeError, KeyError):
+ except (httpx2.HTTPError, json.JSONDecodeError, KeyError):
return None
diff --git a/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml
index 7555ae5f1..bb78b47a2 100644
--- a/fastmcp_slim/pyproject.toml
+++ b/fastmcp_slim/pyproject.toml
@@ -4,7 +4,7 @@ dynamic = ["version", "optional-dependencies"]
description = "The dependency-slim FastMCP package."
authors = [{ name = "Jeremiah Lowin" }]
dependencies = [
- "mcp-types==2.0.0b1",
+ "mcp-types==2.0.0b2",
"platformdirs>=4.0.0",
"pydantic[email]>=2.12.0",
"pydantic-settings>=2.0.0",
@@ -78,8 +78,11 @@ code-mode = ["pydantic-monty==0.0.17"]
gemini = ["google-genai>=1.18.0", "jsonref>=1.1.0"]
mcp = [
"exceptiongroup>=1.2.2",
- "httpx>=0.28.1,<1.0",
- "mcp==2.0.0b1",
+ # FastMCP uses httpx2 exclusively: the MCP SDK boundary (client transports,
+ # client auth) requires it, and all FastMCP-owned HTTP (server auth provider
+ # upstream calls, OpenAPI provider, version check, etc.) uses it too.
+ "httpx2>=2.5.0",
+ "mcp==2.0.0b2",
"opentelemetry-api>=1.28.0",
# starlette floor: transitive via mcp (which only requires >=0.27).
# Pin past CVE-2026-48710, which was patched in 1.0.1.
diff --git a/pyproject.toml b/pyproject.toml
index 55079ec66..f8cacf956 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,7 +72,7 @@ members = ["fastmcp_slim", "fastmcp_remote"]
[tool.uv]
default-groups = ["dev"]
exclude-newer = "1 week"
-exclude-newer-package = { prefab-ui = false, mcp = false, mcp-types = false }
+exclude-newer-package = { prefab-ui = false, mcp = false, mcp-types = false, httpx2 = false, httpcore2 = false, truststore = false }
[dependency-groups]
dev = [
@@ -92,7 +92,6 @@ dev = [
"pytest-cov>=6.1.1",
"pytest-env>=1.1.5",
"pytest-flakefinder>=1.1.0",
- "pytest-httpx>=0.35.0",
"pytest-report>=0.2.1",
"pytest-retry>=1.7.0",
"pytest-timeout>=2.4.0",
diff --git a/tests/client/auth/test_oauth_cimd.py b/tests/client/auth/test_oauth_cimd.py
index 04818af60..26ee146e9 100644
--- a/tests/client/auth/test_oauth_cimd.py
+++ b/tests/client/auth/test_oauth_cimd.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import warnings
-import httpx
+import httpx2
import pytest
from fastmcp.client.auth import OAuth
@@ -108,7 +108,7 @@ class TestOAuthBind:
async def test_unbound_raises_runtime_error(self):
"""async_auth_flow should fail clearly when OAuth is not bound."""
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
- request = httpx.Request("GET", MCP_SERVER_URL)
+ request = httpx2.Request("GET", MCP_SERVER_URL)
with pytest.raises(RuntimeError, match="no server URL"):
async for _ in oauth.async_auth_flow(request):
pass
diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py
index e069270e0..1d873b16d 100644
--- a/tests/client/auth/test_oauth_client.py
+++ b/tests/client/auth/test_oauth_client.py
@@ -3,7 +3,7 @@ import time
from unittest.mock import patch
from urllib.parse import urlparse
-import httpx
+import httpx2
import pytest
from mcp import MCPError
from mcp_types import TextResourceContents
@@ -72,7 +72,7 @@ async def test_unauthorized(client_unauthorized: Client):
"""Test that unauthenticated requests are rejected.
SDK v2 surfaces the server's 401 as an MCPError ("Server returned an error
- response") rather than re-raising the raw httpx.HTTPStatusError.
+ response") rather than re-raising the raw httpx2.HTTPStatusError.
"""
with pytest.raises(MCPError, match="error response"):
async with client_unauthorized:
@@ -123,7 +123,7 @@ async def test_oauth_server_metadata_discovery(streamable_http_server: str):
parsed_url = urlparse(streamable_http_server)
server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
# Test OAuth discovery endpoint
metadata_url = f"{server_base_url}/.well-known/oauth-authorization-server"
response = await client.get(metadata_url)
@@ -305,13 +305,13 @@ class TestOAuthGeneratorCleanup:
if self._exhausted:
raise StopAsyncIteration
self._exhausted = True
- return httpx.Request("GET", "https://example.com")
+ return httpx2.Request("GET", "https://example.com")
async def asend(self, value):
if self._exhausted:
raise StopAsyncIteration
self._exhausted = True
- return httpx.Request("GET", "https://example.com")
+ return httpx2.Request("GET", "https://example.com")
async def athrow(self, exc_type, exc_val=None, exc_tb=None):
raise StopAsyncIteration
@@ -326,12 +326,12 @@ class TestOAuthGeneratorCleanup:
OAuth.__bases__[0], "async_auth_flow", return_value=tracked_gen
):
# Drive the OAuth flow
- flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
+ flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
try:
# First asend(None) starts the generator per async generator protocol
await flow.asend(None) # ty: ignore[invalid-argument-type]
try:
- await flow.asend(httpx.Response(200))
+ await flow.asend(httpx2.Response(200))
except StopAsyncIteration:
pass
except StopAsyncIteration:
@@ -359,7 +359,7 @@ class TestOAuthGeneratorCleanup:
async def asend(self, value):
if self._first_call:
self._first_call = False
- return httpx.Request("GET", "https://example.com")
+ return httpx2.Request("GET", "https://example.com")
raise ValueError("Simulated failure")
async def athrow(self, exc_type, exc_val=None, exc_tb=None):
@@ -373,10 +373,10 @@ class TestOAuthGeneratorCleanup:
with patch.object(
OAuth.__bases__[0], "async_auth_flow", return_value=tracked_gen
):
- flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
+ flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
with pytest.raises(ValueError, match="Simulated failure"):
await flow.asend(None) # ty: ignore[invalid-argument-type]
- await flow.asend(httpx.Response(200))
+ await flow.asend(httpx2.Response(200))
assert tracked_gen.aclose_called, (
"Generator aclose() was not called after exception"
diff --git a/tests/client/auth/test_oauth_static_client.py b/tests/client/auth/test_oauth_static_client.py
index c9f17cdbe..1fbae5c8e 100644
--- a/tests/client/auth/test_oauth_static_client.py
+++ b/tests/client/auth/test_oauth_static_client.py
@@ -2,7 +2,7 @@
from unittest.mock import patch
-import httpx
+import httpx2
import pytest
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
@@ -146,7 +146,7 @@ class TestStaticClientRetryBehavior:
with patch.object(
OAuth.__bases__[0], "async_auth_flow", side_effect=failing_auth_flow
):
- flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
+ flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
with pytest.raises(ClientNotFoundError, match="static client credentials"):
await flow.__anext__()
@@ -162,12 +162,12 @@ class TestStaticClientRetryBehavior:
if call_count == 1:
raise ClientNotFoundError("client not found")
# Second attempt succeeds
- yield httpx.Request("GET", "https://example.com")
+ yield httpx2.Request("GET", "https://example.com")
with patch.object(
OAuth.__bases__[0], "async_auth_flow", side_effect=auth_flow_with_retry
):
- flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
+ flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
request = await flow.__anext__()
assert request is not None
assert call_count == 2
diff --git a/tests/client/test_oauth_callback_race.py b/tests/client/test_oauth_callback_race.py
index 1e1e75efc..43efddee8 100644
--- a/tests/client/test_oauth_callback_race.py
+++ b/tests/client/test_oauth_callback_race.py
@@ -1,5 +1,5 @@
import anyio
-import httpx
+import httpx2
from fastmcp.client.oauth_callback import (
OAuthCallbackResult,
@@ -24,7 +24,7 @@ async def test_oauth_callback_result_ignores_subsequent_callbacks():
await anyio.sleep(0.05)
- async with httpx.AsyncClient() as client:
+ async with httpx2.AsyncClient() as client:
first = await client.get(
f"http://127.0.0.1:{port}/callback?code=good&state=s1"
)
diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py
index e4ea6e68b..f109587e6 100644
--- a/tests/client/test_streamable_http.py
+++ b/tests/client/test_streamable_http.py
@@ -234,9 +234,9 @@ async def test_elicitation_tool(streamable_http_server: str, request):
@pytest.mark.parametrize("streamable_http_server", [True], indirect=True)
async def test_stateless_http_rejects_get_sse(streamable_http_server: str):
"""Stateless servers should reject GET SSE requests with 405."""
- import httpx
+ import httpx2
- async with httpx.AsyncClient() as http_client:
+ async with httpx2.AsyncClient() as http_client:
response = await http_client.get(streamable_http_server)
assert response.status_code == 405
diff --git a/tests/client/transports/test_no_redirect.py b/tests/client/transports/test_no_redirect.py
index 00af8911c..06476f71b 100644
--- a/tests/client/transports/test_no_redirect.py
+++ b/tests/client/transports/test_no_redirect.py
@@ -5,7 +5,7 @@ _redirect_headers mechanism. These tests verify that FastMCP's transports rely o
this behavior correctly and do not override it.
"""
-import httpx
+import httpx2
import pytest
from starlette.applications import Starlette
from starlette.requests import Request
@@ -41,8 +41,8 @@ class TestHttpxBuiltinRedirectProtection:
)
# Use an httpx client with follow_redirects=True (as MCP does)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
follow_redirects=True,
) as client:
response = await client.get(
@@ -76,8 +76,8 @@ class TestHttpxBuiltinRedirectProtection:
]
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
follow_redirects=True,
) as client:
response = await client.get(
@@ -119,8 +119,8 @@ class TestHttpxBuiltinRedirectProtection:
]
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
follow_redirects=True,
) as client:
response = await client.get(
@@ -159,8 +159,8 @@ class TestMcpHttpClientRedirectProtection:
# Use AsyncClient directly with ASGI transport rather than
# monkey-patching _transport on create_mcp_http_client, which
# breaks when proxy env vars are set.
- client = httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ client = httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
headers={"Authorization": "Bearer secret"},
follow_redirects=True,
)
diff --git a/tests/client/transports/test_transports.py b/tests/client/transports/test_transports.py
index 79e85bab9..d46867763 100644
--- a/tests/client/transports/test_transports.py
+++ b/tests/client/transports/test_transports.py
@@ -4,7 +4,7 @@ from collections.abc import AsyncIterator
from ssl import VerifyMode
from typing import Any, cast
-import httpx
+import httpx2
import pytest
from mcp.shared._httpx_utils import McpHttpClientFactory
@@ -44,7 +44,7 @@ class TestClientTransport:
async def test_oauth_uses_same_client_as_transport_streamable_http():
transport = StreamableHttpTransport(
"https://some.fake.url/",
- httpx_client_factory=lambda *args, **kwargs: httpx.AsyncClient(
+ httpx_client_factory=lambda *args, **kwargs: httpx2.AsyncClient(
verify=False, *args, **kwargs
),
auth="oauth",
@@ -62,7 +62,7 @@ async def test_oauth_uses_same_client_as_transport_streamable_http():
async def test_oauth_uses_same_client_as_transport_sse():
transport = SSETransport(
"https://some.fake.url/",
- httpx_client_factory=lambda *args, **kwargs: httpx.AsyncClient(
+ httpx_client_factory=lambda *args, **kwargs: httpx2.AsyncClient(
verify=False, *args, **kwargs
),
auth="oauth",
@@ -263,7 +263,7 @@ class TestSSLVerify:
async def test_oauth_custom_factory_preserved_with_verify(self):
custom_factory = cast(
McpHttpClientFactory,
- lambda **kwargs: httpx.AsyncClient(verify=False, **kwargs),
+ lambda **kwargs: httpx2.AsyncClient(verify=False, **kwargs),
)
auth = OAuth(httpx_client_factory=custom_factory)
transport = StreamableHttpTransport(
@@ -275,7 +275,7 @@ class TestSSLVerify:
assert transport.auth.httpx_client_factory is custom_factory
def test_warns_when_both_factory_and_verify_provided_streamable(self):
- factory = cast(McpHttpClientFactory, httpx.AsyncClient)
+ factory = cast(McpHttpClientFactory, httpx2.AsyncClient)
with pytest.warns(UserWarning, match="httpx_client_factory.*takes precedence"):
StreamableHttpTransport(
"https://example.com/mcp",
@@ -284,7 +284,7 @@ class TestSSLVerify:
)
def test_warns_when_both_factory_and_verify_provided_sse(self):
- factory = cast(McpHttpClientFactory, httpx.AsyncClient)
+ factory = cast(McpHttpClientFactory, httpx2.AsyncClient)
with pytest.warns(UserWarning, match="httpx_client_factory.*takes precedence"):
SSETransport(
"https://example.com/sse",
diff --git a/tests/conftest.py b/tests/conftest.py
index 51ee2c26d..6eef890e2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,6 +15,7 @@ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from fastmcp.utilities.tests import temporary_settings
+from tests.utilities.httpx2_mock import httpx_mock as httpx_mock
# Use SelectorEventLoop on Windows to avoid ProactorEventLoop crashes
# See: https://github.com/python/cpython/issues/116773
diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py
index 990bba096..af7863746 100644
--- a/tests/integration_tests/auth/test_github_provider_integration.py
+++ b/tests/integration_tests/auth/test_github_provider_integration.py
@@ -17,7 +17,7 @@ import time
from collections.abc import AsyncGenerator
from urllib.parse import parse_qs, urlencode, urlparse
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -216,7 +216,7 @@ async def test_github_oauth_authorization_redirect(github_server: str):
parsed = urlparse(github_server)
base_url = f"{parsed.scheme}://{parsed.netloc}"
- async with httpx.AsyncClient() as http_client:
+ async with httpx2.AsyncClient() as http_client:
# Step 1: Register OAuth client (DCR)
register_response = await http_client.post(
f"{base_url}/register",
@@ -311,13 +311,13 @@ async def test_github_oauth_server_metadata(github_server: str):
"""Test OAuth server metadata discovery."""
from urllib.parse import urlparse
- import httpx
+ import httpx2
# Extract base URL from server URL
parsed = urlparse(github_server)
base_url = f"{parsed.scheme}://{parsed.netloc}"
- async with httpx.AsyncClient() as http_client:
+ async with httpx2.AsyncClient() as http_client:
# Test OAuth authorization server metadata
metadata_response = await http_client.get(
f"{base_url}/.well-known/oauth-authorization-server"
@@ -340,7 +340,7 @@ async def test_github_oauth_unauthorized_access(github_server: str):
"""Test that unauthenticated requests are rejected.
SDK v2 surfaces the server's 401 as an MCPError ("Server returned an error
- response") rather than re-raising the raw httpx.HTTPStatusError.
+ response") rather than re-raising the raw httpx2.HTTPStatusError.
"""
from mcp.shared.exceptions import MCPError
@@ -375,13 +375,13 @@ async def test_github_oauth_mock_only_accepts_mock_tokens(github_server_with_moc
"""Test that the mock token verifier only accepts mock tokens, not real ones."""
from urllib.parse import urlparse
- import httpx
+ import httpx2
# Extract base URL
parsed = urlparse(github_server_with_mock)
base_url = f"{parsed.scheme}://{parsed.netloc}"
- async with httpx.AsyncClient() as http_client:
+ async with httpx2.AsyncClient() as http_client:
# Test that a fake "real" GitHub token is rejected
fake_real_token = "gho_real_token_should_be_rejected"
diff --git a/tests/integration_tests/auth/test_keycloak_provider_integration.py b/tests/integration_tests/auth/test_keycloak_provider_integration.py
index 3b3a8fafb..db17328c6 100644
--- a/tests/integration_tests/auth/test_keycloak_provider_integration.py
+++ b/tests/integration_tests/auth/test_keycloak_provider_integration.py
@@ -3,7 +3,7 @@
import os
from unittest.mock import AsyncMock, Mock, patch
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -19,7 +19,7 @@ class TestKeycloakProviderIntegration:
async def test_oauth_discovery_endpoints_integration(self):
"""Test OAuth discovery endpoints work correctly together."""
- with patch("httpx.get") as mock_get:
+ with patch("httpx2.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = {
"issuer": TEST_REALM_URL,
@@ -40,8 +40,8 @@ class TestKeycloakProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
# Test protected resource metadata
@@ -73,8 +73,8 @@ class TestKeycloakProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
response = await client.post(
@@ -91,11 +91,11 @@ class TestKeycloakProviderIntegration:
async def test_authorization_server_metadata_forwards_keycloak(self):
"""Test that authorization server metadata is forwarded from Keycloak.
- Note: This test is skipped because mocking httpx.AsyncClient conflicts with the
+ Note: This test is skipped because mocking httpx2.AsyncClient conflicts with the
ASGI transport used by the test client. The functionality has been verified to
work correctly in production (see user testing logs showing successful DCR proxy).
"""
- with patch("httpx.get") as mock_get:
+ with patch("httpx2.get") as mock_get:
# Mock OIDC discovery
mock_discovery = Mock()
mock_discovery.json.return_value = {
@@ -118,7 +118,7 @@ class TestKeycloakProviderIntegration:
mcp_http_app = mcp.http_app()
# Mock the metadata forwarding request
- with patch("httpx.AsyncClient") as mock_client_class:
+ with patch("httpx2.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
@@ -136,8 +136,8 @@ class TestKeycloakProviderIntegration:
mock_metadata_response.raise_for_status = Mock()
mock_client.get.return_value = mock_metadata_response
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
# Test authorization server metadata forwarding
@@ -190,10 +190,10 @@ class TestKeycloakProviderIntegration:
async def test_metadata_forwarding_error_handling(self):
"""Test error handling when metadata forwarding fails.
- Note: This test is skipped because mocking httpx.AsyncClient conflicts with the
+ Note: This test is skipped because mocking httpx2.AsyncClient conflicts with the
ASGI transport. Error handling code is present and follows standard patterns.
"""
- with patch("httpx.get") as mock_get:
+ with patch("httpx2.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = {
"issuer": TEST_REALM_URL,
@@ -212,15 +212,15 @@ class TestKeycloakProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
- with patch("httpx.AsyncClient") as mock_client_class:
+ with patch("httpx2.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
# Simulate Keycloak error
- mock_client.get.side_effect = httpx.RequestError("Connection failed")
+ mock_client.get.side_effect = httpx2.RequestError("Connection failed")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
response = await client.get(
@@ -247,7 +247,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
with (
patch.dict(os.environ, env_vars),
- patch("httpx.get") as mock_get,
+ patch("httpx2.get") as mock_get,
):
mock_response = Mock()
mock_response.json.return_value = {
@@ -283,7 +283,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
async def test_provider_works_in_production_like_environment(self):
"""Test provider configuration that mimics production deployment.
- Note: This test is skipped because mocking httpx.AsyncClient conflicts with the
+ Note: This test is skipped because mocking httpx2.AsyncClient conflicts with the
ASGI transport used by the test client. The functionality has been verified to
work correctly in production (see user testing logs showing successful DCR proxy).
"""
@@ -295,7 +295,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
with (
patch.dict(os.environ, production_env),
- patch("httpx.get") as mock_get,
+ patch("httpx2.get") as mock_get,
):
mock_response = Mock()
mock_response.json.return_value = {
@@ -319,7 +319,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
mcp = FastMCP("production-server", auth=provider)
mcp_http_app = mcp.http_app()
- with patch("httpx.AsyncClient") as mock_client_class:
+ with patch("httpx2.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
@@ -335,8 +335,8 @@ class TestKeycloakProviderEnvironmentConfiguration:
mock_metadata.raise_for_status = Mock()
mock_client.get.return_value = mock_metadata
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://api.company.com",
) as client:
# Test discovery endpoints work
diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py
index c85b38e53..94a93a141 100644
--- a/tests/integration_tests/conftest.py
+++ b/tests/integration_tests/conftest.py
@@ -21,7 +21,7 @@ def _is_rate_limit_error(excinfo, report=None) -> bool:
if exc_type == "BrokenResourceError":
return True
- # httpx.HTTPStatusError with 429 status
+ # httpx2.HTTPStatusError with 429 status
if exc_type == "HTTPStatusError":
try:
if hasattr(exc, "response") and exc.response.status_code == 429:
diff --git a/tests/server/auth/oauth_proxy/test_client_registration.py b/tests/server/auth/oauth_proxy/test_client_registration.py
index a074c75f0..37c63ba0b 100644
--- a/tests/server/auth/oauth_proxy/test_client_registration.py
+++ b/tests/server/auth/oauth_proxy/test_client_registration.py
@@ -1,6 +1,6 @@
"""Tests for OAuth proxy client registration (DCR)."""
-import httpx
+import httpx2
import pytest
from mcp.server.auth.provider import RegistrationError
from mcp.shared.auth import OAuthClientInformationFull
@@ -175,9 +175,9 @@ class TestOAuthProxyClientRegistration:
oauth_proxy.update_default_scopes(["read", "write", "calendar"])
app = Starlette(routes=oauth_proxy.get_routes())
- transport = httpx.ASGITransport(app=app)
+ transport = httpx2.ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport,
base_url="https://myserver.com",
) as client:
diff --git a/tests/server/auth/oauth_proxy/test_e2e.py b/tests/server/auth/oauth_proxy/test_e2e.py
index 39c4592e8..34b2496aa 100644
--- a/tests/server/auth/oauth_proxy/test_e2e.py
+++ b/tests/server/auth/oauth_proxy/test_e2e.py
@@ -4,7 +4,7 @@ import time
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlparse
-import httpx
+import httpx2
from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationCode, AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
@@ -125,7 +125,7 @@ class TestOAuthProxyE2E:
# Configure mock to call real provider for refresh
async def mock_refresh(*args, **kwargs):
- async with httpx.AsyncClient() as http:
+ async with httpx2.AsyncClient() as http:
response = await http.post(
mock_oauth_provider.token_endpoint,
data={
diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py
index b221f3ccc..f3b962bc4 100644
--- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py
+++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py
@@ -3,14 +3,14 @@
import time
from urllib.parse import parse_qs, urlparse
-import httpx
+import httpx2
import pytest
-from authlib.integrations.httpx_client import AsyncOAuth2Client
from key_value.aio.stores.memory import MemoryStore
from starlette.applications import Starlette
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction
+from fastmcp.server.auth.oauth_proxy.upstream import AsyncOAuth2Client
class TestOAuthProxyInitialization:
@@ -221,9 +221,9 @@ class TestOAuthProxyInitialization:
)
app = Starlette(routes=proxy.get_routes())
- transport = httpx.ASGITransport(app=app)
+ transport = httpx2.ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport, base_url="https://api.example.com"
) as client:
response = await client.get("/.well-known/oauth-authorization-server")
@@ -339,9 +339,9 @@ class TestIdpCallbackErrorForwarding:
await oauth_proxy._transaction_store.put(key=txn_id, value=transaction)
app = Starlette(routes=oauth_proxy.get_routes())
- transport = httpx.ASGITransport(app=app)
+ transport = httpx2.ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport,
base_url="https://myserver.com",
follow_redirects=False,
@@ -380,9 +380,9 @@ class TestIdpCallbackErrorForwarding:
await oauth_proxy._transaction_store.put(key=txn_id, value=transaction)
app = Starlette(routes=oauth_proxy.get_routes())
- transport = httpx.ASGITransport(app=app)
+ transport = httpx2.ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport,
base_url="https://myserver.com",
follow_redirects=False,
@@ -400,9 +400,9 @@ class TestIdpCallbackErrorForwarding:
expired, the proxy must return a local HTML error page — there is no
trusted client redirect_uri to forward to."""
app = Starlette(routes=oauth_proxy.get_routes())
- transport = httpx.ASGITransport(app=app)
+ transport = httpx2.ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport,
base_url="https://myserver.com",
follow_redirects=False,
diff --git a/tests/server/auth/oauth_proxy/test_upstream_client.py b/tests/server/auth/oauth_proxy/test_upstream_client.py
new file mode 100644
index 000000000..884edcc3e
--- /dev/null
+++ b/tests/server/auth/oauth_proxy/test_upstream_client.py
@@ -0,0 +1,183 @@
+"""Wire-behavior tests for the httpx2-based upstream OAuth2 client.
+
+This client replaced authlib's `AsyncOAuth2Client` for upstream token-endpoint
+calls; these tests pin the wire format authlib produced so the migration is
+observable: form-encoded bodies, client authentication methods, falsy-param
+dropping, refresh-token injection, expires_at derivation, and error mapping.
+"""
+
+import base64
+import time
+from urllib.parse import parse_qs
+
+import httpx2
+import pytest
+from authlib.integrations.base_client import OAuthError
+
+from fastmcp.server.auth.oauth_proxy.upstream import AsyncOAuth2Client
+from tests.utilities.httpx2_mock import HTTPXMock
+
+TOKEN_URL = "https://idp.example.com/token"
+
+
+def _form(request: httpx2.Request) -> dict[str, list[str]]:
+ return parse_qs(request.content.decode("utf-8"))
+
+
+class TestClientAuthMethods:
+ async def test_default_is_client_secret_basic(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, json={"access_token": "tok"})
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ await client.fetch_token(TOKEN_URL, code="abc", redirect_uri="https://cb")
+ await client.aclose()
+
+ request = httpx_mock.get_request()
+ assert request is not None
+ expected = base64.b64encode(b"cid:sec").decode("ascii")
+ assert request.headers["Authorization"] == f"Basic {expected}"
+ assert "client_secret" not in _form(request)
+
+ async def test_client_secret_post(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, json={"access_token": "tok"})
+ client = AsyncOAuth2Client(
+ client_id="cid",
+ client_secret="sec",
+ token_endpoint_auth_method="client_secret_post",
+ )
+ await client.fetch_token(TOKEN_URL, code="abc")
+ await client.aclose()
+
+ request = httpx_mock.get_request()
+ assert request is not None
+ form = _form(request)
+ assert form["client_id"] == ["cid"]
+ assert form["client_secret"] == ["sec"]
+ assert "Authorization" not in request.headers
+
+ async def test_none_auth_sends_client_id_only(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, json={"access_token": "tok"})
+ client = AsyncOAuth2Client(client_id="cid", token_endpoint_auth_method="none")
+ await client.fetch_token(TOKEN_URL, code="abc")
+ await client.aclose()
+
+ request = httpx_mock.get_request()
+ assert request is not None
+ form = _form(request)
+ assert form["client_id"] == ["cid"]
+ assert "client_secret" not in form
+ assert "Authorization" not in request.headers
+
+ async def test_unsupported_method_raises(self):
+ client = AsyncOAuth2Client(
+ client_id="cid", token_endpoint_auth_method="private_key_jwt"
+ )
+ with pytest.raises(ValueError, match="Unsupported token_endpoint_auth_method"):
+ await client.fetch_token(TOKEN_URL, code="abc")
+ await client.aclose()
+
+
+class TestFetchToken:
+ async def test_authorization_code_body(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, json={"access_token": "tok"})
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ await client.fetch_token(
+ TOKEN_URL,
+ code="abc",
+ redirect_uri="https://cb",
+ code_verifier="ver",
+ scope="openid email",
+ )
+ await client.aclose()
+
+ request = httpx_mock.get_request()
+ assert request is not None
+ assert request.method == "POST"
+ assert (
+ request.headers["Content-Type"]
+ == "application/x-www-form-urlencoded;charset=UTF-8"
+ )
+ form = _form(request)
+ assert form["grant_type"] == ["authorization_code"]
+ assert form["code"] == ["abc"]
+ assert form["redirect_uri"] == ["https://cb"]
+ assert form["code_verifier"] == ["ver"]
+ assert form["scope"] == ["openid email"]
+
+ async def test_falsy_params_dropped(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, json={"access_token": "tok"})
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ await client.fetch_token(TOKEN_URL, code="abc", scope=None, audience="")
+ await client.aclose()
+
+ request = httpx_mock.get_request()
+ assert request is not None
+ form = _form(request)
+ assert "scope" not in form
+ assert "audience" not in form
+
+ async def test_expires_at_derived_from_expires_in(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(
+ url=TOKEN_URL, json={"access_token": "tok", "expires_in": 3600}
+ )
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ before = int(time.time())
+ token = await client.fetch_token(TOKEN_URL, code="abc")
+ await client.aclose()
+
+ assert before + 3600 <= token["expires_at"] <= int(time.time()) + 3600
+
+ async def test_oauth_error_response_raises(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(
+ url=TOKEN_URL,
+ status_code=400,
+ json={"error": "invalid_grant", "error_description": "bad code"},
+ )
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ with pytest.raises(OAuthError, match="invalid_grant"):
+ await client.fetch_token(TOKEN_URL, code="abc")
+ await client.aclose()
+
+ async def test_server_error_raises_http_status_error(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, status_code=503, text="down")
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ with pytest.raises(httpx2.HTTPStatusError):
+ await client.fetch_token(TOKEN_URL, code="abc")
+ await client.aclose()
+
+
+class TestRefreshToken:
+ async def test_refresh_body(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(
+ url=TOKEN_URL, json={"access_token": "new", "refresh_token": "rot"}
+ )
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ token = await client.refresh_token(
+ TOKEN_URL, refresh_token="old", scope="openid"
+ )
+ await client.aclose()
+
+ request = httpx_mock.get_request()
+ assert request is not None
+ form = _form(request)
+ assert form["grant_type"] == ["refresh_token"]
+ assert form["refresh_token"] == ["old"]
+ assert form["scope"] == ["openid"]
+ assert token["refresh_token"] == "rot"
+
+ async def test_unrotated_refresh_token_injected(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, json={"access_token": "new"})
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ token = await client.refresh_token(TOKEN_URL, refresh_token="old")
+ await client.aclose()
+
+ assert token["refresh_token"] == "old"
+
+ async def test_none_scope_omitted(self, httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url=TOKEN_URL, json={"access_token": "new"})
+ client = AsyncOAuth2Client(client_id="cid", client_secret="sec")
+ await client.refresh_token(TOKEN_URL, refresh_token="old", scope=None)
+ await client.aclose()
+
+ request = httpx_mock.get_request()
+ assert request is not None
+ assert "scope" not in _form(request)
diff --git a/tests/server/auth/providers/test_aws.py b/tests/server/auth/providers/test_aws.py
index 4f5232b53..8dfeaa03b 100644
--- a/tests/server/auth/providers/test_aws.py
+++ b/tests/server/auth/providers/test_aws.py
@@ -27,7 +27,7 @@ def mock_cognito_oidc_discovery():
],
}
- with patch("httpx.get") as mock_get:
+ with patch("httpx2.get") as mock_get:
mock_response = mock_get.return_value
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = mock_oidc_config
diff --git a/tests/server/auth/providers/test_clerk.py b/tests/server/auth/providers/test_clerk.py
index 323b36572..0e9d56cb5 100644
--- a/tests/server/auth/providers/test_clerk.py
+++ b/tests/server/auth/providers/test_clerk.py
@@ -2,12 +2,12 @@
import re
-import httpx
+import httpx2
import pytest
from key_value.aio.stores.memory import MemoryStore
-from pytest_httpx import HTTPXMock
from fastmcp.server.auth.providers.clerk import ClerkProvider, ClerkTokenVerifier
+from tests.utilities.httpx2_mock import HTTPXMock
CLERK_DOMAIN = "test-instance.clerk.accounts.dev"
@@ -496,7 +496,7 @@ class TestClerkTokenVerifier:
async def test_network_error_returns_none(self, httpx_mock: HTTPXMock):
"""Network errors during introspection return None instead of raising."""
httpx_mock.add_exception(
- httpx.ConnectError("Connection refused"),
+ httpx2.ConnectError("Connection refused"),
url=_INTROSPECTION_RE,
)
diff --git a/tests/server/auth/providers/test_descope.py b/tests/server/auth/providers/test_descope.py
index 8a149c903..cbf4607f2 100644
--- a/tests/server/auth/providers/test_descope.py
+++ b/tests/server/auth/providers/test_descope.py
@@ -223,7 +223,7 @@ def client_with_headless_oauth(mcp_server_url: str) -> Client:
class TestDescopeProviderIntegration:
async def test_unauthorized_access(self, mcp_server_url: str):
# SDK v2 surfaces the server's 401 as a generic MCPError at the client
- # boundary rather than re-raising httpx.HTTPStatusError.
+ # boundary rather than re-raising httpx2.HTTPStatusError.
with pytest.raises(MCPError):
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py
index edf3ffdc7..c8af69693 100644
--- a/tests/server/auth/providers/test_discord.py
+++ b/tests/server/auth/providers/test_discord.py
@@ -120,7 +120,7 @@ class TestDiscordTokenVerifier:
mock_client.get.return_value = token_info_response
with patch(
- "fastmcp.server.auth.providers.discord.httpx.AsyncClient"
+ "fastmcp.server.auth.providers.discord.httpx2.AsyncClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await verifier.verify_token("token")
diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py
index a0cc4b9cd..29c2f7ba2 100644
--- a/tests/server/auth/providers/test_github.py
+++ b/tests/server/auth/providers/test_github.py
@@ -101,8 +101,8 @@ class TestGitHubTokenVerifier:
"""Test token verification when GitHub API returns error."""
verifier = GitHubTokenVerifier()
- # Mock httpx.AsyncClient to simulate GitHub API failure
- with patch("httpx.AsyncClient") as mock_client_class:
+ # Mock httpx2.AsyncClient to simulate GitHub API failure
+ with patch("httpx2.AsyncClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
@@ -119,7 +119,7 @@ class TestGitHubTokenVerifier:
"""Test successful token verification."""
verifier = GitHubTokenVerifier(required_scopes=["user"])
- # Mock the httpx.AsyncClient directly
+ # Mock the httpx2.AsyncClient directly
mock_client = AsyncMock()
# Mock successful user API response
@@ -142,7 +142,7 @@ class TestGitHubTokenVerifier:
# Patch the AsyncClient context manager
with patch(
- "fastmcp.server.auth.providers.github.httpx.AsyncClient"
+ "fastmcp.server.auth.providers.github.httpx2.AsyncClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
@@ -203,7 +203,7 @@ class TestGitHubTokenVerifierCaching:
mock_client = AsyncMock()
with patch(
- "fastmcp.server.auth.providers.github.httpx.AsyncClient"
+ "fastmcp.server.auth.providers.github.httpx2.AsyncClient"
) as mock_cls:
mock_cls.return_value.__aenter__.return_value = mock_client
@@ -226,7 +226,7 @@ class TestGitHubTokenVerifierCaching:
mock_client = AsyncMock()
with patch(
- "fastmcp.server.auth.providers.github.httpx.AsyncClient"
+ "fastmcp.server.auth.providers.github.httpx2.AsyncClient"
) as mock_cls:
mock_cls.return_value.__aenter__.return_value = mock_client
@@ -244,7 +244,7 @@ class TestGitHubTokenVerifierCaching:
mock_client = AsyncMock()
with patch(
- "fastmcp.server.auth.providers.github.httpx.AsyncClient"
+ "fastmcp.server.auth.providers.github.httpx2.AsyncClient"
) as mock_cls:
mock_cls.return_value.__aenter__.return_value = mock_client
@@ -265,7 +265,7 @@ class TestGitHubTokenVerifierCaching:
mock_client = AsyncMock()
with patch(
- "fastmcp.server.auth.providers.github.httpx.AsyncClient"
+ "fastmcp.server.auth.providers.github.httpx2.AsyncClient"
) as mock_cls:
mock_cls.return_value.__aenter__.return_value = mock_client
@@ -299,7 +299,7 @@ class TestGitHubTokenVerifierCaching:
scopes_response.headers = {}
with patch(
- "fastmcp.server.auth.providers.github.httpx.AsyncClient"
+ "fastmcp.server.auth.providers.github.httpx2.AsyncClient"
) as mock_cls:
mock_cls.return_value.__aenter__.return_value = mock_client
diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py
index 229f04ad3..3deae3bcb 100644
--- a/tests/server/auth/providers/test_google.py
+++ b/tests/server/auth/providers/test_google.py
@@ -5,7 +5,6 @@ import time
import pytest
from key_value.aio.stores.memory import MemoryStore
-from pytest_httpx import HTTPXMock
from fastmcp.server.auth.providers.google import (
GOOGLE_SCOPE_ALIASES,
@@ -13,6 +12,7 @@ from fastmcp.server.auth.providers.google import (
GoogleTokenVerifier,
_normalize_google_scope,
)
+from tests.utilities.httpx2_mock import HTTPXMock
@pytest.fixture
diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py
index 1fc9b628b..ba41203a7 100644
--- a/tests/server/auth/providers/test_http_client.py
+++ b/tests/server/auth/providers/test_http_client.py
@@ -1,28 +1,28 @@
"""Tests for http_client parameter on token verifiers.
-Verifies that all token verifiers accept an optional httpx.AsyncClient for
+Verifies that all token verifiers accept an optional httpx2.AsyncClient for
connection pooling (issues #3287 and #3293).
"""
import time
-import httpx
+import httpx2
import pytest
from joserfc import jwk
-from pytest_httpx import HTTPXMock
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
+from tests.utilities.httpx2_mock import HTTPXMock
class TestIntrospectionHttpClient:
"""Test http_client parameter on IntrospectionTokenVerifier."""
@pytest.fixture
- def shared_client(self) -> httpx.AsyncClient:
- return httpx.AsyncClient(timeout=30)
+ def shared_client(self) -> httpx2.AsyncClient:
+ return httpx2.AsyncClient(timeout=30)
- def test_stores_http_client(self, shared_client: httpx.AsyncClient):
+ def test_stores_http_client(self, shared_client: httpx2.AsyncClient):
verifier = IntrospectionTokenVerifier(
introspection_url="https://auth.example.com/introspect",
client_id="test",
@@ -40,7 +40,7 @@ class TestIntrospectionHttpClient:
assert verifier._http_client is None
async def test_uses_provided_client(
- self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
+ self, shared_client: httpx2.AsyncClient, httpx_mock: HTTPXMock
):
"""When http_client is provided, it should be used for requests."""
httpx_mock.add_response(
@@ -66,7 +66,7 @@ class TestIntrospectionHttpClient:
assert result.client_id == "user-1"
async def test_client_not_closed_after_call(
- self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
+ self, shared_client: httpx2.AsyncClient, httpx_mock: HTTPXMock
):
"""User-provided client must not be closed by the verifier."""
httpx_mock.add_response(
@@ -92,7 +92,7 @@ class TestIntrospectionHttpClient:
assert not shared_client.is_closed
async def test_reuses_client_across_calls(
- self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
+ self, shared_client: httpx2.AsyncClient, httpx_mock: HTTPXMock
):
"""Same client instance should be reused across multiple verify_token calls."""
for _ in range(3):
@@ -129,10 +129,10 @@ class TestJWTVerifierHttpClient:
return RSAKeyPair.generate()
@pytest.fixture
- def shared_client(self) -> httpx.AsyncClient:
- return httpx.AsyncClient(timeout=30)
+ def shared_client(self) -> httpx2.AsyncClient:
+ return httpx2.AsyncClient(timeout=30)
- def test_stores_http_client(self, shared_client: httpx.AsyncClient):
+ def test_stores_http_client(self, shared_client: httpx2.AsyncClient):
verifier = JWTVerifier(
jwks_uri="https://auth.example.com/.well-known/jwks.json",
http_client=shared_client,
@@ -148,7 +148,7 @@ class TestJWTVerifierHttpClient:
async def test_jwks_fetch_uses_provided_client(
self,
rsa_key_pair: RSAKeyPair,
- shared_client: httpx.AsyncClient,
+ shared_client: httpx2.AsyncClient,
httpx_mock: HTTPXMock,
):
"""When http_client is provided, JWKS fetches should use it."""
@@ -181,7 +181,7 @@ class TestJWTVerifierHttpClient:
def test_ssrf_safe_rejects_http_client_with_jwks(
self,
- shared_client: httpx.AsyncClient,
+ shared_client: httpx2.AsyncClient,
):
"""ssrf_safe=True and http_client cannot be used together with JWKS."""
with pytest.raises(ValueError, match="cannot be used with ssrf_safe=True"):
@@ -194,7 +194,7 @@ class TestJWTVerifierHttpClient:
def test_ssrf_safe_allows_http_client_with_static_key(
self,
rsa_key_pair: RSAKeyPair,
- shared_client: httpx.AsyncClient,
+ shared_client: httpx2.AsyncClient,
):
"""ssrf_safe with http_client is allowed when using static public_key (no HTTP)."""
# This should NOT raise — static key means no JWKS fetching
@@ -213,14 +213,14 @@ class TestGitHubHttpClient:
def test_stores_http_client(self):
from fastmcp.server.auth.providers.github import GitHubTokenVerifier
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
verifier = GitHubTokenVerifier(http_client=client)
assert verifier._http_client is client
async def test_uses_provided_client(self, httpx_mock: HTTPXMock):
from fastmcp.server.auth.providers.github import GitHubTokenVerifier
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
httpx_mock.add_response(
url="https://api.github.com/user",
json={"id": 123, "login": "testuser"},
@@ -243,7 +243,7 @@ class TestDiscordHttpClient:
def test_stores_http_client(self):
from fastmcp.server.auth.providers.discord import DiscordTokenVerifier
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
verifier = DiscordTokenVerifier(
expected_client_id="test-client-id",
http_client=client,
@@ -257,7 +257,7 @@ class TestGoogleHttpClient:
def test_stores_http_client(self):
from fastmcp.server.auth.providers.google import GoogleTokenVerifier
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
verifier = GoogleTokenVerifier(http_client=client)
assert verifier._http_client is client
@@ -268,7 +268,7 @@ class TestWorkOSHttpClient:
def test_stores_http_client(self):
from fastmcp.server.auth.providers.workos import WorkOSTokenVerifier
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
verifier = WorkOSTokenVerifier(
authkit_domain="https://test.authkit.app",
http_client=client,
@@ -285,7 +285,7 @@ class TestProviderHttpClientPassthrough:
GitHubTokenVerifier,
)
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
provider = GitHubProvider(
client_id="test",
client_secret="secret",
@@ -303,7 +303,7 @@ class TestProviderHttpClientPassthrough:
DiscordTokenVerifier,
)
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
provider = DiscordProvider(
client_id="test",
client_secret="secret",
@@ -320,7 +320,7 @@ class TestProviderHttpClientPassthrough:
GoogleTokenVerifier,
)
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
provider = GoogleProvider(
client_id="test",
client_secret="secret",
@@ -337,7 +337,7 @@ class TestProviderHttpClientPassthrough:
WorkOSTokenVerifier,
)
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
provider = WorkOSProvider(
client_id="test",
client_secret="secret",
@@ -353,7 +353,7 @@ class TestProviderHttpClientPassthrough:
from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
provider = AzureProvider(
client_id="test-client-id",
client_secret="secret",
diff --git a/tests/server/auth/providers/test_huggingface.py b/tests/server/auth/providers/test_huggingface.py
index 4f5808d50..048a6a808 100644
--- a/tests/server/auth/providers/test_huggingface.py
+++ b/tests/server/auth/providers/test_huggingface.py
@@ -4,7 +4,6 @@ import re
import pytest
from key_value.aio.stores.memory import MemoryStore
-from pytest_httpx import HTTPXMock
from fastmcp.server.auth.providers.huggingface import (
DEFAULT_HUGGINGFACE_SCOPES,
@@ -15,6 +14,7 @@ from fastmcp.server.auth.providers.huggingface import (
HuggingFaceProvider,
HuggingFaceTokenVerifier,
)
+from tests.utilities.httpx2_mock import HTTPXMock
@pytest.fixture
diff --git a/tests/server/auth/providers/test_introspection.py b/tests/server/auth/providers/test_introspection.py
index 46dcd2f0d..047a3dee0 100644
--- a/tests/server/auth/providers/test_introspection.py
+++ b/tests/server/auth/providers/test_introspection.py
@@ -5,11 +5,11 @@ import time
from typing import Any
import pytest
-from pytest_httpx import HTTPXMock
from fastmcp.server.auth.providers.introspection import (
IntrospectionTokenVerifier,
)
+from tests.utilities.httpx2_mock import HTTPXMock
class TestIntrospectionTokenVerifier:
@@ -287,7 +287,7 @@ class TestIntrospectionTokenVerifier:
self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
):
"""Test that timeouts return None."""
- from httpx import TimeoutException
+ from httpx2 import TimeoutException
httpx_mock.add_exception(
TimeoutException("Request timed out"),
@@ -820,7 +820,7 @@ class TestIntrospectionCaching:
self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
):
"""Test that timeout errors are not cached (transient failures)."""
- from httpx import TimeoutException
+ from httpx2 import TimeoutException
# First call - timeout
httpx_mock.add_exception(
diff --git a/tests/server/auth/providers/test_propelauth.py b/tests/server/auth/providers/test_propelauth.py
index 2a254d528..cf94786de 100644
--- a/tests/server/auth/providers/test_propelauth.py
+++ b/tests/server/auth/providers/test_propelauth.py
@@ -3,7 +3,7 @@
from typing import cast
from unittest.mock import AsyncMock
-import httpx
+import httpx2
import pytest
from mcp import MCPError
from pydantic import SecretStr
@@ -139,7 +139,7 @@ class TestPropelAuthProvider:
def test_token_introspection_overrides_http_client(self):
"""Test that http_client override is passed to the verifier."""
- client = httpx.AsyncClient()
+ client = httpx2.AsyncClient()
provider = PropelAuthProvider(
auth_url="https://auth.example.com",
introspection_client_id="client_id_123",
@@ -271,7 +271,7 @@ async def mcp_server_url():
class TestPropelAuthProviderIntegration:
async def test_unauthorized_access(self, mcp_server_url: str):
# SDK v2 surfaces the server's 401 as a generic MCPError at the client
- # boundary rather than re-raising httpx.HTTPStatusError.
+ # boundary rather than re-raising httpx2.HTTPStatusError.
with pytest.raises(MCPError):
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
@@ -315,10 +315,10 @@ class TestPropelAuthProviderIntegration:
DummyAsyncClient.last_url = url
return DummyResponse(metadata_payload)
- real_httpx_client = httpx.AsyncClient
+ real_httpx_client = httpx2.AsyncClient
monkeypatch.setattr(
- "fastmcp.server.auth.providers.propelauth.httpx.AsyncClient",
+ "fastmcp.server.auth.providers.propelauth.httpx2.AsyncClient",
DummyAsyncClient,
)
diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py
index ad1a0858f..1833ed50d 100644
--- a/tests/server/auth/providers/test_scalekit.py
+++ b/tests/server/auth/providers/test_scalekit.py
@@ -1,6 +1,6 @@
"""Tests for Scalekit OAuth provider."""
-import httpx
+import httpx2
import pytest
from mcp import MCPError
@@ -155,7 +155,7 @@ def client_with_headless_oauth(mcp_server_url: str) -> Client:
class TestScalekitProviderIntegration:
async def test_unauthorized_access(self, mcp_server_url: str):
# SDK v2 surfaces the server's 401 as a generic MCPError at the client
- # boundary rather than re-raising httpx.HTTPStatusError.
+ # boundary rather than re-raising httpx2.HTTPStatusError.
with pytest.raises(MCPError):
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
@@ -199,10 +199,10 @@ class TestScalekitProviderIntegration:
DummyAsyncClient.last_url = url
return DummyResponse(metadata_payload)
- real_httpx_client = httpx.AsyncClient
+ real_httpx_client = httpx2.AsyncClient
monkeypatch.setattr(
- "fastmcp.server.auth.providers.scalekit.httpx.AsyncClient",
+ "fastmcp.server.auth.providers.scalekit.httpx2.AsyncClient",
DummyAsyncClient,
)
diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py
index b15c613e4..ec994069f 100644
--- a/tests/server/auth/providers/test_supabase.py
+++ b/tests/server/auth/providers/test_supabase.py
@@ -196,7 +196,7 @@ def client_with_headless_oauth(
class TestSupabaseProviderIntegration:
async def test_unauthorized_access(self, mcp_server_url: str):
# SDK v2 surfaces the server's 401 as a generic MCPError at the client
- # boundary rather than re-raising httpx.HTTPStatusError.
+ # boundary rather than re-raising httpx2.HTTPStatusError.
with pytest.raises(MCPError):
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py
index 2cdc866a4..66c19d769 100644
--- a/tests/server/auth/providers/test_workos.py
+++ b/tests/server/auth/providers/test_workos.py
@@ -5,7 +5,6 @@ from urllib.parse import urlparse
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp import MCPError
-from pytest_httpx import HTTPXMock
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
@@ -16,6 +15,7 @@ from fastmcp.server.auth.providers.workos import (
WorkOSTokenVerifier,
)
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
+from tests.utilities.httpx2_mock import HTTPXMock
@pytest.fixture
@@ -234,7 +234,7 @@ class TestAuthKitProvider:
self, memory_storage: MemoryStore, mcp_server_url: str
):
# SDK v2 surfaces the server's 401 as a generic MCPError at the client
- # boundary rather than re-raising httpx.HTTPStatusError.
+ # boundary rather than re-raising httpx2.HTTPStatusError.
with pytest.raises(MCPError):
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
diff --git a/tests/server/auth/test_auth_provider.py b/tests/server/auth/test_auth_provider.py
index 22f98e0ab..e9ce2f4b0 100644
--- a/tests/server/auth/test_auth_provider.py
+++ b/tests/server/auth/test_auth_provider.py
@@ -1,6 +1,6 @@
import re
-import httpx
+import httpx2
import pytest
from pydantic import AnyHttpUrl
@@ -64,8 +64,8 @@ class TestAuthProviderBase:
# Mount MCP at a non-root path
mcp_http_app = mcp.http_app(path="/api/v1/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
# Make unauthorized request to MCP endpoint
@@ -96,8 +96,8 @@ class TestAuthProviderBase:
# Mount MCP at a specific path
mcp_http_app = mcp.http_app(path="/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
# The .well-known metadata is at a path-aware location per RFC 9728
@@ -113,8 +113,8 @@ class TestAuthProviderBase:
mcp = FastMCP("test-server", auth=basic_remote_provider)
mcp_http_app = mcp.http_app(path="/api/v2/services/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
# The .well-known metadata includes the resource path per RFC 9728
diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py
index 1961a0c18..fb182dda9 100644
--- a/tests/server/auth/test_jwt_provider.py
+++ b/tests/server/auth/test_jwt_provider.py
@@ -8,11 +8,11 @@ from joserfc import jwk as jose_jwk
from joserfc import jwt
from joserfc.jws import JWSRegistry
from joserfc.registry import HeaderParameter
-from pytest_httpx import HTTPXMock
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair
from fastmcp.utilities.tests import run_server_async
+from tests.utilities.httpx2_mock import HTTPXMock
# Standard public IP used for DNS mocking in tests
TEST_PUBLIC_IP = "93.184.216.34"
diff --git a/tests/server/auth/test_jwt_provider_bearer.py b/tests/server/auth/test_jwt_provider_bearer.py
index eb72227c6..1be370481 100644
--- a/tests/server/auth/test_jwt_provider_bearer.py
+++ b/tests/server/auth/test_jwt_provider_bearer.py
@@ -487,7 +487,7 @@ class TestFastMCPBearerAuth:
async def test_unauthorized_access(self, mcp_server_url: str):
# SDK v2 masks the server's 401 behind a generic MCPError at the client
- # boundary rather than re-raising httpx.HTTPStatusError.
+ # boundary rather than re-raising httpx2.HTTPStatusError.
with pytest.raises(MCPError):
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
diff --git a/tests/server/auth/test_multi_auth.py b/tests/server/auth/test_multi_auth.py
index 727158d1c..08aaa380b 100644
--- a/tests/server/auth/test_multi_auth.py
+++ b/tests/server/auth/test_multi_auth.py
@@ -1,4 +1,4 @@
-import httpx
+import httpx2
import pytest
from pydantic import AnyHttpUrl
@@ -318,8 +318,8 @@ class TestMultiAuthIntegration:
mcp = FastMCP("test", auth=auth)
app = mcp.http_app(path="/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
base_url="http://localhost",
) as client:
# No token → 401
@@ -346,8 +346,8 @@ class TestMultiAuthIntegration:
mcp = FastMCP("test", auth=auth)
app = mcp.http_app(path="/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
base_url="https://api.example.com",
) as client:
# Protected resource metadata should be available
@@ -370,8 +370,8 @@ class TestMultiAuthIntegration:
mcp = FastMCP("test", auth=auth)
app = mcp.http_app(path="/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
base_url="http://localhost",
) as client:
response = await client.get("/mcp")
@@ -394,8 +394,8 @@ class TestMultiAuthIntegration:
mcp = FastMCP("test", auth=auth)
app = mcp.http_app(path="/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app),
base_url="http://localhost",
) as client:
response = await client.get("/mcp")
@@ -444,8 +444,8 @@ class TestMultiAuthIntegration:
mcp = FastMCP("test", auth=auth)
app = mcp.http_app(path="/mcp")
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app, raise_app_exceptions=False),
base_url="http://localhost",
) as client:
# No token → 401
diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py
index 295942353..50bf67b1a 100644
--- a/tests/server/auth/test_oauth_mounting.py
+++ b/tests/server/auth/test_oauth_mounting.py
@@ -6,7 +6,7 @@ returns 404 at root level when a FastMCP app is mounted under a path prefix.
The fix uses MCP SDK 1.17+ which implements RFC 9728 path-scoped well-known URLs.
"""
-import httpx
+import httpx2
import pytest
from key_value.aio.stores.memory import MemoryStore
from pydantic import AnyHttpUrl
@@ -50,8 +50,8 @@ class TestOAuthMounting:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_app),
base_url="https://api.example.com",
) as client:
# RFC 9728: path-scoped well-known URL
@@ -96,8 +96,8 @@ class TestOAuthMounting:
lifespan=mcp_app.lifespan,
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=parent_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=parent_app),
base_url="https://api.example.com",
) as client:
# The CORRECT RFC 9728 path-scoped well-known URL at root
@@ -140,8 +140,8 @@ class TestOAuthMounting:
lifespan=mcp_app.lifespan,
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=parent_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=parent_app),
base_url="https://api.example.com",
) as client:
# The MCP endpoint should work at /api/mcp (mounted correctly)
@@ -183,8 +183,8 @@ class TestOAuthMounting:
lifespan=mcp_app.lifespan,
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=outer_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=outer_app),
base_url="https://api.example.com",
) as client:
# RFC 9728: path-scoped well-known URL for nested mounting
@@ -239,8 +239,8 @@ class TestOAuthMounting:
lifespan=mcp_app.lifespan,
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=parent_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=parent_app),
base_url="https://api.example.com",
) as client:
# Fetch the authorization server metadata
@@ -332,8 +332,8 @@ class TestOAuthMounting:
lifespan=mcp_app.lifespan,
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=parent_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=parent_app),
base_url="https://api.example.com",
) as client:
# Path-aware authorization server metadata should be accessible
@@ -464,8 +464,8 @@ class TestOAuthMounting:
lifespan=mcp_app.lifespan,
)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=parent_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=parent_app),
base_url="https://api.example.com",
) as client:
# Path-aware OIDC discovery (RFC 8414 §5)
diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py
index acbe6ef62..2636a1c83 100644
--- a/tests/server/auth/test_oidc_proxy.py
+++ b/tests/server/auth/test_oidc_proxy.py
@@ -4,7 +4,7 @@ import json
from unittest.mock import MagicMock, patch
import pytest
-from httpx import Response
+from httpx2 import Response
from pydantic import AnyHttpUrl
from fastmcp.server.auth.oidc_proxy import OIDCConfiguration, OIDCProxy
@@ -360,7 +360,7 @@ class TestOIDCConfiguration:
def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds):
"""Validate get_oidc_configuration call."""
- with patch("httpx.get") as mock_get:
+ with patch("httpx2.get") as mock_get:
mock_response = MagicMock(spec=Response)
mock_response.json.return_value = oidc_configuration
mock_get.return_value = mock_response
@@ -409,7 +409,7 @@ class TestGetOIDCConfiguration:
self, invalid_oidc_configuration_dict
) -> None:
"""Test with invalid response and strict set to False."""
- with patch("httpx.get") as mock_get:
+ with patch("httpx2.get") as mock_get:
mock_response = MagicMock(spec=Response)
mock_response.json.return_value = invalid_oidc_configuration_dict
mock_get.return_value = mock_response
diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py
index bc37decf8..c56cd7b2b 100644
--- a/tests/server/auth/test_remote_auth_provider.py
+++ b/tests/server/auth/test_remote_auth_provider.py
@@ -1,4 +1,4 @@
-import httpx
+import httpx2
import pytest
from pydantic import AnyHttpUrl
@@ -222,8 +222,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=basic_auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -237,8 +237,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -257,8 +257,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -289,8 +289,8 @@ class TestRemoteAuthProviderIntegration:
resource_path = resource_parsed.path.lstrip("/")
metadata_path = f"/.well-known/oauth-protected-resource/{resource_path}"
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://test.example.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -314,8 +314,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -338,8 +338,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -423,8 +423,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -460,8 +460,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -498,8 +498,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
# The metadata URL is path-aware per RFC 9728
@@ -533,8 +533,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource/mcp")
@@ -561,8 +561,8 @@ class TestRemoteAuthProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=mcp_http_app),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource/mcp")
diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py
index 0236942f8..08c2f3416 100644
--- a/tests/server/auth/test_ssrf_protection.py
+++ b/tests/server/auth/test_ssrf_protection.py
@@ -5,7 +5,7 @@ This module tests the ssrf.py module which provides SSRF-protected HTTP fetching
from unittest.mock import AsyncMock, MagicMock, patch
-import httpx
+import httpx2
import pytest
from fastmcp.server.auth.ssrf import (
@@ -175,7 +175,7 @@ class TestSSRFSafeFetch:
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[resolved_ip],
),
- patch("httpx.AsyncClient") as mock_client_class,
+ patch("httpx2.AsyncClient") as mock_client_class,
):
mock_stream = MagicMock()
mock_stream.status_code = 200
@@ -210,13 +210,13 @@ class TestSSRFSafeFetch:
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=resolved_ips,
),
- patch("httpx.AsyncClient") as mock_client_class,
+ patch("httpx2.AsyncClient") as mock_client_class,
):
- request = httpx.Request("GET", "https://example.com/api")
+ request = httpx2.Request("GET", "https://example.com/api")
first_client = AsyncMock()
first_client.stream = MagicMock(
- side_effect=httpx.RequestError("boom", request=request)
+ side_effect=httpx2.RequestError("boom", request=request)
)
first_client.__aenter__.return_value = first_client
first_client.__aexit__ = AsyncMock(return_value=None)
@@ -256,7 +256,7 @@ class TestSSRFSafeFetch:
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[resolved_ip],
),
- patch("httpx.AsyncClient") as mock_client_class,
+ patch("httpx2.AsyncClient") as mock_client_class,
):
mock_stream = MagicMock()
mock_stream.status_code = 200
@@ -288,7 +288,7 @@ class TestSSRFSafeFetch:
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=["93.184.216.34"],
),
- patch("httpx.AsyncClient") as mock_client_class,
+ patch("httpx2.AsyncClient") as mock_client_class,
):
# Response larger than default 5KB (no Content-Length, so streaming enforces)
mock_stream = MagicMock()
@@ -404,7 +404,7 @@ class TestIPv6URLFormatting:
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[resolved_ipv6],
),
- patch("httpx.AsyncClient") as mock_client_class,
+ patch("httpx2.AsyncClient") as mock_client_class,
):
mock_stream = MagicMock()
mock_stream.status_code = 200
@@ -445,7 +445,7 @@ class TestStreamingResponseSizeLimit:
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=["93.184.216.34"],
),
- patch("httpx.AsyncClient") as mock_client_class,
+ patch("httpx2.AsyncClient") as mock_client_class,
):
chunks_yielded = []
@@ -485,7 +485,7 @@ class TestStreamingResponseSizeLimit:
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=["93.184.216.34"],
),
- patch("httpx.AsyncClient") as mock_client_class,
+ patch("httpx2.AsyncClient") as mock_client_class,
):
mock_stream = MagicMock()
mock_stream.status_code = 200
diff --git a/tests/server/auth/test_static_token_verifier.py b/tests/server/auth/test_static_token_verifier.py
index 2f07a674f..6b51edcef 100644
--- a/tests/server/auth/test_static_token_verifier.py
+++ b/tests/server/auth/test_static_token_verifier.py
@@ -1,6 +1,6 @@
"""Tests for StaticTokenVerifier integration with FastMCP."""
-import httpx
+import httpx2
from fastmcp.server import FastMCP
from fastmcp.server.auth import AccessToken
@@ -66,8 +66,8 @@ class TestStaticTokenVerifier:
app = server.http_app(transport="http")
# Test unauthenticated request gets 401 (use exact path match to avoid redirect)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/mcp")
assert response.status_code == 401
@@ -89,8 +89,8 @@ class TestStaticTokenVerifier:
app = server.http_app(transport="http")
# Test that non-matching path gets 307 redirect
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/mcp/", follow_redirects=False)
assert response.status_code == 307
diff --git a/tests/server/http/test_http_middleware.py b/tests/server/http/test_http_middleware.py
index 94b263d88..df4182008 100644
--- a/tests/server/http/test_http_middleware.py
+++ b/tests/server/http/test_http_middleware.py
@@ -3,8 +3,8 @@
from collections.abc import Callable
from typing import Any
-import httpx
-from httpx import ASGITransport
+import httpx2
+from httpx2 import ASGITransport
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
@@ -72,7 +72,7 @@ async def test_sse_app_with_custom_middleware():
# Create a test client
transport = ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
response = await client.get("/test")
@@ -104,7 +104,7 @@ async def test_streamable_http_app_with_custom_middleware():
# Create a test client
transport = ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
response = await client.get("/test")
@@ -141,7 +141,7 @@ async def test_create_sse_app_with_custom_middleware():
# Create a test client
transport = ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
response = await client.get("/test")
@@ -179,7 +179,7 @@ async def test_create_streamable_http_app_with_custom_middleware():
# Create a test client
transport = ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
response = await client.get("/test")
@@ -218,7 +218,7 @@ async def test_multiple_middleware_ordering():
# Create a test client
transport = ASGITransport(app=app)
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
response = await client.get("/test")
diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py
index 31a54c4b3..d5b8ceffd 100644
--- a/tests/server/providers/openapi/test_comprehensive.py
+++ b/tests/server/providers/openapi/test_comprehensive.py
@@ -3,14 +3,17 @@
import json
from unittest.mock import AsyncMock, Mock
-import httpx
+import httpx2
import pytest
-from httpx import Response
+from httpx2 import Response
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
+# Real client used only to delegate build_request on mocked clients - never sends.
+_request_builder = httpx2.AsyncClient()
+
def create_openapi_server(
openapi_spec: dict,
@@ -369,7 +372,7 @@ class TestOpenAPIComprehensive:
self, comprehensive_openapi_spec
):
"""Test server initialization with comprehensive spec."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=comprehensive_openapi_spec,
client=client,
@@ -402,7 +405,7 @@ class TestOpenAPIComprehensive:
async def test_openapi_31_compatibility(self, openapi_31_spec):
"""Test that OpenAPI 3.1 specs work correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=openapi_31_spec,
client=client,
@@ -418,7 +421,7 @@ class TestOpenAPIComprehensive:
async def test_parameter_collision_handling(self, comprehensive_openapi_spec):
"""Test that parameter collisions are handled correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=comprehensive_openapi_spec,
client=client,
@@ -449,7 +452,7 @@ class TestOpenAPIComprehensive:
async def test_deep_object_parameters(self, comprehensive_openapi_spec):
"""Test deepObject parameter handling."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=comprehensive_openapi_spec,
client=client,
@@ -475,9 +478,10 @@ class TestOpenAPIComprehensive:
async def test_request_building_and_execution(self, comprehensive_openapi_spec):
"""Test that requests are built and executed correctly."""
# Create a mock client that tracks requests
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
# Mock successful response
mock_response = Mock(spec=Response)
@@ -516,9 +520,10 @@ class TestOpenAPIComprehensive:
self, comprehensive_openapi_spec
):
"""Test that tool uses localhost fallback when client has no base_url."""
- mock_client = Mock(spec=httpx.AsyncClient)
- mock_client.base_url = httpx.URL("") # Empty URL, same as httpx default
+ mock_client = Mock(spec=httpx2.AsyncClient)
+ mock_client.base_url = httpx2.URL("") # Empty URL, same as httpx default
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
mock_response = Mock(spec=Response)
mock_response.status_code = 200
@@ -548,9 +553,10 @@ class TestOpenAPIComprehensive:
self, comprehensive_openapi_spec
):
"""Test complex request with both parameters and body."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
mock_response = Mock(spec=Response)
mock_response.status_code = 201
@@ -596,9 +602,10 @@ class TestOpenAPIComprehensive:
async def test_query_parameters(self, comprehensive_openapi_spec):
"""Test query parameter handling."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
mock_response = Mock(spec=Response)
mock_response.status_code = 200
@@ -634,9 +641,10 @@ class TestOpenAPIComprehensive:
async def test_error_handling(self, comprehensive_openapi_spec):
"""Test error handling for HTTP errors."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
# Mock HTTP error response
mock_response = Mock(spec=Response)
@@ -647,7 +655,7 @@ class TestOpenAPIComprehensive:
# Configure raise_for_status to raise HTTPStatusError
def raise_for_status():
- raise httpx.HTTPStatusError(
+ raise httpx2.HTTPStatusError(
"404 Not Found", request=Mock(), response=mock_response
)
@@ -670,7 +678,7 @@ class TestOpenAPIComprehensive:
async def test_schema_refs_resolution(self, comprehensive_openapi_spec):
"""Test that schema references are resolved correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=comprehensive_openapi_spec,
client=client,
@@ -691,7 +699,7 @@ class TestOpenAPIComprehensive:
async def test_optional_vs_required_parameters(self, comprehensive_openapi_spec):
"""Test handling of optional vs required parameters."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=comprehensive_openapi_spec,
client=client,
@@ -723,7 +731,7 @@ class TestOpenAPIComprehensive:
# Time the provider creation
start_time = time.time()
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=comprehensive_openapi_spec,
client=client,
@@ -746,12 +754,13 @@ class TestOpenAPIComprehensive:
self, comprehensive_openapi_spec
):
"""ReadTimeout should surface a clear error, not an empty string."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
# httpx internally raises ReadTimeout with an empty message
- mock_client.send = AsyncMock(side_effect=httpx.ReadTimeout(""))
+ mock_client.send = AsyncMock(side_effect=httpx2.ReadTimeout(""))
server = create_openapi_server(
openapi_spec=comprehensive_openapi_spec,
@@ -864,9 +873,10 @@ class TestOpenAPIPostEdgeCases:
async def test_post_with_body_params(self, post_spec_with_empty_content_schema):
"""POST with body parameters should build the request correctly."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
mock_response = Mock(spec=Response)
mock_response.status_code = 201
@@ -896,9 +906,10 @@ class TestOpenAPIPostEdgeCases:
self, post_spec_with_empty_content_schema
):
"""POST with both path parameters and body should route args correctly."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
mock_response = Mock(spec=Response)
mock_response.status_code = 200
@@ -933,9 +944,10 @@ class TestOpenAPIPostEdgeCases:
from fastmcp.utilities.openapi.director import RequestDirector
from fastmcp.utilities.openapi.models import HTTPRoute
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
route = HTTPRoute(
path="/test",
diff --git a/tests/server/providers/openapi/test_deepobject_style.py b/tests/server/providers/openapi/test_deepobject_style.py
index 29564f370..e2033c144 100644
--- a/tests/server/providers/openapi/test_deepobject_style.py
+++ b/tests/server/providers/openapi/test_deepobject_style.py
@@ -1,6 +1,6 @@
"""Tests for deepObject style parameter handling in OpenAPIProvider."""
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -189,7 +189,7 @@ class TestDeepObjectStyle:
async def test_deepobject_style_parsing_from_spec(self, deepobject_spec):
"""Test that deepObject style parameters are correctly parsed from OpenAPI spec."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=deepobject_spec,
client=client,
@@ -222,7 +222,7 @@ class TestDeepObjectStyle:
async def test_deepobject_explode_true_handling(self, deepobject_spec):
"""Test deepObject with explode=true parameter handling."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=deepobject_spec,
client=client,
@@ -247,7 +247,7 @@ class TestDeepObjectStyle:
async def test_deepobject_explode_false_handling(self, deepobject_spec):
"""Test deepObject with explode=false parameter handling."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=deepobject_spec,
client=client,
@@ -275,7 +275,7 @@ class TestDeepObjectStyle:
async def test_nested_object_structure_in_request_body(self, deepobject_spec):
"""Test nested object structures in request body are preserved."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=deepobject_spec,
client=client,
@@ -319,7 +319,7 @@ class TestDeepObjectStyle:
async def test_deepobject_tool_functionality(self, deepobject_spec):
"""Test that tools with deepObject parameters maintain basic functionality."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=deepobject_spec,
client=client,
diff --git a/tests/server/providers/openapi/test_end_to_end_compatibility.py b/tests/server/providers/openapi/test_end_to_end_compatibility.py
index 29f15a651..69397a4ba 100644
--- a/tests/server/providers/openapi/test_end_to_end_compatibility.py
+++ b/tests/server/providers/openapi/test_end_to_end_compatibility.py
@@ -1,6 +1,6 @@
"""End-to-end tests for OpenAPIProvider implementation."""
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -96,7 +96,7 @@ class TestEndToEndFunctionality:
async def test_tool_schema_generation(self, simple_spec):
"""Test that tools have correct input schemas."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=simple_spec,
client=client,
@@ -127,7 +127,7 @@ class TestEndToEndFunctionality:
async def test_collision_handling(self, collision_spec):
"""Test that parameter collision handling works correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=collision_spec,
client=client,
@@ -164,7 +164,7 @@ class TestEndToEndFunctionality:
async def test_tool_execution_parameter_mapping(self, collision_spec):
"""Test that tool execution with collisions works correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=collision_spec,
client=client,
@@ -194,7 +194,7 @@ class TestEndToEndFunctionality:
async def test_optional_parameter_handling(self, simple_spec):
"""Test that optional parameters are handled correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=simple_spec,
client=client,
diff --git a/tests/server/providers/openapi/test_legacy_client_compat.py b/tests/server/providers/openapi/test_legacy_client_compat.py
new file mode 100644
index 000000000..707be823d
--- /dev/null
+++ b/tests/server/providers/openapi/test_legacy_client_compat.py
@@ -0,0 +1,166 @@
+"""Legacy-httpx client compatibility for the OpenAPI integration.
+
+The upgrade guide promises that an existing legacy ``httpx.AsyncClient`` passed
+to ``OpenAPIProvider``/``FastMCP.from_openapi`` keeps working via duck-typing.
+That requires two things of the OpenAPI request path: requests must be built
+through the user's own client (``build_request``), and errors raised by that
+client — which are legacy-httpx exceptions, not httpx2 — must still receive the
+integration's specific error formatting rather than surfacing as generic
+failures.
+"""
+
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.exceptions import ToolError
+from fastmcp.server.providers.openapi import OpenAPIProvider
+
+httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
+
+SPEC = {
+ "openapi": "3.0.0",
+ "info": {"title": "Legacy Client API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/items": {
+ "get": {
+ "operationId": "list_items",
+ "summary": "List items",
+ "responses": {
+ "200": {
+ "description": "Items",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {"type": "string"},
+ }
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ },
+}
+
+
+def _legacy_client(handler) -> "httpx.AsyncClient":
+ transport = httpx.MockTransport(handler)
+ return httpx.AsyncClient(transport=transport, base_url="https://api.example.com")
+
+
+def _server(client) -> FastMCP:
+ mcp = FastMCP("Legacy Client Server")
+ mcp.add_provider(OpenAPIProvider(openapi_spec=SPEC, client=client))
+ return mcp
+
+
+async def test_tool_call_with_legacy_client_succeeds():
+ """A legacy httpx.AsyncClient drives an OpenAPI tool end-to-end."""
+
+ def handler(request: "httpx.Request") -> "httpx.Response":
+ assert isinstance(request, httpx.Request)
+ return httpx.Response(200, json={"items": ["a", "b"]})
+
+ async with _legacy_client(handler) as client:
+ async with Client(_server(client)) as mcp_client:
+ result = await mcp_client.call_tool("list_items", {})
+ assert result.structured_content == {"items": ["a", "b"]}
+
+
+async def test_tool_http_error_keeps_openapi_formatting_with_legacy_client():
+ """A legacy client's HTTP error still gets the integration's message format.
+
+ The handler raises legacy ``httpx.HTTPStatusError``; the catch tuples must
+ recognize it so the error carries the formatted status + body rather than a
+ generic failure.
+ """
+
+ def handler(request: "httpx.Request") -> "httpx.Response":
+ return httpx.Response(500, json={"detail": "boom"})
+
+ async with _legacy_client(handler) as client:
+ async with Client(_server(client)) as mcp_client:
+ with pytest.raises(ToolError, match="HTTP error 500") as excinfo:
+ await mcp_client.call_tool("list_items", {})
+ assert "boom" in str(excinfo.value)
+
+
+async def test_tool_request_error_keeps_openapi_formatting_with_legacy_client():
+ """A legacy client's transport error maps to the formatted request error."""
+
+ def handler(request: "httpx.Request") -> "httpx.Response":
+ raise httpx.ConnectError("connection refused")
+
+ async with _legacy_client(handler) as client:
+ async with Client(_server(client)) as mcp_client:
+ with pytest.raises(ToolError, match="Request error"):
+ await mcp_client.call_tool("list_items", {})
+
+
+async def test_multipart_tool_call_with_legacy_client():
+ """Multipart bodies must materialize and send through a legacy client too."""
+ spec = {
+ "openapi": "3.0.0",
+ "info": {"title": "Upload API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/upload": {
+ "post": {
+ "operationId": "upload_file",
+ "summary": "Upload a file",
+ "requestBody": {
+ "required": True,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": {"file": {"type": "string"}},
+ }
+ }
+ },
+ },
+ "responses": {
+ "200": {
+ "description": "Uploaded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {"ok": {"type": "boolean"}},
+ }
+ }
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+ received: dict[str, object] = {}
+
+ def handler(request: "httpx.Request") -> "httpx.Response":
+ received["content_type"] = request.headers.get("content-type", "")
+ received["body"] = request.read()
+ return httpx.Response(200, json={"ok": True})
+
+ async with _legacy_client(handler) as client:
+ mcp = FastMCP("Legacy Multipart Server")
+ mcp.add_provider(OpenAPIProvider(openapi_spec=spec, client=client))
+ async with Client(mcp) as mcp_client:
+ result = await mcp_client.call_tool("upload_file", {"file": "data"})
+ assert result.structured_content == {"ok": True}
+
+ content_type = received["content_type"]
+ assert isinstance(content_type, str)
+ assert "multipart/form-data" in content_type
+ body = received["body"]
+ assert isinstance(body, bytes)
+ assert b"data" in body
diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py
index 2c94e4386..ceabe117d 100644
--- a/tests/server/providers/openapi/test_openapi_features.py
+++ b/tests/server/providers/openapi/test_openapi_features.py
@@ -3,9 +3,9 @@
from typing import Any
from unittest.mock import AsyncMock, Mock
-import httpx
+import httpx2
import pytest
-from httpx import Response
+from httpx2 import Response
from fastmcp import FastMCP
from fastmcp.client import Client
@@ -17,6 +17,9 @@ from fastmcp.server.providers.openapi.components import (
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo
+# Real client used only to delegate build_request on mocked clients - never sends.
+_request_builder = httpx2.AsyncClient()
+
def create_openapi_server(
openapi_spec: dict,
@@ -149,7 +152,7 @@ class TestParameterHandling:
async def test_query_parameters_in_tools(self, parameter_spec):
"""Test that query parameters are properly included in tool parameters."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=parameter_spec, client=client, name="Parameter Test Server"
)
@@ -197,7 +200,7 @@ class TestParameterHandling:
async def test_path_parameters_in_tools(self, parameter_spec):
"""Test that path parameters are properly included in tool parameters."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=parameter_spec, client=client, name="Parameter Test Server"
)
@@ -302,7 +305,7 @@ class TestRequestBodyHandling:
async def test_request_body_properties_in_tool(self, request_body_spec):
"""Test that request body properties are included in tool parameters."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=request_body_spec,
client=client,
@@ -403,7 +406,7 @@ class TestResponseSchemas:
async def test_tool_has_output_schema(self, response_schema_spec):
"""Test that tools have output schemas from response definitions."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=response_schema_spec,
client=client,
@@ -629,7 +632,7 @@ class TestResourceTemplateMimeType:
async def test_resource_template_text_plain_mime_type(self, text_plain_spec):
"""Resource template should reflect text/plain from OpenAPI spec."""
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=text_plain_spec, client=client, route_maps=route_maps
)
@@ -643,7 +646,7 @@ class TestResourceTemplateMimeType:
async def test_resource_template_html_mime_type(self, html_spec):
"""Resource template should reflect text/html from OpenAPI spec."""
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=html_spec, client=client, route_maps=route_maps
)
@@ -694,7 +697,7 @@ class TestResourceTemplateMimeType:
},
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec, client=client, route_maps=route_maps
)
@@ -742,16 +745,16 @@ class TestResourceTemplateRequestBuilding:
async def test_resource_template_encodes_matched_path_params(
self, path_param_spec: dict[str, Any]
):
- seen_urls: list[httpx.URL] = []
+ seen_urls: list[httpx2.URL] = []
- async def handler(request: httpx.Request) -> httpx.Response:
+ async def handler(request: httpx2.Request) -> httpx2.Response:
seen_urls.append(request.url)
- return httpx.Response(200, json={"ok": True})
+ return httpx2.Response(200, json={"ok": True})
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
base_url="https://api.example.com/api/v1",
- transport=httpx.MockTransport(handler),
+ transport=httpx2.MockTransport(handler),
) as client:
provider = OpenAPIProvider(
openapi_spec=path_param_spec,
@@ -770,22 +773,22 @@ class TestResourceTemplateRequestBuilding:
await mcp_client.read_resource("resource://get_user/a%2Fb%20c")
assert seen_urls == [
- httpx.URL("https://api.example.com/api/v1/users/a%2Fb%20c")
+ httpx2.URL("https://api.example.com/api/v1/users/a%2Fb%20c")
]
async def test_resource_template_ignores_unmatched_query_string(
self, path_param_spec: dict[str, Any]
):
- seen_urls: list[httpx.URL] = []
+ seen_urls: list[httpx2.URL] = []
- async def handler(request: httpx.Request) -> httpx.Response:
+ async def handler(request: httpx2.Request) -> httpx2.Response:
seen_urls.append(request.url)
- return httpx.Response(200, json={"ok": True})
+ return httpx2.Response(200, json={"ok": True})
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
base_url="https://api.example.com/api/v1",
- transport=httpx.MockTransport(handler),
+ transport=httpx2.MockTransport(handler),
) as client:
provider = OpenAPIProvider(
openapi_spec=path_param_spec,
@@ -798,14 +801,14 @@ class TestResourceTemplateRequestBuilding:
async with Client(mcp) as mcp_client:
await mcp_client.read_resource("resource://get_user/alice?admin=true")
- assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/alice")]
+ assert seen_urls == [httpx2.URL("https://api.example.com/api/v1/users/alice")]
async def test_resource_template_preserves_hyphenated_path_params(self):
- seen_urls: list[httpx.URL] = []
+ seen_urls: list[httpx2.URL] = []
- async def handler(request: httpx.Request) -> httpx.Response:
+ async def handler(request: httpx2.Request) -> httpx2.Response:
seen_urls.append(request.url)
- return httpx.Response(200, json={"ok": True})
+ return httpx2.Response(200, json={"ok": True})
spec = {
"openapi": "3.0.0",
@@ -837,9 +840,9 @@ class TestResourceTemplateRequestBuilding:
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
base_url="https://api.example.com/api/v1",
- transport=httpx.MockTransport(handler),
+ transport=httpx2.MockTransport(handler),
) as client:
provider = OpenAPIProvider(
openapi_spec=spec,
@@ -852,23 +855,23 @@ class TestResourceTemplateRequestBuilding:
async with Client(mcp) as mcp_client:
await mcp_client.read_resource("resource://get_user/abc")
- assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")]
+ assert seen_urls == [httpx2.URL("https://api.example.com/api/v1/users/abc")]
async def test_resource_template_preserves_client_defaults(
self, path_param_spec: dict[str, Any]
):
- seen_requests: list[httpx.Request] = []
+ seen_requests: list[httpx2.Request] = []
- async def handler(request: httpx.Request) -> httpx.Response:
+ async def handler(request: httpx2.Request) -> httpx2.Response:
seen_requests.append(request)
- return httpx.Response(200, json={"ok": True})
+ return httpx2.Response(200, json={"ok": True})
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
base_url="https://api.example.com/api/v1",
params={"api-version": "2026-06-29"},
cookies={"session": "abc123"},
- transport=httpx.MockTransport(handler),
+ transport=httpx2.MockTransport(handler),
) as client:
provider = OpenAPIProvider(
openapi_spec=path_param_spec,
@@ -881,17 +884,17 @@ class TestResourceTemplateRequestBuilding:
async with Client(mcp) as mcp_client:
await mcp_client.read_resource("resource://get_user/alice")
- assert seen_requests[0].url == httpx.URL(
+ assert seen_requests[0].url == httpx2.URL(
"https://api.example.com/api/v1/users/alice?api-version=2026-06-29"
)
assert seen_requests[0].headers["cookie"] == "session=abc123"
async def test_resource_template_uses_mapped_path_argument_names(self):
- seen_urls: list[httpx.URL] = []
+ seen_urls: list[httpx2.URL] = []
- async def handler(request: httpx.Request) -> httpx.Response:
+ async def handler(request: httpx2.Request) -> httpx2.Response:
seen_urls.append(request.url)
- return httpx.Response(200, json={"ok": True})
+ return httpx2.Response(200, json={"ok": True})
spec = {
"openapi": "3.0.0",
@@ -936,9 +939,9 @@ class TestResourceTemplateRequestBuilding:
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
base_url="https://api.example.com/api/v1",
- transport=httpx.MockTransport(handler),
+ transport=httpx2.MockTransport(handler),
) as client:
provider = OpenAPIProvider(
openapi_spec=spec,
@@ -951,16 +954,16 @@ class TestResourceTemplateRequestBuilding:
async with Client(mcp) as mcp_client:
await mcp_client.read_resource("resource://get_user/abc")
- assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")]
+ assert seen_urls == [httpx2.URL("https://api.example.com/api/v1/users/abc")]
async def test_resource_template_uses_path_arg_when_query_param_has_same_name(
self,
):
- seen_urls: list[httpx.URL] = []
+ seen_urls: list[httpx2.URL] = []
- async def handler(request: httpx.Request) -> httpx.Response:
+ async def handler(request: httpx2.Request) -> httpx2.Response:
seen_urls.append(request.url)
- return httpx.Response(200, json={"ok": True})
+ return httpx2.Response(200, json={"ok": True})
spec = {
"openapi": "3.0.0",
@@ -998,9 +1001,9 @@ class TestResourceTemplateRequestBuilding:
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
- async with httpx.AsyncClient(
+ async with httpx2.AsyncClient(
base_url="https://api.example.com/api/v1",
- transport=httpx.MockTransport(handler),
+ transport=httpx2.MockTransport(handler),
) as client:
provider = OpenAPIProvider(
openapi_spec=spec,
@@ -1013,7 +1016,7 @@ class TestResourceTemplateRequestBuilding:
async with Client(mcp) as mcp_client:
await mcp_client.read_resource("resource://get_user/abc")
- assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")]
+ assert seen_urls == [httpx2.URL("https://api.example.com/api/v1/users/abc")]
class TestResourceMimeType:
@@ -1043,7 +1046,7 @@ class TestResourceMimeType:
},
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)]
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec, client=client, route_maps=route_maps
)
@@ -1076,7 +1079,7 @@ class TestResourceMimeType:
},
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)]
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec, client=client, route_maps=route_maps
)
@@ -1161,7 +1164,7 @@ class TestValidateOutput:
self, spec_with_output_schema
):
"""Default validate_output=True uses the real extracted schema."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=client,
@@ -1177,7 +1180,7 @@ class TestValidateOutput:
self, spec_with_output_schema
):
"""validate_output=False replaces the schema with a permissive one."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=client,
@@ -1195,7 +1198,7 @@ class TestValidateOutput:
self, spec_with_output_schema
):
"""validate_output=False preserves x-fastmcp-wrap-result for array responses."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=client,
@@ -1213,9 +1216,10 @@ class TestValidateOutput:
self, spec_with_output_schema
):
"""With validate_output=False, responses that don't match the spec succeed."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
# Return extra fields not in the schema
mock_response = Mock(spec=Response)
@@ -1249,9 +1253,10 @@ class TestValidateOutput:
self, spec_with_output_schema
):
"""Non-dict responses are wrapped even when schema says object and validate_output=False."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
# Backend returns an array even though schema says object
mock_response = Mock(spec=Response)
@@ -1278,9 +1283,10 @@ class TestValidateOutput:
async def test_from_openapi_threads_validate_output(self, spec_with_output_schema):
"""FastMCP.from_openapi() correctly passes validate_output to the provider."""
- mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client = Mock(spec=httpx2.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
+ mock_client.build_request = _request_builder.build_request
server = FastMCP.from_openapi(
openapi_spec=spec_with_output_schema,
@@ -1302,7 +1308,7 @@ class TestRedactHeaders:
"""Test that non-safe headers are redacted in debug logging."""
def test_known_sensitive_headers_are_redacted(self):
- headers = httpx.Headers(
+ headers = httpx2.Headers(
{
"Authorization": "Bearer secret-token",
"X-API-Key": "my-api-key",
@@ -1322,7 +1328,7 @@ class TestRedactHeaders:
def test_arbitrary_auth_headers_are_redacted(self):
"""Arbitrary header names (e.g. OpenAPI apiKey-in-header) are redacted."""
- headers = httpx.Headers(
+ headers = httpx2.Headers(
{
"X-Custom-Token": "secret",
"X-My-Service-Key": "also-secret",
@@ -1335,6 +1341,74 @@ class TestRedactHeaders:
assert redacted["content-type"] == "application/json"
def test_safe_only_headers(self):
- headers = httpx.Headers({"Content-Type": "application/json"})
+ headers = httpx2.Headers({"Content-Type": "application/json"})
redacted = _redact_headers(headers)
assert redacted == {"content-type": "application/json"}
+
+
+class TestMultipartUpload:
+ """Multipart request bodies must survive the build_request rebuild.
+
+ The director constructs multipart bodies with ``files=``, which yields a
+ streaming request body; the rebuild through the user's client must
+ materialize it (``read()``) rather than access ``.content``, which raises
+ ``RequestNotRead`` on unread streams.
+ """
+
+ MULTIPART_SPEC = {
+ "openapi": "3.0.0",
+ "info": {"title": "Upload API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/upload": {
+ "post": {
+ "operationId": "upload_file",
+ "summary": "Upload a file",
+ "requestBody": {
+ "required": True,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": {"file": {"type": "string"}},
+ }
+ }
+ },
+ },
+ "responses": {
+ "200": {
+ "description": "Uploaded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {"ok": {"type": "boolean"}},
+ }
+ }
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+
+ async def test_multipart_tool_call_sends_materialized_body(self):
+ received: dict[str, Any] = {}
+
+ def handler(request):
+ received["content_type"] = request.headers.get("content-type", "")
+ received["body"] = request.read()
+ return httpx2.Response(200, json={"ok": True})
+
+ transport = httpx2.MockTransport(handler)
+ async with httpx2.AsyncClient(
+ transport=transport, base_url="https://api.example.com"
+ ) as client:
+ server = create_openapi_server(self.MULTIPART_SPEC, client)
+ async with Client(server) as mcp_client:
+ result = await mcp_client.call_tool("upload_file", {"file": "data"})
+ assert result.structured_content == {"ok": True}
+
+ assert "multipart/form-data" in received["content_type"]
+ assert b"data" in received["body"]
diff --git a/tests/server/providers/openapi/test_openapi_performance.py b/tests/server/providers/openapi/test_openapi_performance.py
index c09a3ff64..926c94f27 100644
--- a/tests/server/providers/openapi/test_openapi_performance.py
+++ b/tests/server/providers/openapi/test_openapi_performance.py
@@ -7,7 +7,7 @@ and don't regress to the slow performance we had before optimization.
import time
from typing import Any
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -35,7 +35,7 @@ class TestOpenAPIPerformance:
"""
# Download the full GitHub API schema (typically ~10MB)
- response = httpx.get(
+ response = httpx2.get(
"https://raw.githubusercontent.com/github/rest-api-description/refs/heads/main/descriptions-next/ghes-3.17/ghes-3.17.json",
timeout=30.0, # Allow time for download
)
@@ -46,7 +46,7 @@ class TestOpenAPIPerformance:
start_time = time.time()
# This should complete quickly with our optimizations
- mcp_server = FastMCP.from_openapi(schema, httpx.AsyncClient())
+ mcp_server = FastMCP.from_openapi(schema, httpx2.AsyncClient())
elapsed_time = time.time() - start_time
@@ -127,7 +127,7 @@ class TestOpenAPIPerformance:
# Time the parsing
start_time = time.time()
- mcp_server = FastMCP.from_openapi(schema, httpx.AsyncClient())
+ mcp_server = FastMCP.from_openapi(schema, httpx2.AsyncClient())
elapsed_time = time.time() - start_time
# Should be very fast for medium schemas (well under 1 second)
diff --git a/tests/server/providers/openapi/test_parameter_collisions.py b/tests/server/providers/openapi/test_parameter_collisions.py
index 104d66398..2be81b13d 100644
--- a/tests/server/providers/openapi/test_parameter_collisions.py
+++ b/tests/server/providers/openapi/test_parameter_collisions.py
@@ -1,6 +1,6 @@
"""Tests for parameter collision handling in OpenAPIProvider."""
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -133,7 +133,7 @@ class TestParameterCollisions:
async def test_path_body_collision_handling(self, collision_spec):
"""Test that path and body parameters with same name are handled correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=collision_spec, client=client, name="Collision Test Server"
)
@@ -173,7 +173,7 @@ class TestParameterCollisions:
async def test_query_header_collision_handling(self, collision_spec):
"""Test that query and header parameters with same name are handled correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=collision_spec, client=client, name="Collision Test Server"
)
@@ -203,7 +203,7 @@ class TestParameterCollisions:
async def test_collision_resolution_maintains_functionality(self, collision_spec):
"""Test that collision resolution doesn't break basic tool functionality."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=collision_spec, client=client, name="Collision Test Server"
)
diff --git a/tests/server/providers/openapi/test_performance_comparison.py b/tests/server/providers/openapi/test_performance_comparison.py
index 66f55bd1b..fda982101 100644
--- a/tests/server/providers/openapi/test_performance_comparison.py
+++ b/tests/server/providers/openapi/test_performance_comparison.py
@@ -3,7 +3,7 @@
import gc
import time
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -173,7 +173,7 @@ class TestPerformance:
# Measure provider initialization
times = []
for _ in range(num_iterations):
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
start_time = time.time()
provider = OpenAPIProvider(
openapi_spec=comprehensive_spec,
@@ -201,7 +201,7 @@ class TestPerformance:
times = []
for _ in range(num_iterations):
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
start_time = time.time()
server = create_openapi_server(
openapi_spec=comprehensive_spec,
@@ -224,7 +224,7 @@ class TestPerformance:
async def test_functionality_after_optimization(self, comprehensive_spec):
"""Verify that performance optimization doesn't break functionality."""
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
server = create_openapi_server(
openapi_spec=comprehensive_spec,
@@ -265,7 +265,7 @@ class TestPerformance:
servers = []
for i in range(10):
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
server = create_openapi_server(
openapi_spec=comprehensive_spec,
client=client,
diff --git a/tests/server/providers/openapi/test_server.py b/tests/server/providers/openapi/test_server.py
index 994e3b9e2..501bb2ec2 100644
--- a/tests/server/providers/openapi/test_server.py
+++ b/tests/server/providers/openapi/test_server.py
@@ -1,6 +1,6 @@
"""Unit tests for OpenAPIProvider."""
-import httpx
+import httpx2
import pytest
from fastmcp import FastMCP
@@ -146,7 +146,7 @@ class TestOpenAPIProviderBasicFunctionality:
def test_provider_initialization(self, simple_openapi_spec):
"""Test provider initialization with OpenAPI spec."""
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=simple_openapi_spec, client=client)
# Should have initialized RequestDirector successfully
@@ -155,7 +155,7 @@ class TestOpenAPIProviderBasicFunctionality:
def test_server_with_provider(self, simple_openapi_spec):
"""Test server initialization with OpenAPIProvider."""
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=simple_openapi_spec, client=client)
mcp = FastMCP("Test Server")
@@ -165,7 +165,7 @@ class TestOpenAPIProviderBasicFunctionality:
async def test_provider_creates_tools_from_spec(self, simple_openapi_spec):
"""Test that provider creates tools from OpenAPI spec."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(openapi_spec=simple_openapi_spec, client=client)
mcp = FastMCP("Test Server")
@@ -183,7 +183,7 @@ class TestOpenAPIProviderBasicFunctionality:
async def test_provider_tool_execution(self, simple_openapi_spec):
"""Test tool execution uses RequestDirector."""
- mock_client = httpx.AsyncClient()
+ mock_client = httpx2.AsyncClient()
provider = OpenAPIProvider(openapi_spec=simple_openapi_spec, client=mock_client)
mcp = FastMCP("Test Server")
@@ -203,7 +203,7 @@ class TestOpenAPIProviderBasicFunctionality:
"""Test that omitting client creates one from the spec's servers URL."""
provider = OpenAPIProvider(openapi_spec=simple_openapi_spec)
assert str(provider._client.base_url).rstrip("/") == "https://api.example.com"
- assert provider._client.timeout == httpx.Timeout(DEFAULT_TIMEOUT)
+ assert provider._client.timeout == httpx2.Timeout(DEFAULT_TIMEOUT)
def test_provider_default_client_requires_servers(self):
"""Test that omitting client without servers in spec raises."""
@@ -223,7 +223,7 @@ class TestOpenAPIProviderBasicFunctionality:
"paths": {},
}
- client = httpx.AsyncClient(base_url="https://api.example.com")
+ client = httpx2.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=minimal_spec, client=client)
# Should handle empty paths gracefully
@@ -325,7 +325,7 @@ class TestOpenAPIProviderBasicFunctionality:
},
}
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec_with_unused_defs, client=client
)
diff --git a/tests/server/test_auth_integration.py b/tests/server/test_auth_integration.py
index 2b1317376..b1c9a0af8 100644
--- a/tests/server/test_auth_integration.py
+++ b/tests/server/test_auth_integration.py
@@ -5,7 +5,7 @@ import time
import unittest.mock
from urllib.parse import parse_qs, urlparse
-import httpx
+import httpx2
import pytest
from mcp.server.auth.provider import (
AccessToken,
@@ -227,14 +227,14 @@ def auth_app(mock_oauth_provider):
@pytest.fixture
async def test_client(auth_app):
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com"
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=auth_app), base_url="https://mcptest.com"
) as client:
yield client
@pytest.fixture
-async def registered_client(test_client: httpx.AsyncClient, request):
+async def registered_client(test_client: httpx2.AsyncClient, request):
"""Create and register a test client.
Parameters can be customized via indirect parameterization:
@@ -347,7 +347,7 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ
class TestAuthEndpoints:
- async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
+ async def test_metadata_endpoint(self, test_client: httpx2.AsyncClient):
"""Test the OAuth 2.1 metadata endpoint."""
print("Sending request to metadata endpoint")
response = await test_client.get("/.well-known/oauth-authorization-server")
@@ -376,7 +376,7 @@ class TestAuthEndpoints:
]
assert metadata["service_documentation"] == "https://docs.example.com/"
- async def test_token_validation_error(self, test_client: httpx.AsyncClient):
+ async def test_token_validation_error(self, test_client: httpx2.AsyncClient):
"""Test token endpoint error - missing client_id returns auth error."""
# Missing required fields - SDK validates client_id first
response = await test_client.post(
@@ -625,7 +625,7 @@ class TestAuthEndpoints:
assert "cannot request scope" in error_response["error_description"]
async def test_client_registration(
- self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
+ self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider
):
"""Test client registration."""
client_metadata = {
@@ -652,7 +652,7 @@ class TestAuthEndpoints:
# ) is not None
async def test_client_registration_missing_required_fields(
- self, test_client: httpx.AsyncClient
+ self, test_client: httpx2.AsyncClient
):
"""Test client registration with missing required fields."""
# Missing redirect_uris which is a required field
@@ -672,7 +672,7 @@ class TestAuthEndpoints:
assert error_data["error_description"] == "redirect_uris: Field required"
async def test_client_registration_invalid_uri(
- self, test_client: httpx.AsyncClient
+ self, test_client: httpx2.AsyncClient
):
"""Test client registration with invalid URIs."""
# Invalid redirect_uri format
@@ -694,7 +694,7 @@ class TestAuthEndpoints:
)
async def test_client_registration_empty_redirect_uris(
- self, test_client: httpx.AsyncClient
+ self, test_client: httpx2.AsyncClient
):
"""Test client registration with empty redirect_uris array."""
client_metadata = {
@@ -717,7 +717,7 @@ class TestAuthEndpoints:
async def test_authorize_form_post(
self,
- test_client: httpx.AsyncClient,
+ test_client: httpx2.AsyncClient,
mock_oauth_provider: MockOAuthProvider,
pkce_challenge,
):
@@ -760,7 +760,7 @@ class TestAuthEndpoints:
async def test_authorization_get(
self,
- test_client: httpx.AsyncClient,
+ test_client: httpx2.AsyncClient,
mock_oauth_provider: MockOAuthProvider,
pkce_challenge,
):
@@ -901,7 +901,7 @@ class TestAuthEndpoints:
assert "token_type_hint" in error_response["error_description"]
async def test_client_registration_disallowed_scopes(
- self, test_client: httpx.AsyncClient
+ self, test_client: httpx2.AsyncClient
):
"""Test client registration with scopes that are not allowed."""
client_metadata = {
@@ -922,7 +922,7 @@ class TestAuthEndpoints:
assert "admin" in error_data["error_description"]
async def test_client_registration_default_scopes(
- self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
+ self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider
):
client_metadata = {
"redirect_uris": ["https://client.example.com/callback"],
@@ -950,7 +950,7 @@ class TestAuthEndpoints:
assert registered_client.scope == "read write"
async def test_client_registration_invalid_grant_type(
- self, test_client: httpx.AsyncClient
+ self, test_client: httpx2.AsyncClient
):
# The SDK requires `authorization_code` to be present in grant_types;
# a set that omits it is rejected. (`refresh_token` alone is invalid.)
diff --git a/tests/server/test_auth_integration_errors.py b/tests/server/test_auth_integration_errors.py
index 196701119..25351d7c4 100644
--- a/tests/server/test_auth_integration_errors.py
+++ b/tests/server/test_auth_integration_errors.py
@@ -4,7 +4,7 @@ import secrets
import time
from urllib.parse import parse_qs, urlparse
-import httpx
+import httpx2
import pytest
from mcp.server.auth.provider import (
AccessToken,
@@ -222,14 +222,14 @@ def auth_app(mock_oauth_provider):
@pytest.fixture
async def test_client(auth_app):
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com"
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=auth_app), base_url="https://mcptest.com"
) as client:
yield client
@pytest.fixture
-async def registered_client(test_client: httpx.AsyncClient, request):
+async def registered_client(test_client: httpx2.AsyncClient, request):
"""Create and register a test client.
Parameters can be customized via indirect parameterization:
@@ -272,7 +272,7 @@ class TestAuthorizeEndpointErrors:
"""Test error handling in the OAuth authorization endpoint."""
async def test_authorize_missing_client_id(
- self, test_client: httpx.AsyncClient, pkce_challenge
+ self, test_client: httpx2.AsyncClient, pkce_challenge
):
"""Test authorization endpoint with missing client_id.
@@ -297,7 +297,7 @@ class TestAuthorizeEndpointErrors:
assert "client_id" in response.text.lower()
async def test_authorize_invalid_client_id(
- self, test_client: httpx.AsyncClient, pkce_challenge
+ self, test_client: httpx2.AsyncClient, pkce_challenge
):
"""Test authorization endpoint with invalid client_id.
@@ -322,7 +322,7 @@ class TestAuthorizeEndpointErrors:
assert "client" in response.text.lower()
async def test_authorize_missing_redirect_uri(
- self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
+ self, test_client: httpx2.AsyncClient, registered_client, pkce_challenge
):
"""Test authorization endpoint with missing redirect_uri.
@@ -347,7 +347,7 @@ class TestAuthorizeEndpointErrors:
assert redirect_url.startswith("https://client.example.com/callback")
async def test_authorize_invalid_redirect_uri(
- self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
+ self, test_client: httpx2.AsyncClient, registered_client, pkce_challenge
):
"""Test authorization endpoint with invalid redirect_uri.
@@ -386,7 +386,7 @@ class TestAuthorizeEndpointErrors:
indirect=True,
)
async def test_authorize_missing_redirect_uri_multiple_registered(
- self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
+ self, test_client: httpx2.AsyncClient, registered_client, pkce_challenge
):
"""Test endpoint with missing redirect_uri with multiple registered URIs.
@@ -411,7 +411,7 @@ class TestAuthorizeEndpointErrors:
assert "redirect_uri" in response.text.lower()
async def test_authorize_unsupported_response_type(
- self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
+ self, test_client: httpx2.AsyncClient, registered_client, pkce_challenge
):
"""Test authorization endpoint with unsupported response_type.
@@ -444,7 +444,7 @@ class TestAuthorizeEndpointErrors:
assert query_params["state"][0] == "test_state"
async def test_authorize_missing_response_type(
- self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
+ self, test_client: httpx2.AsyncClient, registered_client, pkce_challenge
):
"""Test authorization endpoint with missing response_type.
@@ -476,7 +476,7 @@ class TestAuthorizeEndpointErrors:
assert query_params["state"][0] == "test_state"
async def test_authorize_missing_pkce_challenge(
- self, test_client: httpx.AsyncClient, registered_client
+ self, test_client: httpx2.AsyncClient, registered_client
):
"""Test authorization endpoint with missing PKCE code_challenge.
@@ -506,7 +506,7 @@ class TestAuthorizeEndpointErrors:
assert query_params["state"][0] == "test_state"
async def test_authorize_invalid_scope(
- self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
+ self, test_client: httpx2.AsyncClient, registered_client, pkce_challenge
):
"""Test authorization endpoint with invalid scope.
diff --git a/tests/server/test_streamable_http_no_redirect.py b/tests/server/test_streamable_http_no_redirect.py
index cd40a288b..8b71b49c7 100644
--- a/tests/server/test_streamable_http_no_redirect.py
+++ b/tests/server/test_streamable_http_no_redirect.py
@@ -1,6 +1,6 @@
"""Test that streamable HTTP routes avoid 307 redirects."""
-import httpx
+import httpx2
import pytest
from starlette.routing import Route
@@ -51,8 +51,8 @@ async def test_streamable_http_redirect_behavior():
app = mcp.http_app(transport="http", path="/mcp")
# Test that /mcp/ gets redirected to /mcp
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/mcp/", follow_redirects=False)
assert response.status_code == 307
diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py
index 1379e54dc..1bdf3b6a7 100644
--- a/tests/server/test_tool_transformation.py
+++ b/tests/server/test_tool_transformation.py
@@ -1,4 +1,4 @@
-import httpx
+import httpx2
from fastmcp import FastMCP
from fastmcp.client import Client
@@ -253,7 +253,7 @@ async def test_openapi_path_params_not_duplicated_in_description():
},
}
- async with httpx.AsyncClient(base_url="http://localhost") as http_client:
+ async with httpx2.AsyncClient(base_url="http://localhost") as http_client:
mcp = FastMCP.from_openapi(openapi_spec=spec, client=http_client)
# Hide one of the two path params
diff --git a/tests/test_no_legacy_httpx.py b/tests/test_no_legacy_httpx.py
new file mode 100644
index 000000000..e4cfb8c6e
--- /dev/null
+++ b/tests/test_no_legacy_httpx.py
@@ -0,0 +1,57 @@
+"""FastMCP must import without the legacy httpx/httpcore packages.
+
+FastMCP depends on httpx2 exclusively; legacy httpx remains importable in the
+test environment only as a transitive dependency of optional LLM SDKs. That
+masks clean-install regressions: an accidental ``import httpx`` (directly or
+via a third-party integration such as authlib's httpx client) passes CI but
+breaks any install without those extras.
+
+This test simulates the clean install by running a subprocess that blocks
+legacy httpx imports at the meta-path level, then imports the modules that
+have historically regressed. The defensive user-compat shim in
+``fastmcp.server.server`` catches ImportError by design and must keep working
+when httpx is absent.
+"""
+
+import subprocess
+import sys
+import textwrap
+
+_BLOCKER_SCRIPT = textwrap.dedent(
+ """
+ import sys
+
+ class _LegacyHttpxBlocker:
+ blocked = {"httpx", "httpcore", "pytest_httpx"}
+
+ def find_spec(self, name, path=None, target=None):
+ if name.split(".")[0] in self.blocked:
+ raise ImportError(
+ f"{name} is blocked: FastMCP must not require legacy httpx"
+ )
+ return None
+
+ sys.meta_path.insert(0, _LegacyHttpxBlocker())
+
+ import fastmcp.cli.apps_dev
+ import fastmcp.client.client
+ import fastmcp.server.auth.oauth_proxy.proxy
+ import fastmcp.server.server
+ import fastmcp.utilities.openapi.director
+
+ print("OK")
+ """
+)
+
+
+def test_fastmcp_imports_without_legacy_httpx():
+ result = subprocess.run(
+ [sys.executable, "-c", _BLOCKER_SCRIPT],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert result.returncode == 0, (
+ f"Import failed with legacy httpx blocked:\n{result.stderr}"
+ )
+ assert "OK" in result.stdout
diff --git a/tests/utilities/httpx2_mock.py b/tests/utilities/httpx2_mock.py
new file mode 100644
index 000000000..911b29a9d
--- /dev/null
+++ b/tests/utilities/httpx2_mock.py
@@ -0,0 +1,250 @@
+"""Local ``httpx_mock`` fixture backed by httpx2.
+
+FastMCP no longer depends on the legacy ``httpx`` package, so the third-party
+``pytest-httpx`` plugin (which patches ``httpx``) can no longer intercept the
+``httpx2`` clients that the auth providers and OpenAPI integration create
+internally.
+
+This module provides a drop-in replacement for the small slice of the
+``pytest_httpx`` API that the test suite relies on (``add_response``,
+``add_exception``, ``get_request`` and ``get_requests``), backed by ``httpx2``.
+Interception works exactly like ``pytest-httpx``: the transport request handlers
+are monkeypatched at the class level so every client is intercepted regardless
+of where it is constructed. Matching and teardown-assertion semantics mirror
+``pytest-httpx`` so the ported tests keep their assertions verbatim.
+"""
+
+from __future__ import annotations
+
+import copy
+import re
+from collections.abc import Callable, Generator
+from typing import Any
+
+import httpx2
+import pytest
+from pytest import FixtureRequest, MonkeyPatch
+
+__all__ = ["HTTPXMock", "httpx_mock"]
+
+
+def _params_dict(params: httpx2.QueryParams) -> dict[str, str | list[str]]:
+ """Query params as a dict, order-insensitive, mirroring pytest-httpx."""
+ result: dict[str, str | list[str]] = {}
+ for key in params:
+ values = params.get_list(key)
+ result[key] = values if len(values) > 1 else values[0]
+ return result
+
+
+def _url_match(expected: re.Pattern[str] | httpx2.URL, received: httpx2.URL) -> bool:
+ """Full URL match with query-parameter order insensitivity.
+
+ A ``re.Pattern`` matches against the string form of the received URL,
+ mirroring ``pytest-httpx``.
+ """
+ if isinstance(expected, re.Pattern):
+ return expected.match(str(received)) is not None
+ if _params_dict(expected.params) != _params_dict(received.params):
+ return False
+ return expected.copy_with(query=None) == received.copy_with(query=None)
+
+
+class _Matcher:
+ def __init__(
+ self,
+ url: str | re.Pattern[str] | httpx2.URL | None,
+ method: str | None,
+ ) -> None:
+ self.url = httpx2.URL(url) if isinstance(url, str) else url
+ self.method = method.upper() if method else method
+ self.nb_calls = 0
+
+ def match(self, request: httpx2.Request) -> bool:
+ if self.url is not None and not _url_match(self.url, request.url):
+ return False
+ if self.method is not None and request.method != self.method:
+ return False
+ return True
+
+ def __str__(self) -> str:
+ description = f"Match {self.method or 'any'} request"
+ if self.url is not None:
+ description += f" on {self.url}"
+ return description
+
+
+def _unread(response: httpx2.Response) -> httpx2.Response:
+ """Allow the response body to be read on the client side."""
+ response.is_stream_consumed = False
+ response.is_closed = False
+ if hasattr(response, "_content"):
+ del response._content
+ return response
+
+
+class HTTPXMock:
+ """Minimal httpx2-backed reimplementation of ``pytest_httpx.HTTPXMock``."""
+
+ def __init__(self) -> None:
+ self._callbacks: list[
+ tuple[_Matcher, Callable[[httpx2.Request], httpx2.Response]]
+ ] = []
+ self._requests: list[httpx2.Request] = []
+ self._requests_not_matched: list[httpx2.Request] = []
+
+ def add_response(
+ self,
+ status_code: int = 200,
+ headers: Any = None,
+ content: bytes | None = None,
+ text: str | None = None,
+ html: str | None = None,
+ stream: Any = None,
+ json: Any = None,
+ *,
+ url: str | re.Pattern[str] | httpx2.URL | None = None,
+ method: str | None = None,
+ ) -> None:
+ json = copy.deepcopy(json) if json is not None else None
+
+ def callback(request: httpx2.Request) -> httpx2.Response:
+ return httpx2.Response(
+ status_code=status_code,
+ headers=headers,
+ content=content,
+ text=text,
+ html=html,
+ json=json,
+ stream=stream,
+ )
+
+ self._callbacks.append((_Matcher(url, method), callback))
+
+ def add_exception(
+ self,
+ exception: BaseException,
+ *,
+ url: str | re.Pattern[str] | httpx2.URL | None = None,
+ method: str | None = None,
+ ) -> None:
+ def callback(request: httpx2.Request) -> httpx2.Response:
+ if isinstance(exception, httpx2.RequestError):
+ exception.request = request
+ raise exception
+
+ self._callbacks.append((_Matcher(url, method), callback))
+
+ def _get_callback(
+ self, request: httpx2.Request
+ ) -> Callable[[httpx2.Request], httpx2.Response] | None:
+ matching = [
+ (matcher, callback)
+ for matcher, callback in self._callbacks
+ if matcher.match(request)
+ ]
+ if not matching:
+ return None
+ # First not-yet-used callback wins; otherwise reuse the last match.
+ for matcher, callback in matching:
+ if not matcher.nb_calls:
+ matcher.nb_calls += 1
+ return callback
+ matcher, callback = matching[-1]
+ matcher.nb_calls += 1
+ return callback
+
+ def _handle(self, request: httpx2.Request) -> httpx2.Response:
+ self._requests.append(request)
+ callback = self._get_callback(request)
+ if callback is None:
+ self._requests_not_matched.append(request)
+ raise httpx2.TimeoutException(
+ f"No response can be found for {request.method} request on "
+ f"{request.url}",
+ request=request,
+ )
+ return _unread(callback(request))
+
+ def _handle_request(self, request: httpx2.Request) -> httpx2.Response:
+ request.read()
+ return self._handle(request)
+
+ async def _handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
+ await request.aread()
+ return self._handle(request)
+
+ def get_requests(
+ self,
+ *,
+ url: str | re.Pattern[str] | httpx2.URL | None = None,
+ method: str | None = None,
+ ) -> list[httpx2.Request]:
+ matcher = _Matcher(url, method)
+ return [request for request in self._requests if matcher.match(request)]
+
+ def get_request(
+ self,
+ *,
+ url: str | re.Pattern[str] | httpx2.URL | None = None,
+ method: str | None = None,
+ ) -> httpx2.Request | None:
+ requests = self.get_requests(url=url, method=method)
+ assert len(requests) <= 1, (
+ f"More than one request ({len(requests)}) matched, use get_requests "
+ "instead or refine your filters."
+ )
+ return requests[0] if requests else None
+
+ def reset(self) -> None:
+ self._callbacks.clear()
+ self._requests.clear()
+ self._requests_not_matched.clear()
+
+ def _assert_options(self) -> None:
+ not_requested = [
+ str(matcher) for matcher, _ in self._callbacks if not matcher.nb_calls
+ ]
+ assert not not_requested, (
+ "The following responses are mocked but not requested:\n"
+ + "\n".join(f"- {matcher}" for matcher in not_requested)
+ )
+ not_matched = [
+ f"- {request.method} request on {request.url}"
+ for request in self._requests_not_matched
+ ]
+ assert not not_matched, (
+ "The following requests were not expected:\n" + "\n".join(not_matched)
+ )
+
+
+@pytest.fixture
+def httpx_mock(
+ monkeypatch: MonkeyPatch,
+ request: FixtureRequest,
+) -> Generator[HTTPXMock, None, None]:
+ mock = HTTPXMock()
+
+ def mocked_handle_request(
+ transport: httpx2.HTTPTransport, request: httpx2.Request
+ ) -> httpx2.Response:
+ return mock._handle_request(request)
+
+ monkeypatch.setattr(httpx2.HTTPTransport, "handle_request", mocked_handle_request)
+
+ async def mocked_handle_async_request(
+ transport: httpx2.AsyncHTTPTransport, request: httpx2.Request
+ ) -> httpx2.Response:
+ return await mock._handle_async_request(request)
+
+ monkeypatch.setattr(
+ httpx2.AsyncHTTPTransport,
+ "handle_async_request",
+ mocked_handle_async_request,
+ )
+
+ yield mock
+ try:
+ mock._assert_options()
+ finally:
+ mock.reset()
diff --git a/tests/utilities/openapi/test_circular_references.py b/tests/utilities/openapi/test_circular_references.py
index ed08d5e06..cff7bdbd7 100644
--- a/tests/utilities/openapi/test_circular_references.py
+++ b/tests/utilities/openapi/test_circular_references.py
@@ -1,6 +1,6 @@
"""Tests for circular and self-referential schema serialization (Issues #1016, #1206, #3242)."""
-import httpx
+import httpx2
from fastmcp import FastMCP
from fastmcp.utilities.openapi.models import (
@@ -247,7 +247,7 @@ class TestCircularReferencesSerialization:
},
}
- server = FastMCP.from_openapi(spec, httpx.AsyncClient())
+ server = FastMCP.from_openapi(spec, httpx2.AsyncClient())
tools = await server.list_tools()
assert len(tools) >= 3
diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py
index 651d8c4a9..777566688 100644
--- a/tests/utilities/openapi/test_director.py
+++ b/tests/utilities/openapi/test_director.py
@@ -172,7 +172,7 @@ class TestRequestDirector:
assert request.url == "https://api.example.com/users/123"
assert (
request.content == b""
- ) # httpx.Request sets content to empty bytes for GET
+ ) # httpx2.Request sets content to empty bytes for GET
def test_build_complex_request(self, director, complex_route):
"""Test building a complex request with multiple parameter types."""
diff --git a/tests/utilities/openapi/test_nullable_fields.py b/tests/utilities/openapi/test_nullable_fields.py
index ccdfe8c43..ff65442cc 100644
--- a/tests/utilities/openapi/test_nullable_fields.py
+++ b/tests/utilities/openapi/test_nullable_fields.py
@@ -1,6 +1,6 @@
"""Tests for nullable field handling in OpenAPI schemas."""
-import httpx
+import httpx2
import pytest
from jsonschema import ValidationError, validate
@@ -488,7 +488,7 @@ class TestNullableInputSchemaIntegration:
}
},
}
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("test")
mcp.add_provider(provider)
@@ -537,7 +537,7 @@ class TestNullableInputSchemaIntegration:
}
},
}
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ async with httpx2.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("test")
mcp.add_provider(provider)
diff --git a/tests/utilities/test_version_check.py b/tests/utilities/test_version_check.py
index 9cc9aab3b..6198a5279 100644
--- a/tests/utilities/test_version_check.py
+++ b/tests/utilities/test_version_check.py
@@ -5,7 +5,7 @@ import time
from pathlib import Path
from unittest.mock import MagicMock, patch
-import httpx
+import httpx2
import pytest
from fastmcp.utilities.version_check import (
@@ -105,14 +105,14 @@ class TestFetchLatestVersion:
}
}
- with patch("httpx.get", return_value=mock_response) as mock_get:
+ with patch("httpx2.get", return_value=mock_response) as mock_get:
version = _fetch_latest_version()
assert version == "2.5.0"
mock_get.assert_called_once()
def test_fetch_network_error(self):
"""Network error returns None."""
- with patch("httpx.get", side_effect=httpx.HTTPError("Network error")):
+ with patch("httpx2.get", side_effect=httpx2.HTTPError("Network error")):
version = _fetch_latest_version()
assert version is None
@@ -121,7 +121,7 @@ class TestFetchLatestVersion:
mock_response = MagicMock()
mock_response.json.return_value = {"unexpected": "format"}
- with patch("httpx.get", return_value=mock_response):
+ with patch("httpx2.get", return_value=mock_response):
version = _fetch_latest_version()
assert version is None
@@ -138,7 +138,7 @@ class TestFetchLatestVersion:
},
}
- with patch("httpx.get", return_value=mock_response):
+ with patch("httpx2.get", return_value=mock_response):
version = _fetch_latest_version(include_prereleases=True)
assert version == "2.6.0b2"
@@ -154,7 +154,7 @@ class TestFetchLatestVersion:
},
}
- with patch("httpx.get", return_value=mock_response):
+ with patch("httpx2.get", return_value=mock_response):
version = _fetch_latest_version(include_prereleases=True)
assert version == "2.5.0"
diff --git a/uv.lock b/uv.lock
index d78c55152..a3fe4cc9b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10,13 +10,16 @@ resolution-markers = [
]
[options]
-exclude-newer = "2026-06-29T14:57:29.757967Z"
+exclude-newer = "2026-07-07T19:26:14.279827Z"
exclude-newer-span = "P1W"
[options.exclude-newer-package]
mcp-types = false
prefab-ui = false
+truststore = false
mcp = false
+httpcore2 = false
+httpx2 = false
[manifest]
members = [
@@ -888,7 +891,6 @@ dev = [
{ name = "pytest-env" },
{ name = "pytest-examples" },
{ name = "pytest-flakefinder" },
- { name = "pytest-httpx" },
{ name = "pytest-report" },
{ name = "pytest-retry" },
{ name = "pytest-timeout" },
@@ -932,7 +934,6 @@ dev = [
{ name = "pytest-env", specifier = ">=1.1.5" },
{ name = "pytest-examples", specifier = ">=0.0.18" },
{ name = "pytest-flakefinder", specifier = ">=1.1.0" },
- { name = "pytest-httpx", specifier = ">=0.35.0" },
{ name = "pytest-report", specifier = ">=0.2.1" },
{ name = "pytest-retry", specifier = ">=1.7.0" },
{ name = "pytest-timeout", specifier = ">=2.4.0" },
@@ -978,7 +979,7 @@ azure = [
client = [
{ name = "authlib" },
{ name = "exceptiongroup" },
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "opentelemetry-api" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
@@ -993,7 +994,7 @@ gemini = [
]
mcp = [
{ name = "exceptiongroup" },
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "mcp" },
{ name = "opentelemetry-api" },
{ name = "starlette" },
@@ -1006,7 +1007,7 @@ server = [
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "griffelib" },
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "joserfc" },
{ name = "jsonref" },
{ name = "jsonschema-path" },
@@ -1040,17 +1041,17 @@ requires-dist = [
{ name = "exceptiongroup", marker = "extra == 'server'", specifier = ">=1.2.2" },
{ name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.18.0" },
{ name = "griffelib", marker = "extra == 'server'", specifier = ">=2.0.0" },
- { name = "httpx", marker = "extra == 'client'", specifier = ">=0.28.1,<1.0" },
- { name = "httpx", marker = "extra == 'mcp'", specifier = ">=0.28.1,<1.0" },
- { name = "httpx", marker = "extra == 'server'", specifier = ">=0.28.1,<1.0" },
+ { name = "httpx2", marker = "extra == 'client'", specifier = ">=2.5.0" },
+ { name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.5.0" },
+ { name = "httpx2", marker = "extra == 'server'", specifier = ">=2.5.0" },
{ name = "joserfc", marker = "extra == 'server'", specifier = ">=1.1.0" },
{ name = "jsonref", marker = "extra == 'gemini'", specifier = ">=1.1.0" },
{ name = "jsonref", marker = "extra == 'server'", specifier = ">=1.1.0" },
{ name = "jsonschema-path", marker = "extra == 'server'", specifier = ">=0.3.4" },
- { name = "mcp", marker = "extra == 'client'", specifier = "==2.0.0b1" },
- { name = "mcp", marker = "extra == 'mcp'", specifier = "==2.0.0b1" },
- { name = "mcp", marker = "extra == 'server'", specifier = "==2.0.0b1" },
- { name = "mcp-types", specifier = "==2.0.0b1" },
+ { name = "mcp", marker = "extra == 'client'", specifier = "==2.0.0b2" },
+ { name = "mcp", marker = "extra == 'mcp'", specifier = "==2.0.0b2" },
+ { name = "mcp", marker = "extra == 'server'", specifier = "==2.0.0b2" },
+ { name = "mcp-types", specifier = "==2.0.0b2" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
{ name = "openapi-pydantic", marker = "extra == 'server'", specifier = ">=0.5.1" },
{ name = "opentelemetry-api", marker = "extra == 'client'", specifier = ">=1.28.0" },
@@ -1225,6 +1226,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
+[[package]]
+name = "httpcore2"
+version = "2.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+ { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/db/2ad49878b36af4cff7527c1158b083ad6d9350462f1a35685cc3ebfa7c2b/httpcore2-2.6.0.tar.gz", hash = "sha256:95b692b582402ec49b3d84c2343556e4ac4c0962c8b3d39c48d485b9ecc240ab", size = 65592, upload-time = "2026-07-14T10:48:33.816Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/fa/08f483851a70ef10806e3b84240f2a8f923658035b794f7609795895d9ea/httpcore2-2.6.0-py3-none-any.whl", hash = "sha256:c237a45c7eef885cf032cb9b850d59fcf1fa7e00230307f08aab26486a6ed584", size = 81507, upload-time = "2026-07-14T10:48:31.504Z" },
+]
+
[[package]]
name = "httpx"
version = "0.28.1"
@@ -1241,12 +1255,19 @@ wheels = [
]
[[package]]
-name = "httpx-sse"
-version = "0.4.3"
+name = "httpx2"
+version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpcore2" },
+ { name = "idna" },
+ { name = "truststore" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/17/1e142bf3c76684232a092e1e4002be07fd3403b1c2dcb15d0012ea300c8f/httpx2-2.6.0.tar.gz", hash = "sha256:5d362fd59562cf2139a60c67bb016587a70b36156a517f176c7cbf1587d1ab22", size = 92736, upload-time = "2026-07-14T10:48:35.12Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
+ { url = "https://files.pythonhosted.org/packages/47/86/7d82f7c6aac32433eaf0c914b8bc870ae25759413b9d7d52ea6aa15f2546/httpx2-2.6.0-py3-none-any.whl", hash = "sha256:6cccc3665d6bceb3c1c4f1422ae7e53fda67a853f0135f09b25ce0d4dcac01e3", size = 88541, upload-time = "2026-07-14T10:48:32.681Z" },
]
[[package]]
@@ -1664,12 +1685,11 @@ wheels = [
[[package]]
name = "mcp"
-version = "2.0.0b1"
+version = "2.0.0b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
- { name = "httpx" },
- { name = "httpx-sse" },
+ { name = "httpx2" },
{ name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
@@ -1684,22 +1704,22 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d6/78/9f540a2f8f673973c9dee7977d020a0c10b97be3a6417a833bc3af166811/mcp-2.0.0b1.tar.gz", hash = "sha256:f0bb4543507117f872613fc76bd7b73cc2a8f6ddd9426f0402f268447f21b260", size = 1478434, upload-time = "2026-06-30T23:24:38.363Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/aa/c5d38e0199304494be6370667d04d93d8681d9bdc864d56678250dbd3f3b/mcp-2.0.0b2.tar.gz", hash = "sha256:0528d0d38ae798fbff251616ec687faaaa8f5309571e0b2bc553c530fa10b8b1", size = 1590650, upload-time = "2026-07-14T16:47:57.897Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ac/bb/9c5c4e428e63d40b5542cfc9c61a0438943ccb260bf9929dc02c47527235/mcp-2.0.0b1-py3-none-any.whl", hash = "sha256:7e169929da99487b1998f8f53548bb93ff5157165f2f5a10fb45202c02a91fce", size = 320414, upload-time = "2026-06-30T23:24:34.788Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/90/187d6283a304acc6954987992ef17e2515739a649f148dc8b67b302e1bbd/mcp-2.0.0b2-py3-none-any.whl", hash = "sha256:9c50ae5afa08960ab76d50aa3adab3184952d9bea7ef87f4a4a5ba68bdefcf0a", size = 334286, upload-time = "2026-07-14T16:47:54.768Z" },
]
[[package]]
name = "mcp-types"
-version = "2.0.0b1"
+version = "2.0.0b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2b/88/f3e4322a5bfc382f8851e584f2e2c87354467dc17fb602dd4b13dee65b7c/mcp_types-2.0.0b1.tar.gz", hash = "sha256:6a26910a737c4cd4de36c7d5629febe7d33d4556a9d196166b5c7187a6516944", size = 65785, upload-time = "2026-06-30T23:24:39.796Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ad/21/db529130ac8edd1d844fc322862afeceaf2b7f610a591fa528002b022e07/mcp_types-2.0.0b2.tar.gz", hash = "sha256:094fa7160106819ab39a1586179c3a9f070bfd833d0a6f8fcb30a54a986cc402", size = 65877, upload-time = "2026-07-14T16:47:59.198Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3f/05/2da79c73dd07d028163c2b24d4cc6e988da11dff83f7902a07d69a87df80/mcp_types-2.0.0b1-py3-none-any.whl", hash = "sha256:c1b22b56b0ba7b1d51c84dc99afdd603ae225c55e9e8f07477774552bf34c8cd", size = 68945, upload-time = "2026-06-30T23:24:36.671Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/e1/c466ceacdaa929396d35ad45176398acd0c416fead0970d3ad22618a19d7/mcp_types-2.0.0b2-py3-none-any.whl", hash = "sha256:35c9c33abb90a77dc6ad1daecaa6407c788c2f32d14d52dad8f843c4f008eae2", size = 68944, upload-time = "2026-07-14T16:47:56.493Z" },
]
[[package]]
@@ -2569,19 +2589,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" },
]
-[[package]]
-name = "pytest-httpx"
-version = "0.36.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "httpx" },
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" },
-]
-
[[package]]
name = "pytest-report"
version = "0.2.1"
@@ -3171,6 +3178,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
[[package]]
name = "ty"
version = "0.0.55"