Migrate to MCP SDK v2.0.0b2 (httpx2) (#4503)

This commit is contained in:
Jeremiah Lowin 2026-07-18 15:12:47 -04:00 committed by GitHub
commit 18b5ab5852
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 1710 additions and 678 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -327,21 +327,21 @@ This pattern enables comprehensive testing of JWT validation logic without depen
<VersionBadge version="2.18.0" />
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",

View file

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

View file

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

View file

@ -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`.
<Tip>
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
Heres 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 = {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more