Preserve legacy httpx compatibility without importing it (#4766)

This commit is contained in:
Jeremiah Lowin 2026-08-05 18:09:16 -04:00 committed by GitHub
commit 875e8e18bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 257 additions and 249 deletions

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

@ -43,8 +43,7 @@ class AsyncOAuth2Client:
Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that
`OAuthProxy` uses. Subclasses of `OAuthProxy` that override `OAuthProxy` uses. Subclasses of `OAuthProxy` that override
`_create_upstream_oauth_client` may return any object with the same `_create_upstream_oauth_client` may return any object with the same
`fetch_token`/`refresh_token`/`client_secret`/`aclose` surface (including `fetch_token`/`refresh_token`/`client_secret`/`aclose` surface.
an authlib client, if legacy httpx is installed in their environment).
""" """
def __init__( def __init__(

View file

@ -53,7 +53,7 @@ The main server class orchestrates the stateless request building approach:
```python ```python
class FastMCPOpenAPI(FastMCP): 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 # 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas
self._routes = parse_openapi_to_http_routes(openapi_spec) 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 2. **RequestDirector Setup**: openapi-core Spec initialized for request building
3. **Component Creation**: Create components with RequestDirector reference 3. **Component Creation**: Create components with RequestDirector reference
4. **Request Building**: RequestDirector builds HTTP request from flat parameters 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 6. **Response Processing**: Return structured MCP response
## Key Features ## Key Features
@ -263,4 +263,4 @@ logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG)
- `/utilities/openapi_new/README.md` - Utility implementation details - `/utilities/openapi_new/README.md` - Utility implementation details
- `/server/openapi/README.md` - Legacy implementation reference - `/server/openapi/README.md` - Legacy implementation reference
- `/tests/server/openapi_new/` - Comprehensive test suite - `/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 json
import re import re
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any
import httpx2 import httpx2
from mcp_types import ToolAnnotations from mcp_types import ToolAnnotations
@ -18,11 +18,7 @@ from fastmcp.resources import (
) )
from fastmcp.server.dependencies import get_http_headers from fastmcp.server.dependencies import get_http_headers
from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.exceptions import ( from fastmcp.utilities.exceptions import is_request_error, is_timeout_error
HTTP_STATUS_ERRORS,
REQUEST_ERRORS,
TIMEOUT_ERRORS,
)
from fastmcp.utilities.logging import get_logger from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector from fastmcp.utilities.openapi.director import RequestDirector
@ -63,6 +59,36 @@ logger = get_logger(__name__)
_DEFAULT_MIME_TYPE = "application/json" _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: def _extract_mime_type_from_route(route: HTTPRoute) -> str:
"""Extract the primary MIME type from an HTTPRoute's response definitions. """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" base_url = str(self._client.base_url) or "http://localhost"
directed_request = self._director.build(self._route, arguments, base_url) directed_request = self._director.build(self._route, arguments, base_url)
# Rebuild through the user's client so the request object comes # Rebuild through the configured client so its default headers are
# from whichever httpx library the client belongs to (a legacy # merged with the directed headers taking priority.
# 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( request = self._client.build_request(
method=directed_request.method, method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)), 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)}" f"run - sending request; headers: {_redact_headers(request.headers)}"
) )
response = await self._client.send(request) response = await _send_request(self._client, request)
response.raise_for_status() _raise_for_status(response)
# Try to parse as JSON first # Try to parse as JSON first
try: try:
@ -238,25 +260,11 @@ class OpenAPITool(Tool):
except json.JSONDecodeError: except json.JSONDecodeError:
return ToolResult(content=response.text) return ToolResult(content=response.text)
except HTTP_STATUS_ERRORS as e: except httpx2.TimeoutException as exc:
status_error = cast("httpx2.HTTPStatusError", e) raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
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 TIMEOUT_ERRORS as e: except httpx2.RequestError as exc:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResource(Resource): class OpenAPIResource(Resource):
@ -298,8 +306,7 @@ class OpenAPIResource(Resource):
directed_request = self._director.build( directed_request = self._director.build(
self._route, self._arguments, base_url self._route, self._arguments, base_url
) )
# Primitive values only: a legacy httpx.AsyncClient cannot accept # Build through the configured client so its defaults are applied.
# httpx2 URL/QueryParams/Headers objects.
request = self._client.build_request( request = self._client.build_request(
method=directed_request.method, method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)), url=str(directed_request.url.copy_with(query=None)),
@ -314,8 +321,8 @@ class OpenAPIResource(Resource):
if mcp_headers: if mcp_headers:
request.headers.update(mcp_headers) request.headers.update(mcp_headers)
response = await self._client.send(request) response = await _send_request(self._client, request)
response.raise_for_status() _raise_for_status(response)
content_type = response.headers.get("content-type", "").lower() content_type = response.headers.get("content-type", "").lower()
@ -343,25 +350,11 @@ class OpenAPIResource(Resource):
] ]
) )
except HTTP_STATUS_ERRORS as e: except httpx2.TimeoutException as exc:
status_error = cast("httpx2.HTTPStatusError", e) raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
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 TIMEOUT_ERRORS as e: except httpx2.RequestError as exc:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str: def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str:

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import warnings
from collections import Counter from collections import Counter
from collections.abc import AsyncIterator, Sequence from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -10,6 +11,7 @@ from typing import Any, Literal, cast
import httpx2 import httpx2
from jsonschema_path import SchemaPath from jsonschema_path import SchemaPath
from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.prompts import Prompt from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.providers.base import Provider from fastmcp.server.providers.base import Provider
@ -48,6 +50,14 @@ logger = get_logger(__name__)
DEFAULT_TIMEOUT: float = 30.0 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): class OpenAPIProvider(Provider):
"""Provider that creates MCP components from an OpenAPI specification. """Provider that creates MCP components from an OpenAPI specification.
@ -84,10 +94,12 @@ class OpenAPIProvider(Provider):
Args: Args:
openapi_spec: OpenAPI schema as a dictionary 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 If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout. server URL from the OpenAPI spec with a 30-second timeout.
To customize timeout or other settings, pass your own client. 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_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization mcp_component_fn: Optional callable for component customization
@ -103,6 +115,14 @@ class OpenAPIProvider(Provider):
self._owns_client = client is None self._owns_client = client is None
if client is None: if client is None:
client = self._create_default_client(openapi_spec) 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._client = client
self._mcp_component_fn = mcp_component_fn self._mcp_component_fn = mcp_component_fn
self._validate_output = validate_output self._validate_output = validate_output

View file

@ -88,7 +88,7 @@ from fastmcp.tools.base import Tool, ToolResult
from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent, _coerce_version 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.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
@ -112,11 +112,6 @@ if TYPE_CHECKING:
logger = get_logger(__name__) 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( def _version_request_meta(
version: VersionSpec | None, version: VersionSpec | None,
@ -1546,15 +1541,11 @@ class FastMCP(
logger.exception(f"Error calling tool {name!r}") logger.exception(f"Error calling tool {name!r}")
# Handle actionable errors that should reach the LLM # Handle actionable errors that should reach the LLM
# even when masking is enabled # even when masking is enabled
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): if get_http_status_code(e) == 429:
if ( raise ToolError(
cast("httpx2.HTTPStatusError", e).response.status_code "Rate limited by upstream API, please retry later"
== 429 ) from e
): if is_timeout_error(e):
raise ToolError(
"Rate limited by upstream API, please retry later"
) from e
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ToolError( raise ToolError(
"Upstream request timed out, please retry" "Upstream request timed out, please retry"
) from e ) from e
@ -1649,15 +1640,11 @@ class FastMCP(
except Exception as e: except Exception as e:
logger.exception(f"Error reading resource {uri!r}") logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM # Handle actionable errors that should reach the LLM
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): if get_http_status_code(e) == 429:
if ( raise ResourceError(
cast("httpx2.HTTPStatusError", e).response.status_code "Rate limited by upstream API, please retry later"
== 429 ) from e
): if is_timeout_error(e):
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError( raise ResourceError(
"Upstream request timed out, please retry" "Upstream request timed out, please retry"
) from e ) from e
@ -1712,15 +1699,11 @@ class FastMCP(
except Exception as e: except Exception as e:
logger.exception(f"Error reading resource {uri!r}") logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM # Handle actionable errors that should reach the LLM
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): if get_http_status_code(e) == 429:
if ( raise ResourceError(
cast("httpx2.HTTPStatusError", e).response.status_code "Rate limited by upstream API, please retry later"
== 429 ) from e
): if is_timeout_error(e):
raise ResourceError(
"Rate limited by upstream API, please retry later"
) from e
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError( raise ResourceError(
"Upstream request timed out, please retry" "Upstream request timed out, please retry"
) from e ) from e
@ -2412,10 +2395,10 @@ class FastMCP(
Args: Args:
openapi_spec: OpenAPI schema as a dictionary openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx2 AsyncClient for making HTTP requests. client: Optional httpx2 AsyncClient for making HTTP requests.
An httpx (v1) AsyncClient is also accepted and works via If not provided, a default client is created using the first
duck-typing. If not provided, a default client is created
using the first
server URL from the OpenAPI spec with a 30-second timeout. 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 name: Name for the MCP server
route_maps: Optional list of RouteMap objects defining route mappings route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping route_map_fn: Optional callable for advanced route type mapping

View file

@ -7,30 +7,42 @@ from mcp import MCPError
import fastmcp 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], ...] = ( def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool:
httpx2.HTTPStatusError, """Check a legacy-httpx exception without importing the legacy package."""
httpx.HTTPStatusError, 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): def iter_exc(group: BaseExceptionGroup):

View file

@ -47,7 +47,7 @@ OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDire
### Request Processing ### 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 1. **Tool Invocation**: FastMCP receives tool call with parameters
@ -103,14 +103,14 @@ All components use the same RequestDirector approach:
### Basic Server Setup ### Basic Server Setup
```python ```python
import httpx import httpx2
from fastmcp.server.openapi import FastMCPOpenAPI from fastmcp.server.openapi import FastMCPOpenAPI
# OpenAPI spec (can be loaded from file/URL) # OpenAPI spec (can be loaded from file/URL)
openapi_spec = {...} openapi_spec = {...}
# Create HTTP client # Create HTTP client
async with httpx.AsyncClient() as client: async with httpx2.AsyncClient() as client:
# Create server with stateless request building # Create server with stateless request building
server = FastMCPOpenAPI( server = FastMCPOpenAPI(
openapi_spec=openapi_spec, openapi_spec=openapi_spec,
@ -134,8 +134,8 @@ director = RequestDirector(spec)
# Build HTTP request # Build HTTP request
request = director.build(route, flat_arguments, base_url) request = director.build(route, flat_arguments, base_url)
# Execute with httpx # Execute with httpx2
async with httpx.AsyncClient() as client: async with httpx2.AsyncClient() as client:
response = await client.send(request) response = await client.send(request)
``` ```
@ -206,6 +206,6 @@ Tests are located in `/tests/server/openapi/`:
## Dependencies ## Dependencies
- `openapi-core` - OpenAPI specification processing and validation - `openapi-core` - OpenAPI specification processing and validation
- `httpx` - HTTP client library - `httpx2` - HTTP client library
- `pydantic` - Data validation and serialization - `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.json.return_value = {"code": 404, "message": "User not found"}
mock_response.text = json.dumps({"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) mock_client.send = AsyncMock(return_value=mock_response)
server = create_openapi_server( server = create_openapi_server(

View file

@ -1,20 +1,9 @@
"""Legacy-httpx client compatibility for the OpenAPI integration. """Deprecation bridge for legacy-httpx OpenAPI clients."""
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 import pytest
from fastmcp import FastMCP from fastmcp import Client, FastMCP, FastMCPDeprecationWarning
from fastmcp.client import Client
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
from fastmcp.server.providers.openapi import OpenAPIProvider
httpx = pytest.importorskip("httpx", reason="legacy httpx not installed") httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
@ -26,7 +15,6 @@ SPEC = {
"/items": { "/items": {
"get": { "get": {
"operationId": "list_items", "operationId": "list_items",
"summary": "List items",
"responses": { "responses": {
"200": { "200": {
"description": "Items", "description": "Items",
@ -46,121 +34,77 @@ SPEC = {
} }
}, },
} }
}, }
}, },
} }
def _legacy_client(handler) -> "httpx.AsyncClient": async def test_legacy_client_warns_and_remains_usable() -> None:
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": def handler(request: "httpx.Request") -> "httpx.Response":
assert isinstance(request, httpx.Request)
return httpx.Response(200, json={"items": ["a", "b"]}) return httpx.Response(200, json={"items": ["a", "b"]})
async with _legacy_client(handler) as client: transport = httpx.MockTransport(handler)
async with Client(_server(client)) as mcp_client: 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", {}) 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(): async def test_legacy_client_preserves_http_error_details() -> None:
"""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": 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: transport = httpx.MockTransport(handler)
async with Client(_server(client)) as mcp_client: async with httpx.AsyncClient(
with pytest.raises(ToolError, match="HTTP error 500") as excinfo: transport=transport,
await mcp_client.call_tool("list_items", {}) base_url="https://api.example.com",
assert "boom" in str(excinfo.value) ) as client:
with pytest.warns(FastMCPDeprecationWarning):
server = FastMCP.from_openapi(SPEC, client=client)
async with Client(server) as mcp_client:
async def test_tool_request_error_keeps_openapi_formatting_with_legacy_client(): with pytest.raises(ToolError, match="HTTP error 404") as exc_info:
"""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", {}) 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": def handler(request: "httpx.Request") -> "httpx.Response":
received["content_type"] = request.headers.get("content-type", "") if error_kind == "timeout":
received["body"] = request.read() raise httpx.ReadTimeout("transport failed", request=request)
return httpx.Response(200, json={"ok": True}) raise httpx.ConnectError("transport failed", request=request)
async with _legacy_client(handler) as client: transport = httpx.MockTransport(handler)
mcp = FastMCP("Legacy Multipart Server") async with httpx.AsyncClient(
mcp.add_provider(OpenAPIProvider(openapi_spec=spec, client=client)) transport=transport,
async with Client(mcp) as mcp_client: base_url="https://api.example.com",
result = await mcp_client.call_tool("upload_file", {"file": "data"}) ) as client:
assert result.structured_content == {"ok": True} with pytest.warns(FastMCPDeprecationWarning):
server = FastMCP.from_openapi(SPEC, client=client)
content_type = received["content_type"] async with Client(server) as mcp_client:
assert isinstance(content_type, str) with pytest.raises(ToolError) as exc_info:
assert "multipart/form-data" in content_type await mcp_client.call_tool("list_items", {})
body = received["body"]
assert isinstance(body, bytes) assert message in str(exc_info.value)
assert b"data" in body

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 via a third-party integration such as authlib's httpx client) passes CI but
breaks any install without those extras. breaks any install without those extras.
This test simulates the clean install by running a subprocess that blocks These tests simulate a clean install by blocking legacy httpx imports at the
legacy httpx imports at the meta-path level, then imports the modules that meta-path level and verify that ordinary server startup leaves both legacy
have historically regressed. The defensive user-compat shim in packages unloaded.
``fastmcp.server.server`` catches ImportError by design and must keep working
when httpx is absent.
""" """
import subprocess 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 @pytest.mark.subprocess_heavy
def test_fastmcp_imports_without_legacy_httpx(): 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}" f"Import failed with legacy httpx blocked:\n{result.stderr}"
) )
assert "OK" in result.stdout 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