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:

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

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

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

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

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

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