mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 13:04:18 +02:00
Merge branch 'main' into encrypt-task-snapshots-4747
This commit is contained in:
commit
a09569bcab
20 changed files with 537 additions and 326 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -240,6 +240,10 @@ proxy = create_proxy(
|
|||
|
||||
A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one.
|
||||
|
||||
### Request Metadata
|
||||
|
||||
Request `_meta` follows the same connection boundary. Progress tokens, tracing, task state, and application or vendor metadata pass through the proxy to the backend. The connection-owned keys — protocol version, client identity, and client capabilities — never copy from the frontend connection: a modern backend session stamps its own negotiated values, and a handshake-era backend receives none. This holds even when the two connections negotiate different eras, such as a modern client reaching a handshake-only backend through an explicit `mode`.
|
||||
|
||||
## Configuration-Based Proxies
|
||||
|
||||
<VersionBadge version="2.4.0" />
|
||||
|
|
|
|||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ The main server class orchestrates the stateless request building approach:
|
|||
|
||||
```python
|
||||
class FastMCPOpenAPI(FastMCP):
|
||||
def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs):
|
||||
def __init__(self, openapi_spec: dict, client: httpx2.AsyncClient, **kwargs):
|
||||
# 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas
|
||||
self._routes = parse_openapi_to_http_routes(openapi_spec)
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ OpenAPI Spec → HTTPRoute with Pre-calculated Fields → RequestDirector → HT
|
|||
2. **RequestDirector Setup**: openapi-core Spec initialized for request building
|
||||
3. **Component Creation**: Create components with RequestDirector reference
|
||||
4. **Request Building**: RequestDirector builds HTTP request from flat parameters
|
||||
5. **Request Execution**: Execute request with httpx client
|
||||
5. **Request Execution**: Execute request with httpx2 client
|
||||
6. **Response Processing**: Return structured MCP response
|
||||
|
||||
## Key Features
|
||||
|
|
@ -263,4 +263,4 @@ logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG)
|
|||
- `/utilities/openapi_new/README.md` - Utility implementation details
|
||||
- `/server/openapi/README.md` - Legacy implementation reference
|
||||
- `/tests/server/openapi_new/` - Comprehensive test suite
|
||||
- Project documentation on OpenAPI integration patterns
|
||||
- Project documentation on OpenAPI integration patterns
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDire
|
|||
### Request Processing
|
||||
|
||||
```
|
||||
MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response → Structured Output
|
||||
MCP Tool Call → RequestDirector.build() → httpx2.Request → HTTP Response → Structured Output
|
||||
```
|
||||
|
||||
1. **Tool Invocation**: FastMCP receives tool call with parameters
|
||||
|
|
@ -103,14 +103,14 @@ All components use the same RequestDirector approach:
|
|||
### Basic Server Setup
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import httpx2
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI
|
||||
|
||||
# OpenAPI spec (can be loaded from file/URL)
|
||||
openapi_spec = {...}
|
||||
|
||||
# Create HTTP client
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx2.AsyncClient() as client:
|
||||
# Create server with stateless request building
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=openapi_spec,
|
||||
|
|
@ -134,8 +134,8 @@ director = RequestDirector(spec)
|
|||
# Build HTTP request
|
||||
request = director.build(route, flat_arguments, base_url)
|
||||
|
||||
# Execute with httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Execute with httpx2
|
||||
async with httpx2.AsyncClient() as client:
|
||||
response = await client.send(request)
|
||||
```
|
||||
|
||||
|
|
@ -206,6 +206,6 @@ Tests are located in `/tests/server/openapi/`:
|
|||
## Dependencies
|
||||
|
||||
- `openapi-core` - OpenAPI specification processing and validation
|
||||
- `httpx` - HTTP client library
|
||||
- `httpx2` - HTTP client library
|
||||
- `pydantic` - Data validation and serialization
|
||||
- `urllib.parse` - URL building and manipulation
|
||||
- `urllib.parse` - URL building and manipulation
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
191
tests/server/providers/proxy/test_proxy_request_meta.py
Normal file
191
tests/server/providers/proxy/test_proxy_request_meta.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Request `_meta` ownership at the proxy's backend connection boundary.
|
||||
|
||||
Protocol version, client identity, and client capabilities describe one
|
||||
negotiated MCP connection. The proxy must never copy them from its frontend
|
||||
connection onto its backend connection: a modern backend session stamps its
|
||||
own values, and a handshake-era backend must not receive them at all.
|
||||
Progress, tracing, task, and application metadata pass through untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import mcp_types
|
||||
import pytest
|
||||
from mcp.client.extension import ClientExtension
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
from fastmcp import Client, Context, FastMCP
|
||||
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
|
||||
|
||||
FRONT_EXTENSION_ID = "example.com/frontend"
|
||||
FRONT_INFO = mcp_types.Implementation(name="frontend-client", version="1.0")
|
||||
BACKEND_INFO = mcp_types.Implementation(name="proxy-backend", version="1.0")
|
||||
RESERVED_META_KEYS = {
|
||||
mcp_types.PROTOCOL_VERSION_META_KEY,
|
||||
mcp_types.CLIENT_INFO_META_KEY,
|
||||
mcp_types.CLIENT_CAPABILITIES_META_KEY,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RecordedRequest:
|
||||
protocol_version: str
|
||||
meta: dict[str, Any]
|
||||
|
||||
|
||||
class _FrontendExtension(ClientExtension):
|
||||
identifier = FRONT_EXTENSION_ID
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
return {"frontend": True}
|
||||
|
||||
|
||||
def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP:
|
||||
backend = FastMCP("metadata-backend")
|
||||
|
||||
def record(operation: str, ctx: Context) -> None:
|
||||
request_context = ctx.request_context
|
||||
assert request_context is not None
|
||||
seen[operation] = _RecordedRequest(
|
||||
protocol_version=request_context.protocol_version,
|
||||
meta=dict(request_context.meta or {}),
|
||||
)
|
||||
|
||||
@backend.tool
|
||||
def inspect_tool(ctx: Context) -> str:
|
||||
record("tool", ctx)
|
||||
return "ok"
|
||||
|
||||
@backend.resource("data://metadata")
|
||||
def inspect_resource(ctx: Context) -> str:
|
||||
record("resource", ctx)
|
||||
return "ok"
|
||||
|
||||
@backend.resource("data://items/{item_id}")
|
||||
def inspect_template(item_id: str, ctx: Context) -> str:
|
||||
record("template", ctx)
|
||||
return "ok"
|
||||
|
||||
@backend.prompt
|
||||
def inspect_prompt(ctx: Context) -> str:
|
||||
record("prompt", ctx)
|
||||
return "ok"
|
||||
|
||||
return backend
|
||||
|
||||
|
||||
def _proxy(
|
||||
backend: FastMCP, *, backend_mode: str, client_class: type[Client]
|
||||
) -> FastMCPProxy:
|
||||
return FastMCPProxy(
|
||||
client_factory=lambda: client_class(
|
||||
backend,
|
||||
mode=backend_mode,
|
||||
client_info=BACKEND_INFO,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _assert_backend_connection_meta(record: _RecordedRequest, modern: bool) -> None:
|
||||
"""The backend request carries the backend connection's own envelope.
|
||||
|
||||
On a handshake-era backend the reserved keys are absent. On a modern
|
||||
backend they hold the backend session's negotiated version and the proxy
|
||||
client's identity and capabilities — never the frontend client's.
|
||||
"""
|
||||
meta = record.meta
|
||||
if not modern:
|
||||
assert RESERVED_META_KEYS.isdisjoint(meta)
|
||||
return
|
||||
|
||||
assert meta[mcp_types.PROTOCOL_VERSION_META_KEY] == record.protocol_version
|
||||
assert meta[mcp_types.CLIENT_INFO_META_KEY] == BACKEND_INFO.model_dump(
|
||||
by_alias=True, mode="json", exclude_none=True
|
||||
)
|
||||
capabilities = meta[mcp_types.CLIENT_CAPABILITIES_META_KEY]
|
||||
assert FRONT_EXTENSION_ID not in capabilities.get("extensions", {})
|
||||
|
||||
|
||||
# Every allowed ClientFactoryT shape must be hop-safe, not just ProxyClient:
|
||||
# a plain Client backend runs the SDK's stock ClientSession rather than the
|
||||
# proxy's session class, so it exercises the copy-site sanitization alone.
|
||||
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
|
||||
@pytest.mark.parametrize(
|
||||
("front_mode", "backend_mode", "backend_is_modern"),
|
||||
[
|
||||
("auto", "auto", True),
|
||||
("auto", "legacy", False),
|
||||
("legacy", "auto", True),
|
||||
("legacy", "legacy", False),
|
||||
],
|
||||
)
|
||||
async def test_forwarded_tool_meta_stays_hop_safe(
|
||||
front_mode: str,
|
||||
backend_mode: str,
|
||||
backend_is_modern: bool,
|
||||
client_class: type[Client],
|
||||
):
|
||||
seen: dict[str, _RecordedRequest] = {}
|
||||
proxy = _proxy(
|
||||
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
|
||||
)
|
||||
|
||||
async with Client(
|
||||
proxy,
|
||||
mode=front_mode,
|
||||
client_info=FRONT_INFO,
|
||||
extensions=[_FrontendExtension()],
|
||||
) as client:
|
||||
await client.call_tool(
|
||||
"inspect_tool",
|
||||
meta={
|
||||
"progressToken": "front-progress",
|
||||
"example.com/vendor": {"request": "kept"},
|
||||
},
|
||||
)
|
||||
|
||||
record = seen["tool"]
|
||||
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
|
||||
assert isinstance(record.meta["progressToken"], str | int)
|
||||
assert record.meta["example.com/vendor"] == {"request": "kept"}
|
||||
_assert_backend_connection_meta(record, backend_is_modern)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
|
||||
@pytest.mark.parametrize(
|
||||
("backend_mode", "backend_is_modern"),
|
||||
[("auto", True), ("legacy", False)],
|
||||
)
|
||||
@pytest.mark.parametrize("operation", ["resource", "template", "prompt"])
|
||||
async def test_non_tool_requests_forward_hop_safe_metadata(
|
||||
operation: str,
|
||||
backend_mode: str,
|
||||
backend_is_modern: bool,
|
||||
client_class: type[Client],
|
||||
):
|
||||
seen: dict[str, _RecordedRequest] = {}
|
||||
proxy = _proxy(
|
||||
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
|
||||
)
|
||||
meta = {"example.com/vendor": {"operation": operation}}
|
||||
|
||||
async with Client(
|
||||
proxy,
|
||||
mode="auto",
|
||||
client_info=FRONT_INFO,
|
||||
extensions=[_FrontendExtension()],
|
||||
) as client:
|
||||
if operation == "resource":
|
||||
await client.read_resource("data://metadata", meta=meta)
|
||||
elif operation == "template":
|
||||
await client.read_resource("data://items/42", meta=meta)
|
||||
else:
|
||||
await client.get_prompt("inspect_prompt", meta=meta)
|
||||
|
||||
record = seen[operation]
|
||||
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
|
||||
assert record.meta["example.com/vendor"] == {"operation": operation}
|
||||
_assert_backend_connection_meta(record, backend_is_modern)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
33
tests/server/test_legacy_httpx_errors.py
Normal file
33
tests/server/test_legacy_httpx_errors.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue