From 959daf232157a5e4dd76e0b82326029c7513cb85 Mon Sep 17 00:00:00 2001 From: Jake Kaplan <40362401+jakekaplan@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:46:10 -0400 Subject: [PATCH 1/4] Sanitize forwarded request metadata where the proxy copies it (#4770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Separate proxy protocol policy from client construction 🤖 Generated with OpenAI Codex * Strip connection-owned request metadata at the proxy backend boundary Co-Authored-By: Claude Fable 5 * Sanitize forwarded request metadata where the proxy copies it Co-Authored-By: Claude Fable 5 * Forward hop-safe request metadata for proxied resources, templates, and prompts Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/servers/providers/proxy.mdx | 4 + .../fastmcp/server/providers/proxy.py | 75 +++++-- .../providers/proxy/test_proxy_client.py | 18 +- .../proxy/test_proxy_request_meta.py | 191 ++++++++++++++++++ .../providers/proxy/test_proxy_server.py | 19 +- .../proxy/test_stateful_proxy_client.py | 9 +- 6 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 tests/server/providers/proxy/test_proxy_request_meta.py diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx index 2be18cefd..df09b1c80 100644 --- a/docs/servers/providers/proxy.mdx +++ b/docs/servers/providers/proxy.mdx @@ -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 diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index 9b1cd4a69..a304eacc3 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -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 diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py index caa4b9075..53b37129a 100644 --- a/tests/server/providers/proxy/test_proxy_client.py +++ b/tests/server/providers/proxy/test_proxy_client.py @@ -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)) diff --git a/tests/server/providers/proxy/test_proxy_request_meta.py b/tests/server/providers/proxy/test_proxy_request_meta.py new file mode 100644 index 000000000..4776eedb5 --- /dev/null +++ b/tests/server/providers/proxy/test_proxy_request_meta.py @@ -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) diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index 8971b5ba0..fe489d56f 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -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 diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py index 1b64afd2d..0bf832440 100644 --- a/tests/server/providers/proxy/test_stateful_proxy_client.py +++ b/tests/server/providers/proxy/test_stateful_proxy_client.py @@ -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) From 875e8e18bd41a81a6614183d7b982a4c79bb1be2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:09:16 -0400 Subject: [PATCH 2/4] Preserve legacy httpx compatibility without importing it (#4766) --- .../upgrading/from-fastmcp-3.mdx | 2 +- .../server/auth/oauth_proxy/upstream.py | 3 +- .../server/providers/openapi/README.md | 6 +- .../server/providers/openapi/components.py | 101 +++++------ .../server/providers/openapi/provider.py | 22 ++- fastmcp_slim/fastmcp/server/server.py | 55 ++---- fastmcp_slim/fastmcp/utilities/exceptions.py | 52 +++--- .../fastmcp/utilities/openapi/README.md | 14 +- .../providers/openapi/test_comprehensive.py | 7 - .../openapi/test_legacy_client_compat.py | 168 ++++++------------ tests/server/test_legacy_httpx_errors.py | 33 ++++ tests/test_no_legacy_httpx.py | 41 ++++- 12 files changed, 256 insertions(+), 248 deletions(-) create mode 100644 tests/server/test_legacy_httpx_errors.py 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 3cb36abcf..a6b88538e 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,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: 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 a25a78712..de3cba77b 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -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 diff --git a/fastmcp_slim/fastmcp/utilities/exceptions.py b/fastmcp_slim/fastmcp/utilities/exceptions.py index f9166a2b6..97cea8f29 100644 --- a/fastmcp_slim/fastmcp/utilities/exceptions.py +++ b/fastmcp_slim/fastmcp/utilities/exceptions.py @@ -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): 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_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py index d5b8ceffd..ace2a03a5 100644 --- a/tests/server/providers/openapi/test_comprehensive.py +++ b/tests/server/providers/openapi/test_comprehensive.py @@ -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( diff --git a/tests/server/providers/openapi/test_legacy_client_compat.py b/tests/server/providers/openapi/test_legacy_client_compat.py index 707be823d..3b0361ad8 100644 --- a/tests/server/providers/openapi/test_legacy_client_compat.py +++ b/tests/server/providers/openapi/test_legacy_client_compat.py @@ -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) diff --git a/tests/server/test_legacy_httpx_errors.py b/tests/server/test_legacy_httpx_errors.py new file mode 100644 index 000000000..38f9434a0 --- /dev/null +++ b/tests/server/test_legacy_httpx_errors.py @@ -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") diff --git a/tests/test_no_legacy_httpx.py b/tests/test_no_legacy_httpx.py index 5cd439f87..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 @@ -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 From 2bee9aeb58c83f1ce7dc2f66240d1ef60b1b2c48 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:34:11 -0400 Subject: [PATCH 3/4] Clarify review of closed contributor PRs (#4780) --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1a2cbddb0..79b040531 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) From c8b88b3a3763c3ce76868b1e92fa80f417084f46 Mon Sep 17 00:00:00 2001 From: Yonatan Date: Thu, 6 Aug 2026 16:35:49 +0300 Subject: [PATCH 4/4] fix(context): move elicit overload docs inside the stubs so mypy sees the chain (#4774) --- fastmcp_slim/fastmcp/server/context.py | 39 ++++++++++---------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py index 3373e09da..0e3ccf9d5 100644 --- a/fastmcp_slim/fastmcp/server/context.py +++ b/fastmcp_slim/fastmcp/server/context.py @@ -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,