diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx index 37ff33f62..1cd484680 100644 --- a/docs/getting-started/upgrading/from-fastmcp-3.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx @@ -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: diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py index 8739aa7a3..bed40f670 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py @@ -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__( diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/README.md b/fastmcp_slim/fastmcp/server/providers/openapi/README.md index 8c5e890c4..8b55d8753 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/README.md +++ b/fastmcp_slim/fastmcp/server/providers/openapi/README.md @@ -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 \ No newline at end of file +- Project documentation on OpenAPI integration patterns diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/components.py b/fastmcp_slim/fastmcp/server/providers/openapi/components.py index 220b221a5..61af9ceaf 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/components.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/components.py @@ -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,6 @@ from fastmcp.resources import ( ) from fastmcp.server.dependencies import get_http_headers from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.exceptions import ( - is_http_status_error, - 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,30 +58,6 @@ logger = get_logger(__name__) _DEFAULT_MIME_TYPE = "application/json" -def _convert_httpx_error(exc: Exception) -> ValueError | None: - if is_http_status_error(exc): - status_error = cast("httpx2.HTTPStatusError", 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}" - return ValueError(error_message) - - if is_timeout_error(exc): - return ValueError(f"HTTP request timed out ({type(exc).__name__})") - - if is_request_error(exc): - return ValueError(f"Request error ({type(exc).__name__}): {exc!s}") - - return None - - def _extract_mime_type_from_route(route: HTTPRoute) -> str: """Extract the primary MIME type from an HTTPRoute's response definitions. @@ -200,12 +171,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)), @@ -262,11 +229,23 @@ class OpenAPITool(Tool): except json.JSONDecodeError: return ToolResult(content=response.text) - except Exception as e: - converted_error = _convert_httpx_error(e) - if converted_error is None: - raise - raise converted_error from e + except httpx2.HTTPStatusError as e: + error_message = ( + f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" + ) + try: + error_data = e.response.json() + error_message += f" - {error_data}" + except (json.JSONDecodeError, ValueError): + if e.response.text: + error_message += f" - {e.response.text}" + raise ValueError(error_message) from e + + except httpx2.TimeoutException as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + + except httpx2.RequestError as e: + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e class OpenAPIResource(Resource): @@ -308,8 +287,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)), @@ -353,11 +331,23 @@ class OpenAPIResource(Resource): ] ) - except Exception as e: - converted_error = _convert_httpx_error(e) - if converted_error is None: - raise - raise converted_error from e + except httpx2.HTTPStatusError as e: + error_message = ( + f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" + ) + try: + error_data = e.response.json() + error_message += f" - {error_data}" + except (json.JSONDecodeError, ValueError): + if e.response.text: + error_message += f" - {e.response.text}" + raise ValueError(error_message) from e + + except httpx2.TimeoutException as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + + except httpx2.RequestError as e: + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str: diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py index 4048479a0..c16f14034 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index 27602cb8c..8f8ccccbc 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -88,7 +88,6 @@ 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 is_http_status_error, 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 @@ -1541,15 +1540,12 @@ class FastMCP( logger.exception(f"Error calling tool {name!r}") # Handle actionable errors that should reach the LLM # even when masking is enabled - if is_http_status_error(e): - if ( - cast("httpx2.HTTPStatusError", e).response.status_code - == 429 - ): + if isinstance(e, httpx2.HTTPStatusError): + if e.response.status_code == 429: raise ToolError( "Rate limited by upstream API, please retry later" ) from e - if is_timeout_error(e): + if isinstance(e, httpx2.TimeoutException): raise ToolError( "Upstream request timed out, please retry" ) from e @@ -1644,15 +1640,12 @@ class FastMCP( except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM - if is_http_status_error(e): - if ( - cast("httpx2.HTTPStatusError", e).response.status_code - == 429 - ): + if isinstance(e, httpx2.HTTPStatusError): + if e.response.status_code == 429: raise ResourceError( "Rate limited by upstream API, please retry later" ) from e - if is_timeout_error(e): + if isinstance(e, httpx2.TimeoutException): raise ResourceError( "Upstream request timed out, please retry" ) from e @@ -1707,15 +1700,12 @@ class FastMCP( except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM - if is_http_status_error(e): - if ( - cast("httpx2.HTTPStatusError", e).response.status_code - == 429 - ): + if isinstance(e, httpx2.HTTPStatusError): + if e.response.status_code == 429: raise ResourceError( "Rate limited by upstream API, please retry later" ) from e - if is_timeout_error(e): + if isinstance(e, httpx2.TimeoutException): raise ResourceError( "Upstream request timed out, please retry" ) from e @@ -2407,10 +2397,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 diff --git a/fastmcp_slim/fastmcp/utilities/exceptions.py b/fastmcp_slim/fastmcp/utilities/exceptions.py index e08200aa8..20241bc2f 100644 --- a/fastmcp_slim/fastmcp/utilities/exceptions.py +++ b/fastmcp_slim/fastmcp/utilities/exceptions.py @@ -8,42 +8,6 @@ from mcp import MCPError import fastmcp -def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool: - """Check a legacy-httpx exception without importing httpx on normal paths.""" - if not any( - cls.__module__.partition(".")[0] == "httpx" for cls in type(exc).__mro__ - ): - return False - - try: - import httpx - except ImportError: - return False - - return isinstance(exc, getattr(httpx, exception_type)) - - -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" - ) - - -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" - ) - - def iter_exc(group: BaseExceptionGroup): for exc in group.exceptions: if isinstance(exc, BaseExceptionGroup): diff --git a/fastmcp_slim/fastmcp/utilities/openapi/README.md b/fastmcp_slim/fastmcp/utilities/openapi/README.md index 2f2a5f45f..c5e478e19 100644 --- a/fastmcp_slim/fastmcp/utilities/openapi/README.md +++ b/fastmcp_slim/fastmcp/utilities/openapi/README.md @@ -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 \ No newline at end of file +- `urllib.parse` - URL building and manipulation diff --git a/tests/server/providers/openapi/test_legacy_client_compat.py b/tests/server/providers/openapi/test_legacy_client_compat.py index 85434eac5..fed60e1bc 100644 --- a/tests/server/providers/openapi/test_legacy_client_compat.py +++ b/tests/server/providers/openapi/test_legacy_client_compat.py @@ -1,20 +1,8 @@ -"""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.exceptions import ToolError -from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp import Client, FastMCP, FastMCPDeprecationWarning httpx = pytest.importorskip("httpx", reason="legacy httpx not installed") @@ -26,7 +14,6 @@ SPEC = { "/items": { "get": { "operationId": "list_items", - "summary": "List items", "responses": { "200": { "description": "Items", @@ -46,136 +33,27 @@ 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"]} - -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 error classifier - must recognize it so the error carries the formatted status + body rather - than a generic failure. - """ - - def handler(request: "httpx.Request") -> "httpx.Response": - return httpx.Response(500, json={"detail": "boom"}) - - async with _legacy_client(handler) as client: - async with Client(_server(client)) as mcp_client: - with pytest.raises(ToolError, match="HTTP error 500") as excinfo: - await mcp_client.call_tool("list_items", {}) - assert "boom" in str(excinfo.value) - - -async def test_tool_request_error_keeps_openapi_formatting_with_legacy_client(): - """A legacy client's transport error maps to the formatted request error.""" - - def handler(request: "httpx.Request") -> "httpx.Response": - raise httpx.ConnectError("connection refused") - - async with _legacy_client(handler) as client: - async with Client(_server(client)) as mcp_client: - with pytest.raises(ToolError, match="Request error"): - await mcp_client.call_tool("list_items", {}) - - -async def test_tool_timeout_keeps_openapi_formatting_with_legacy_client(): - """A legacy client's timeout maps to the formatted timeout error.""" - - class UserTimeout(httpx.ReadTimeout): - pass - - def handler(request: "httpx.Request") -> "httpx.Response": - raise UserTimeout("upstream timed out", request=request) - - async with _legacy_client(handler) as client: - async with Client(_server(client)) as mcp_client: - with pytest.raises(ToolError, match="HTTP request timed out"): - await mcp_client.call_tool("list_items", {}) - - -async def test_multipart_tool_call_with_legacy_client(): - """Multipart bodies must materialize and send through a legacy client too.""" - spec = { - "openapi": "3.0.0", - "info": {"title": "Upload API", "version": "1.0.0"}, - "servers": [{"url": "https://api.example.com"}], - "paths": { - "/upload": { - "post": { - "operationId": "upload_file", - "summary": "Upload a file", - "requestBody": { - "required": True, - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": {"file": {"type": "string"}}, - } - } - }, - }, - "responses": { - "200": { - "description": "Uploaded", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {"ok": {"type": "boolean"}}, - } - } - }, - } - }, - } - } - }, - } - received: dict[str, object] = {} - - def handler(request: "httpx.Request") -> "httpx.Response": - received["content_type"] = request.headers.get("content-type", "") - received["body"] = request.read() - return httpx.Response(200, json={"ok": True}) - - async with _legacy_client(handler) as client: - mcp = FastMCP("Legacy Multipart Server") - mcp.add_provider(OpenAPIProvider(openapi_spec=spec, client=client)) - async with Client(mcp) as mcp_client: - result = await mcp_client.call_tool("upload_file", {"file": "data"}) - assert result.structured_content == {"ok": True} - - content_type = received["content_type"] - assert isinstance(content_type, str) - assert "multipart/form-data" in content_type - body = received["body"] - assert isinstance(body, bytes) - assert b"data" in body + assert result.structured_content == {"items": ["a", "b"]} diff --git a/tests/server/test_legacy_httpx_errors.py b/tests/server/test_legacy_httpx_errors.py deleted file mode 100644 index c5652fc92..000000000 --- a/tests/server/test_legacy_httpx_errors.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Compatibility tests for legacy-httpx exceptions raised by user code.""" - -import pytest - -from fastmcp import FastMCP -from fastmcp.exceptions import 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", {}) diff --git a/tests/test_no_legacy_httpx.py b/tests/test_no_legacy_httpx.py index b8a676e6f..24ee01366 100644 --- a/tests/test_no_legacy_httpx.py +++ b/tests/test_no_legacy_httpx.py @@ -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