mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Migrate to MCP SDK v2.0.0b2 (httpx2) (#4503)
This commit is contained in:
parent
66c0270bc1
commit
18b5ab5852
112 changed files with 1710 additions and 678 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"},
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|||
Here’s how you can add custom route maps to turn `GET` requests into `Resources` and `ResourceTemplates` (if they have path parameters):
|
||||
|
||||
```python api_server_with_resources.py {3, 37-42}
|
||||
import httpx
|
||||
import httpx2
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.providers.openapi import RouteMap, MCPType
|
||||
|
||||
|
||||
# Create an HTTP client for the target API
|
||||
client = httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
|
||||
client = httpx2.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
|
||||
|
||||
# Define a simplified OpenAPI spec for JSONPlaceholder
|
||||
openapi_spec = {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
147
fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
Normal file
147
fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
183
tests/server/auth/oauth_proxy/test_upstream_client.py
Normal file
183
tests/server/auth/oauth_proxy/test_upstream_client.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
166
tests/server/providers/openapi/test_legacy_client_compat.py
Normal file
166
tests/server/providers/openapi/test_legacy_client_compat.py
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue