Merge branch 'main' into encrypt-task-snapshots-4747

This commit is contained in:
Chris Guidry 2026-08-06 10:21:06 -04:00 committed by GitHub
commit a09569bcab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 537 additions and 326 deletions

View file

@ -56,6 +56,8 @@ When modifying MCP functionality, changes typically need to be applied across al
**Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review.
**Review closed contributor PRs.** When reviewing an issue, inspect every associated non-maintainer PR, including closed PRs. External PRs may be closed as part of the issue-link and assignment workflow, so closure alone is not a negative signal. Read `CONTRIBUTING.md` and the PR timeline and comments to understand its status before evaluating it.
### Git & CI
- Prek hooks are required (run automatically on commits)

View file

@ -209,7 +209,7 @@ transport = StreamableHttpTransport(
)
```
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 `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) should now be an `httpx2.AsyncClient`. Existing `httpx.AsyncClient` instances remain temporarily accepted via duck typing, but emit a `FastMCPDeprecationWarning` and will be rejected in a future release. HTTP made inside your own tools is entirely yours and is unaffected.
**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:

View file

@ -240,6 +240,10 @@ proxy = create_proxy(
A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one.
### Request Metadata
Request `_meta` follows the same connection boundary. Progress tokens, tracing, task state, and application or vendor metadata pass through the proxy to the backend. The connection-owned keys — protocol version, client identity, and client capabilities — never copy from the frontend connection: a modern backend session stamps its own negotiated values, and a handshake-era backend receives none. This holds even when the two connections negotiate different eras, such as a modern client reaching a handshake-only backend through an explicit `mode`.
## Configuration-Based Proxies
<VersionBadge version="2.4.0" />

View file

@ -43,8 +43,7 @@ class AsyncOAuth2Client:
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).
`fetch_token`/`refresh_token`/`client_secret`/`aclose` surface.
"""
def __init__(

View file

@ -962,9 +962,8 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
"""The accepted elicitation will contain the response data"""
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
"""The accepted elicitation will contain the response data"""
@overload
async def elicit(
@ -974,10 +973,9 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is a list of strings, the accepted elicitation will
contain the selected string response"""
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
"""When response_type is a list of strings, the accepted elicitation will
contain the selected string response"""
@overload
async def elicit(
@ -987,10 +985,9 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is a dict mapping keys to title dicts, the accepted
elicitation will contain the selected key"""
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
"""When response_type is a dict mapping keys to title dicts, the accepted
elicitation will contain the selected key"""
@overload
async def elicit(
@ -1000,12 +997,9 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
) -> (
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
): ...
"""When response_type is a list containing a list of strings (multi-select),
the accepted elicitation will contain a list of selected strings"""
) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
"""When response_type is a list containing a list of strings (multi-select),
the accepted elicitation will contain a list of selected strings"""
@overload
async def elicit(
@ -1015,13 +1009,10 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
) -> (
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
): ...
"""When response_type is a list containing a dict mapping keys to title dicts
(multi-select with titles), the accepted elicitation will contain a list of
selected keys"""
) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
"""When response_type is a list containing a dict mapping keys to title dicts
(multi-select with titles), the accepted elicitation will contain a list of
selected keys"""
async def elicit(
self,

View file

@ -53,7 +53,7 @@ The main server class orchestrates the stateless request building approach:
```python
class FastMCPOpenAPI(FastMCP):
def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs):
def __init__(self, openapi_spec: dict, client: httpx2.AsyncClient, **kwargs):
# 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas
self._routes = parse_openapi_to_http_routes(openapi_spec)
@ -92,7 +92,7 @@ OpenAPI Spec → HTTPRoute with Pre-calculated Fields → RequestDirector → HT
2. **RequestDirector Setup**: openapi-core Spec initialized for request building
3. **Component Creation**: Create components with RequestDirector reference
4. **Request Building**: RequestDirector builds HTTP request from flat parameters
5. **Request Execution**: Execute request with httpx client
5. **Request Execution**: Execute request with httpx2 client
6. **Response Processing**: Return structured MCP response
## Key Features
@ -263,4 +263,4 @@ logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG)
- `/utilities/openapi_new/README.md` - Utility implementation details
- `/server/openapi/README.md` - Legacy implementation reference
- `/tests/server/openapi_new/` - Comprehensive test suite
- Project documentation on OpenAPI integration patterns
- Project documentation on OpenAPI integration patterns

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
import httpx2
from mcp_types import ToolAnnotations
@ -18,11 +18,7 @@ from fastmcp.resources import (
)
from fastmcp.server.dependencies import get_http_headers
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.exceptions import (
HTTP_STATUS_ERRORS,
REQUEST_ERRORS,
TIMEOUT_ERRORS,
)
from fastmcp.utilities.exceptions import is_request_error, is_timeout_error
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector
@ -63,6 +59,36 @@ logger = get_logger(__name__)
_DEFAULT_MIME_TYPE = "application/json"
def _raise_for_status(response: httpx2.Response) -> None:
"""Raise an OpenAPI-formatted error without relying on client exception types."""
if 200 <= response.status_code < 300:
return
error_message = f"HTTP error {response.status_code}: {response.reason_phrase}"
try:
error_data = response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if response.text:
error_message += f" - {response.text}"
raise ValueError(error_message)
async def _send_request(
client: httpx2.AsyncClient,
request: httpx2.Request,
) -> httpx2.Response:
"""Send a request while preserving transitional legacy-client errors."""
try:
return await client.send(request)
except Exception as exc:
if is_timeout_error(exc):
raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
if is_request_error(exc):
raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
raise
def _extract_mime_type_from_route(route: HTTPRoute) -> str:
"""Extract the primary MIME type from an HTTPRoute's response definitions.
@ -176,12 +202,8 @@ class OpenAPITool(Tool):
base_url = str(self._client.base_url) or "http://localhost"
directed_request = self._director.build(self._route, arguments, base_url)
# 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.
# Rebuild through the configured client so its default headers are
# merged with the directed headers taking priority.
request = self._client.build_request(
method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)),
@ -210,8 +232,8 @@ class OpenAPITool(Tool):
f"run - sending request; headers: {_redact_headers(request.headers)}"
)
response = await self._client.send(request)
response.raise_for_status()
response = await _send_request(self._client, request)
_raise_for_status(response)
# Try to parse as JSON first
try:
@ -238,25 +260,11 @@ class OpenAPITool(Tool):
except json.JSONDecodeError:
return ToolResult(content=response.text)
except HTTP_STATUS_ERRORS as e:
status_error = cast("httpx2.HTTPStatusError", e)
error_message = (
f"HTTP error {status_error.response.status_code}: "
f"{status_error.response.reason_phrase}"
)
try:
error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if status_error.response.text:
error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
except httpx2.TimeoutException as exc:
raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
except TIMEOUT_ERRORS as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
except httpx2.RequestError as exc:
raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
class OpenAPIResource(Resource):
@ -298,8 +306,7 @@ 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.
# Build through the configured client so its defaults are applied.
request = self._client.build_request(
method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)),
@ -314,8 +321,8 @@ class OpenAPIResource(Resource):
if mcp_headers:
request.headers.update(mcp_headers)
response = await self._client.send(request)
response.raise_for_status()
response = await _send_request(self._client, request)
_raise_for_status(response)
content_type = response.headers.get("content-type", "").lower()
@ -343,25 +350,11 @@ class OpenAPIResource(Resource):
]
)
except HTTP_STATUS_ERRORS as e:
status_error = cast("httpx2.HTTPStatusError", e)
error_message = (
f"HTTP error {status_error.response.status_code}: "
f"{status_error.response.reason_phrase}"
)
try:
error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if status_error.response.text:
error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
except httpx2.TimeoutException as exc:
raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
except TIMEOUT_ERRORS as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
except httpx2.RequestError as exc:
raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str:

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import warnings
from collections import Counter
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
@ -10,6 +11,7 @@ from typing import Any, Literal, cast
import httpx2
from jsonschema_path import SchemaPath
from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.providers.base import Provider
@ -48,6 +50,14 @@ logger = get_logger(__name__)
DEFAULT_TIMEOUT: float = 30.0
def _is_legacy_httpx_client(client: object) -> bool:
"""Detect a legacy httpx client without importing the legacy package."""
return any(
cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == "AsyncClient"
for cls in type(client).__mro__
)
class OpenAPIProvider(Provider):
"""Provider that creates MCP components from an OpenAPI specification.
@ -84,10 +94,12 @@ class OpenAPIProvider(Provider):
Args:
openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx AsyncClient for making HTTP requests.
client: Optional httpx2 AsyncClient for making HTTP requests.
If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout.
To customize timeout or other settings, pass your own client.
Legacy httpx clients are temporarily accepted with a deprecation
warning.
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
@ -103,6 +115,14 @@ class OpenAPIProvider(Provider):
self._owns_client = client is None
if client is None:
client = self._create_default_client(openapi_spec)
elif _is_legacy_httpx_client(client):
warnings.warn(
"Passing an httpx.AsyncClient to OpenAPIProvider is deprecated "
"and will be removed in a future release. Pass an "
"httpx2.AsyncClient instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
self._client = client
self._mcp_component_fn = mcp_component_fn
self._validate_output = validate_output

View file

@ -130,6 +130,50 @@ def _proxy_upstream_error(error: Exception) -> MCPError:
)
# Request `_meta` keys that describe one negotiated MCP connection. They never
# cross the proxy: a modern backend session stamps its own negotiated values on
# every request, and a handshake-era backend must not receive them at all.
_CONNECTION_META_KEYS = frozenset(
{
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
}
)
def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
"""Frontend request metadata that may cross onto the backend connection.
This is the proxy's one sanctioned read of the inbound request's `_meta`:
progress tokens, tracing, task, and application metadata pass through,
while connection-owned keys (`_CONNECTION_META_KEYS`) are dropped because
they describe the frontend connection, not the backend one.
"""
request_context = ctx.request_context if ctx is not None else None
if request_context is None or not request_context.meta:
return None
forwarded = {
key: value
for key, value in request_context.meta.items()
if key not in _CONNECTION_META_KEYS
}
return forwarded or None
def _session_request_meta(
meta: dict[str, Any] | None,
) -> mcp_types.RequestParamsMeta | None:
"""Adapt forwardable metadata for a direct backend-session call.
Direct session calls bypass the high-level client mixins, so trace context
is injected here, matching what the mixins do on the legacy client paths.
"""
return cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None
)
async def _relay_read_resource(
client: Client, uri: str, ctx: Context | None
) -> (
@ -143,15 +187,15 @@ async def _relay_read_resource(
to forward, instead of the high-level client trying to answer it here the
proxy has no back-channel to the real user, so driving it fails outright.
The inbound request's continuation state travels down so the backend guard
sees the client's answers on its own `ctx.input_responses`. Trace context
still propagates: the SDK's JSON-RPC dispatcher injects it on every outgoing
request (SEP-414), below whichever client layer issued the call.
sees the client's answers on its own `ctx.input_responses`.
"""
meta = _forwardable_request_meta(ctx)
if client.protocol_version not in MODERN_PROTOCOL_VERSIONS:
return await client.read_resource(uri)
return await client.read_resource(uri, meta=meta)
result = await client._await_with_session_monitoring(
client.session.read_resource(
uri,
meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None,
allow_input_required=True,
@ -311,15 +355,11 @@ class ProxyTool(Tool):
async with client:
ctx = context or get_context()
_stash_proxy_request_context(client, ctx)
# Forward the inbound request's `_meta` block (trace context,
# version, etc.) to the backend. In SDK v2 the request context
# exposes the lifted `_meta` dict directly; task submission is a
# first-class params field rather than context state, so there
# is no separate task-metadata injection here.
req_ctx = ctx.request_context
meta: dict[str, Any] | None = (
dict(req_ctx.meta) if req_ctx is not None and req_ctx.meta else None
)
# Forward the inbound request's hop-safe `_meta` (trace
# context, progress token, etc.) to the backend. Task
# submission is a first-class params field rather than context
# state, so there is no separate task-metadata injection here.
meta = _forwardable_request_meta(ctx)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# Modern backend: call the session directly (not
@ -330,10 +370,7 @@ class ProxyTool(Tool):
# round. Forward the inbound request's continuation state
# down so the backend guard tool sees the client's answers
# on its own `ctx.input_responses` / `ctx.request_state`.
request_meta = cast(
"mcp_types.RequestParamsMeta | None",
inject_trace_context(meta) or None,
)
request_meta = _session_request_meta(meta)
# SEP-2243: a modern backend rejects a `tools/call` whose
# `x-mcp-header` argument is not mirrored into an `Mcp-Param-*`
# header. The SDK client emits those headers only for tools it
@ -704,6 +741,7 @@ class ProxyPrompt(Prompt):
ctx = get_context()
async with client:
_stash_proxy_request_context(client, ctx)
meta = _forwardable_request_meta(ctx)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# See `_relay_read_resource`: surface a backend guard's ask
# instead of trying to answer it inside the proxy.
@ -711,6 +749,7 @@ class ProxyPrompt(Prompt):
client.session.get_prompt(
backend_name,
arguments,
meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None,
allow_input_required=True,
@ -720,7 +759,7 @@ class ProxyPrompt(Prompt):
return InputRequiredPromptResult(raw)
result = raw
else:
result = await client.get_prompt(backend_name, arguments)
result = await client.get_prompt(backend_name, arguments, meta=meta)
# Convert GetPromptResult to PromptResult, preserving meta from result
# (not the static prompt meta which includes fastmcp tags)
# Convert PromptMessages to Messages

View file

@ -88,7 +88,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.exceptions import get_http_status_code, is_timeout_error
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
@ -112,11 +112,6 @@ 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,
@ -1546,15 +1541,11 @@ 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, _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, _ACTIONABLE_TIMEOUT_ERRORS):
if get_http_status_code(e) == 429:
raise ToolError(
"Rate limited by upstream API, please retry later"
) from e
if is_timeout_error(e):
raise ToolError(
"Upstream request timed out, please retry"
) from e
@ -1649,15 +1640,11 @@ 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, _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, _ACTIONABLE_TIMEOUT_ERRORS):
if get_http_status_code(e) == 429:
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
if is_timeout_error(e):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@ -1712,15 +1699,11 @@ 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, _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, _ACTIONABLE_TIMEOUT_ERRORS):
if get_http_status_code(e) == 429:
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
if is_timeout_error(e):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@ -2412,10 +2395,10 @@ class FastMCP(
Args:
openapi_spec: OpenAPI schema as a dictionary
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
If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout.
Legacy httpx clients are temporarily accepted with a deprecation
warning.
name: Name for the MCP server
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping

View file

@ -7,30 +7,42 @@ 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,
def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool:
"""Check a legacy-httpx exception without importing the legacy package."""
return any(
cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == exception_type
for cls in type(exc).__mro__
)
TIMEOUT_ERRORS: tuple[type[BaseException], ...] = (
httpx2.TimeoutException,
httpx.TimeoutException,
def is_http_status_error(exc: BaseException) -> bool:
"""Return whether an exception is an httpx2 or legacy-httpx status error."""
return isinstance(exc, httpx2.HTTPStatusError) or _is_legacy_httpx_exception(
exc, "HTTPStatusError"
)
REQUEST_ERRORS: tuple[type[BaseException], ...] = (
httpx2.RequestError,
httpx.RequestError,
def get_http_status_code(exc: BaseException) -> int | None:
"""Return the response status code from a recognized HTTP status error."""
if not is_http_status_error(exc):
return None
status_code = getattr(getattr(exc, "response", None), "status_code", None)
return status_code if isinstance(status_code, int) else None
def is_timeout_error(exc: BaseException) -> bool:
"""Return whether an exception is an httpx2 or legacy-httpx timeout."""
return isinstance(exc, httpx2.TimeoutException) or _is_legacy_httpx_exception(
exc, "TimeoutException"
)
def is_request_error(exc: BaseException) -> bool:
"""Return whether an exception is an httpx2 or legacy-httpx request error."""
return isinstance(exc, httpx2.RequestError) or _is_legacy_httpx_exception(
exc, "RequestError"
)
except ImportError:
HTTP_STATUS_ERRORS = (httpx2.HTTPStatusError,)
TIMEOUT_ERRORS = (httpx2.TimeoutException,)
REQUEST_ERRORS = (httpx2.RequestError,)
def iter_exc(group: BaseExceptionGroup):

View file

@ -47,7 +47,7 @@ OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDire
### Request Processing
```
MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response → Structured Output
MCP Tool Call → RequestDirector.build() → httpx2.Request → HTTP Response → Structured Output
```
1. **Tool Invocation**: FastMCP receives tool call with parameters
@ -103,14 +103,14 @@ All components use the same RequestDirector approach:
### Basic Server Setup
```python
import httpx
import httpx2
from fastmcp.server.openapi import FastMCPOpenAPI
# OpenAPI spec (can be loaded from file/URL)
openapi_spec = {...}
# Create HTTP client
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
# Create server with stateless request building
server = FastMCPOpenAPI(
openapi_spec=openapi_spec,
@ -134,8 +134,8 @@ director = RequestDirector(spec)
# Build HTTP request
request = director.build(route, flat_arguments, base_url)
# Execute with httpx
async with httpx.AsyncClient() as client:
# Execute with httpx2
async with httpx2.AsyncClient() as client:
response = await client.send(request)
```
@ -206,6 +206,6 @@ Tests are located in `/tests/server/openapi/`:
## Dependencies
- `openapi-core` - OpenAPI specification processing and validation
- `httpx` - HTTP client library
- `httpx2` - HTTP client library
- `pydantic` - Data validation and serialization
- `urllib.parse` - URL building and manipulation
- `urllib.parse` - URL building and manipulation

View file

@ -653,13 +653,6 @@ class TestOpenAPIComprehensive:
mock_response.json.return_value = {"code": 404, "message": "User not found"}
mock_response.text = json.dumps({"code": 404, "message": "User not found"})
# Configure raise_for_status to raise HTTPStatusError
def raise_for_status():
raise httpx2.HTTPStatusError(
"404 Not Found", request=Mock(), response=mock_response
)
mock_response.raise_for_status = raise_for_status
mock_client.send = AsyncMock(return_value=mock_response)
server = create_openapi_server(

View file

@ -1,20 +1,9 @@
"""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.
"""
"""Deprecation bridge for legacy-httpx OpenAPI clients."""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp import Client, FastMCP, FastMCPDeprecationWarning
from fastmcp.exceptions import ToolError
from fastmcp.server.providers.openapi import OpenAPIProvider
httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
@ -26,7 +15,6 @@ SPEC = {
"/items": {
"get": {
"operationId": "list_items",
"summary": "List items",
"responses": {
"200": {
"description": "Items",
@ -46,121 +34,77 @@ SPEC = {
}
},
}
},
}
},
}
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."""
async def test_legacy_client_warns_and_remains_usable() -> None:
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:
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport,
base_url="https://api.example.com",
) as client:
with pytest.warns(
FastMCPDeprecationWarning,
match="httpx.AsyncClient.*deprecated",
):
server = FastMCP.from_openapi(SPEC, client=client)
async with Client(server) as mcp_client:
result = await mcp_client.call_tool("list_items", {})
assert result.structured_content == {"items": ["a", "b"]}
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.
"""
async def test_legacy_client_preserves_http_error_details() -> None:
def handler(request: "httpx.Request") -> "httpx.Response":
return httpx.Response(500, json={"detail": "boom"})
return httpx.Response(404, json={"detail": "items not found"})
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)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport,
base_url="https://api.example.com",
) as client:
with pytest.warns(FastMCPDeprecationWarning):
server = FastMCP.from_openapi(SPEC, client=client)
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"):
async with Client(server) as mcp_client:
with pytest.raises(ToolError, match="HTTP error 404") as exc_info:
await mcp_client.call_tool("list_items", {})
assert "items not found" in str(exc_info.value)
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] = {}
@pytest.mark.parametrize(
("error_kind", "message"),
[
("timeout", "HTTP request timed out (ReadTimeout)"),
("connect", "Request error (ConnectError)"),
],
)
async def test_legacy_client_preserves_transport_error_details(
error_kind: str,
message: str,
) -> None:
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})
if error_kind == "timeout":
raise httpx.ReadTimeout("transport failed", request=request)
raise httpx.ConnectError("transport failed", request=request)
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}
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport,
base_url="https://api.example.com",
) as client:
with pytest.warns(FastMCPDeprecationWarning):
server = FastMCP.from_openapi(SPEC, client=client)
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
async with Client(server) as mcp_client:
with pytest.raises(ToolError) as exc_info:
await mcp_client.call_tool("list_items", {})
assert message in str(exc_info.value)

View file

@ -150,19 +150,11 @@ async def proxy_server(fastmcp_server: FastMCP):
`ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see
`TestProxyClientEraDefault` above a directly-constructed `ProxyClient`
always pins the handshake era, independent of `create_proxy`'s era
mirroring). Every test below that forwards a tool call through this
fixture (not just a listing) needs its front `Client` pinned to
`mode="legacy"` too, for either or both of two reasons:
- The test's subject is itself a handshake-only feature (roots / sampling
/ elicitation push, logging, progress): the modern era has no
back-channel for server-initiated requests at all, so these forwarding
paths cannot exist there.
- Even for subjects that work on both eras, a modern front's request
`_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
legacy-backend path forwards verbatim onto this legacy-locked backend
session, which the backend server then rejects as a protocol
violation.
mirroring). Tests below that exercise a handshake-only feature (roots /
sampling / elicitation push, logging, progress) pin their front `Client`
to `mode="legacy"` too: the modern era has no back-channel for
server-initiated requests at all, so these forwarding paths cannot exist
there.
"""
return create_proxy(ProxyClient(fastmcp_server))

View file

@ -0,0 +1,191 @@
"""Request `_meta` ownership at the proxy's backend connection boundary.
Protocol version, client identity, and client capabilities describe one
negotiated MCP connection. The proxy must never copy them from its frontend
connection onto its backend connection: a modern backend session stamps its
own values, and a handshake-era backend must not receive them at all.
Progress, tracing, task, and application metadata pass through untouched.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import mcp_types
import pytest
from mcp.client.extension import ClientExtension
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from fastmcp import Client, Context, FastMCP
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
FRONT_EXTENSION_ID = "example.com/frontend"
FRONT_INFO = mcp_types.Implementation(name="frontend-client", version="1.0")
BACKEND_INFO = mcp_types.Implementation(name="proxy-backend", version="1.0")
RESERVED_META_KEYS = {
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
}
@dataclass
class _RecordedRequest:
protocol_version: str
meta: dict[str, Any]
class _FrontendExtension(ClientExtension):
identifier = FRONT_EXTENSION_ID
def settings(self) -> dict[str, Any]:
return {"frontend": True}
def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP:
backend = FastMCP("metadata-backend")
def record(operation: str, ctx: Context) -> None:
request_context = ctx.request_context
assert request_context is not None
seen[operation] = _RecordedRequest(
protocol_version=request_context.protocol_version,
meta=dict(request_context.meta or {}),
)
@backend.tool
def inspect_tool(ctx: Context) -> str:
record("tool", ctx)
return "ok"
@backend.resource("data://metadata")
def inspect_resource(ctx: Context) -> str:
record("resource", ctx)
return "ok"
@backend.resource("data://items/{item_id}")
def inspect_template(item_id: str, ctx: Context) -> str:
record("template", ctx)
return "ok"
@backend.prompt
def inspect_prompt(ctx: Context) -> str:
record("prompt", ctx)
return "ok"
return backend
def _proxy(
backend: FastMCP, *, backend_mode: str, client_class: type[Client]
) -> FastMCPProxy:
return FastMCPProxy(
client_factory=lambda: client_class(
backend,
mode=backend_mode,
client_info=BACKEND_INFO,
)
)
def _assert_backend_connection_meta(record: _RecordedRequest, modern: bool) -> None:
"""The backend request carries the backend connection's own envelope.
On a handshake-era backend the reserved keys are absent. On a modern
backend they hold the backend session's negotiated version and the proxy
client's identity and capabilities — never the frontend client's.
"""
meta = record.meta
if not modern:
assert RESERVED_META_KEYS.isdisjoint(meta)
return
assert meta[mcp_types.PROTOCOL_VERSION_META_KEY] == record.protocol_version
assert meta[mcp_types.CLIENT_INFO_META_KEY] == BACKEND_INFO.model_dump(
by_alias=True, mode="json", exclude_none=True
)
capabilities = meta[mcp_types.CLIENT_CAPABILITIES_META_KEY]
assert FRONT_EXTENSION_ID not in capabilities.get("extensions", {})
# Every allowed ClientFactoryT shape must be hop-safe, not just ProxyClient:
# a plain Client backend runs the SDK's stock ClientSession rather than the
# proxy's session class, so it exercises the copy-site sanitization alone.
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
@pytest.mark.parametrize(
("front_mode", "backend_mode", "backend_is_modern"),
[
("auto", "auto", True),
("auto", "legacy", False),
("legacy", "auto", True),
("legacy", "legacy", False),
],
)
async def test_forwarded_tool_meta_stays_hop_safe(
front_mode: str,
backend_mode: str,
backend_is_modern: bool,
client_class: type[Client],
):
seen: dict[str, _RecordedRequest] = {}
proxy = _proxy(
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
)
async with Client(
proxy,
mode=front_mode,
client_info=FRONT_INFO,
extensions=[_FrontendExtension()],
) as client:
await client.call_tool(
"inspect_tool",
meta={
"progressToken": "front-progress",
"example.com/vendor": {"request": "kept"},
},
)
record = seen["tool"]
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
assert isinstance(record.meta["progressToken"], str | int)
assert record.meta["example.com/vendor"] == {"request": "kept"}
_assert_backend_connection_meta(record, backend_is_modern)
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
@pytest.mark.parametrize(
("backend_mode", "backend_is_modern"),
[("auto", True), ("legacy", False)],
)
@pytest.mark.parametrize("operation", ["resource", "template", "prompt"])
async def test_non_tool_requests_forward_hop_safe_metadata(
operation: str,
backend_mode: str,
backend_is_modern: bool,
client_class: type[Client],
):
seen: dict[str, _RecordedRequest] = {}
proxy = _proxy(
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
)
meta = {"example.com/vendor": {"operation": operation}}
async with Client(
proxy,
mode="auto",
client_info=FRONT_INFO,
extensions=[_FrontendExtension()],
) as client:
if operation == "resource":
await client.read_resource("data://metadata", meta=meta)
elif operation == "template":
await client.read_resource("data://items/42", meta=meta)
else:
await client.get_prompt("inspect_prompt", meta=meta)
record = seen[operation]
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
assert record.meta["example.com/vendor"] == {"operation": operation}
_assert_backend_connection_meta(record, backend_is_modern)

View file

@ -172,13 +172,7 @@ async def proxy_server(fastmcp_server):
raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is
instead of building one through the era-mirroring factory so this
backend stays pinned to `ProxyClient`'s own default of `mode="legacy"`
regardless of what era the front client negotiates. A test that actually
forwards a tool *call* through this fixture (not just a listing) needs
its own front `Client` pinned to `mode="legacy"` too: otherwise a modern
front's request `_meta` carries the reserved modern-envelope keys, which
`ProxyTool.run`'s legacy-backend path forwards verbatim onto this
legacy-locked backend session, and the backend server rejects it as a
protocol violation.
regardless of what era the front client negotiates.
"""
return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server)))
@ -1250,11 +1244,7 @@ class TestProxyOutputSchemaEnforcement:
# This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))`
# directly rather than through `create_proxy`'s era-mirroring factory, so it
# stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless
# of the front era (see the `proxy_server` fixture docstring above for the
# full explanation). Pin the end client to match: a modern front's request
# `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
# legacy-backend path forwards verbatim, and this legacy-locked backend
# session rejects them as a protocol violation.
# of the front era (see the `proxy_server` fixture docstring above).
client = Client(server, mode="legacy")
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession
@ -1428,10 +1418,7 @@ class TestProxyForwardingAppliesToEveryBackendClient:
# era. A multi-server config instead mounts a router with a
# StatefulProxyClient per configured server leg — an already-constructed
# ProxyClient subclass, same as the `proxy_server` fixture above, pinned
# to `mode="legacy"` regardless of the front. Callers with that backend
# shape must pin the end client to legacy too, for the reason explained
# there (a modern front's request `_meta` gets forwarded verbatim onto a
# legacy-locked backend session and rejected as a protocol violation).
# to `mode="legacy"` regardless of the front.
client = Client(server, mode=mode)
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession

View file

@ -62,12 +62,9 @@ async def stateful_proxy_server(fastmcp_server: FastMCP):
# `mode="legacy"` default for a directly-constructed instance (see
# `TestProxyClientEraDefault` in test_proxy_client.py) — this backend isn't
# built through `create_proxy`'s era-mirroring factory, so it stays pinned
# regardless of the front era. Every test below that forwards a real tool
# call through this fixture pins its front `Client` to `mode="legacy"` too:
# otherwise a modern front's request `_meta` carries reserved
# modern-envelope keys that `ProxyTool.run`'s legacy-backend path forwards
# verbatim, and this legacy-locked backend session rejects them as a
# protocol violation.
# regardless of the front era. Tests of handshake-only forwarding pin their
# front `Client` to `mode="legacy"` too: those server-initiated
# interactions do not exist on modern connections.
client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server))
return FastMCPProxy(client_factory=client.new_stateful)

View file

@ -0,0 +1,33 @@
"""Compatibility tests for legacy-httpx exceptions raised by user code."""
import pytest
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError, ToolError
httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
async def test_legacy_httpx_rate_limit_remains_actionable() -> None:
server = FastMCP("Legacy httpx errors", mask_error_details=True)
@server.tool
def rate_limited() -> None:
request = httpx.Request("GET", "https://example.com")
response = httpx.Response(429, request=request)
raise httpx.HTTPStatusError("rate limited", request=request, response=response)
with pytest.raises(ToolError, match="Rate limited by upstream API"):
await server.call_tool("rate_limited", {})
async def test_legacy_httpx_resource_timeout_remains_actionable() -> None:
server = FastMCP("Legacy httpx errors", mask_error_details=True)
@server.resource("resource://timed-out")
def timed_out() -> str:
request = httpx.Request("GET", "https://example.com")
raise httpx.ReadTimeout("timed out", request=request)
with pytest.raises(ResourceError, match="Upstream request timed out"):
await server.read_resource("resource://timed-out")

View file

@ -6,11 +6,9 @@ masks clean-install regressions: an accidental ``import httpx`` (directly or
via a third-party integration such as authlib's httpx client) passes CI but
breaks any install without those extras.
This test simulates the clean install by running a subprocess that blocks
legacy httpx imports at the meta-path level, then imports the modules that
have historically regressed. The defensive user-compat shim in
``fastmcp.server.server`` catches ImportError by design and must keep working
when httpx is absent.
These tests simulate a clean install by blocking legacy httpx imports at the
meta-path level and verify that ordinary server startup leaves both legacy
packages unloaded.
"""
import subprocess
@ -45,6 +43,28 @@ _BLOCKER_SCRIPT = textwrap.dedent(
"""
)
_STARTUP_SCRIPT = textwrap.dedent(
"""
import sys
from fastmcp import FastMCP
server = FastMCP("Legacy httpx import guard")
app = server.http_app(transport="http", stateless_http=True)
assert app is not None
loaded = [
name
for name in sys.modules
if name == "httpx"
or name.startswith("httpx.")
or name == "httpcore"
or name.startswith("httpcore.")
]
assert not loaded, loaded
"""
)
@pytest.mark.subprocess_heavy
def test_fastmcp_imports_without_legacy_httpx():
@ -58,3 +78,14 @@ def test_fastmcp_imports_without_legacy_httpx():
f"Import failed with legacy httpx blocked:\n{result.stderr}"
)
assert "OK" in result.stdout
@pytest.mark.subprocess_heavy
def test_default_http_app_does_not_load_legacy_httpx():
result = subprocess.run(
[sys.executable, "-c", _STARTUP_SCRIPT],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr