diff --git a/.github/actions/run-claude/action.yml b/.github/actions/run-claude/action.yml index 66f4bb286..b79131462 100644 --- a/.github/actions/run-claude/action.yml +++ b/.github/actions/run-claude/action.yml @@ -37,6 +37,11 @@ inputs: required: false default: "" + extra-allowed-tools: + description: "Additional comma-separated tools to append to allowed-tools" + required: false + default: "" + model: description: "Model to use for Claude" required: false @@ -88,7 +93,7 @@ runs: track_progress: ${{ inputs.track-progress }} prompt: ${{ inputs.prompt }} claude_args: | - ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }} + ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools ''{0}{1}''', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }} ${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }} --model ${{ inputs.model }} settings: | 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) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index de17a3f8e..0f7efeb17 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,22 @@ rss: true tag: NEW --- + + +**[v3.4.6: Trust, but Proxy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6)** + +FastMCP 3.4.6 backports trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches. Deployments can now route these requests through a mandated corporate proxy while preserving custom CA certificates; FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request. + +### Fixes ๐Ÿž +* Backport #4412 to 3.x: support trusted SSRF proxies by [@jlowin](https://github.com/jlowin) in [#4755](https://github.com/PrefectHQ/fastmcp/pull/4755) + +### Docs ๐Ÿ“š +* Docs: add v3.4.6 changelog entries by [@jlowin](https://github.com/jlowin) in [#4761](https://github.com/PrefectHQ/fastmcp/pull/4761) + +**Full Changelog**: [v3.4.5...v3.4.6](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v3.4.6) + + + **[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)** 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/docs/more/faq.mdx b/docs/more/faq.mdx index 1ac3c2c7d..5566e908f 100644 --- a/docs/more/faq.mdx +++ b/docs/more/faq.mdx @@ -22,16 +22,24 @@ The client probes `server/discover` and adopts the modern protocol when the serv ## What are the two protocol eras, and which one does my server speak? -Both. A FastMCP server serves every era from one deployment and one URL, and the SDK negotiates per connection โ€” the client picks, not the server. +Both. A FastMCP 4 server supports the handshake revisions `2024-11-05`, `2025-03-26`, `2025-06-18`, and `2025-11-25`, plus the modern `2026-07-28` protocol. It serves all of them from one deployment and one URL, and the SDK negotiates per connection โ€” the client picks, not the server. The *handshake* era (`2025-11-25` and earlier) opens each connection with `initialize` and holds a session, which gives the server a back-channel it can push requests down. The *modern* era (`2026-07-28`) is sessionless: the client learns what the server offers through `server/discover`, every request stands alone, and there is no back-channel. Inside a tool, `ctx.request_context.protocol_version` tells you which era the current call arrived on; on the client, `client.protocol_version` reports it after connecting. +A protocol version establishes the wire format, while capabilities describe which optional operations a particular server provides. The capabilities returned by `server/discover` or `initialize` are therefore the authoritative way for a client to determine what is available. + ## Can FastMCP 4 talk to older clients and servers? Yes, in both directions, with no configuration. A FastMCP 4 server answers a handshake-era client and a modern one from the same process: the old client sends `initialize` and gets a session id, the modern client discovers and stays stateless. A FastMCP 4 client is equally happy against an old server, because `mode="auto"` falls back to the handshake when discovery finds no modern peer. The client-side handlers for server-initiated capabilities are all still there too โ€” passing `sampling_handler=` or `roots=` answers a legacy server's requests exactly as before, which is what a modern client needs in order to interoperate. See [client sampling](/clients/sampling) and [client roots](/clients/roots). +## How does FastMCP verify protocol conformance? + +FastMCP runs the [official MCP conformance suite](https://github.com/modelcontextprotocol/conformance) in CI against a pinned suite release. A failing scenario for a released capability that FastMCP advertises as supported is treated as a regression. + +The suite's `all` mode also exercises draft, pending, retired, and deliberately unsupported capabilities, so its raw pass count is broader than FastMCP's support contract. Known exceptions are recorded in [`expected-failures.yml`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/conformance/expected-failures.yml) with their rationale, and new upstream scenarios arrive through deliberate suite-version updates rather than silently changing CI. + ## When should I pin `mode="legacy"`? Pin it when your code depends on the session the handshake creates: `client.ping()` and `transport.get_session_id()` have no modern equivalent, since a sessionless connection has neither a live back-channel to ping nor an id to hold. It is also the escape hatch when a server misbehaves under discovery or you need the classic `initialize` result object. diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx index b21e8b54d..9e55640ba 100644 --- a/docs/servers/auth/token-verification.mdx +++ b/docs/servers/auth/token-verification.mdx @@ -80,6 +80,19 @@ This configuration creates a server that validates JWTs issued by `auth.yourcomp The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server. +`JWTVerifier` accepts RSA (`RS*` and `PS*`), ECDSA (`ES*`), and Edwards-curve (`Ed25519` and `Ed448`) signatures from JWKS endpoints. Set `algorithm` when your issuer does not use the default `RS256`: + +```python +verifier = JWTVerifier( + jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", + issuer="https://auth.yourcompany.com", + audience="mcp-production-api", + algorithm="Ed25519", +) +``` + +The legacy `EdDSA` identifier is also accepted for compatibility with identity providers that have not yet adopted the fully specified identifiers from RFC 9864. + ### Symmetric Key Verification (HMAC) Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators. @@ -121,7 +134,7 @@ The parameter is named `public_key` for backwards compatibility, but when using ### Static Public Key Verification -Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. +Static public key verification works when you have a fixed RSA, ECDSA, or EdDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. ```python from fastmcp import FastMCP @@ -141,7 +154,7 @@ verifier = JWTVerifier( mcp = FastMCP(name="Protected API", auth=verifier) ``` -This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. +This configuration validates tokens using a specific RSA, ECDSA, or EdDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. ## Opaque Token Verification Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662). @@ -425,4 +438,3 @@ mcp = FastMCP(name="Production API", auth=verifier) This keeps configuration out of your codebase while maintaining explicit setup. This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration. - 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/docs/updates.mdx b/docs/updates.mdx index e930cb36c..26e83c917 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,6 +5,16 @@ icon: "sparkles" tag: NEW --- + + +FastMCP 3.4.6 adds trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches on the 3.x line. Deployments can route these requests through a mandated corporate proxy while preserving custom CA certificates, and FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request. + + + object: raise ImportError(_install_hints.APP_SUPPORT) from exc return FastMCPApp - if name == "FastMCPDeprecationWarning": - from fastmcp.exceptions import FastMCPDeprecationWarning - - return FastMCPDeprecationWarning if name == "client": try: return importlib.import_module("fastmcp.client") diff --git a/fastmcp_slim/fastmcp/_compat.py b/fastmcp_slim/fastmcp/_compat.py index 402b8c238..cefc67b49 100644 --- a/fastmcp_slim/fastmcp/_compat.py +++ b/fastmcp_slim/fastmcp/_compat.py @@ -33,7 +33,7 @@ import warnings import mcp_types -from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp._warnings import FastMCPDeprecationWarning # Map each SDK model class to the camelCase -> snake_case field reads we bridge. # Limited to fields FastMCP users actually read (docs boundary inventory). diff --git a/fastmcp_slim/fastmcp/_warnings.py b/fastmcp_slim/fastmcp/_warnings.py new file mode 100644 index 000000000..c63b97a5d --- /dev/null +++ b/fastmcp_slim/fastmcp/_warnings.py @@ -0,0 +1,10 @@ +"""Warning types that can be imported without loading FastMCP's exception stack.""" + + +class FastMCPDeprecationWarning(DeprecationWarning): + """Deprecation warning for FastMCP APIs. + + Subclass of DeprecationWarning so that standard warning filters + still apply, but FastMCP can selectively enable its own warnings + without affecting other libraries in the process. + """ diff --git a/fastmcp_slim/fastmcp/exceptions.py b/fastmcp_slim/fastmcp/exceptions.py index 4042f80b7..3fa255acd 100644 --- a/fastmcp_slim/fastmcp/exceptions.py +++ b/fastmcp_slim/fastmcp/exceptions.py @@ -5,6 +5,8 @@ from typing import Any from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData +from fastmcp import _warnings + try: from mcp import MCPError except ImportError: @@ -30,14 +32,7 @@ except ImportError: # see the migration notes. McpError = MCPError - -class FastMCPDeprecationWarning(DeprecationWarning): - """Deprecation warning for FastMCP APIs. - - Subclass of DeprecationWarning so that standard warning filters - still apply, but FastMCP can selectively enable its own warnings - without affecting other libraries in the process. - """ +FastMCPDeprecationWarning = _warnings.FastMCPDeprecationWarning class FastMCPError(Exception): 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/auth/providers/jwt.py b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py index 64c8549cf..d991b2ab6 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/jwt.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py @@ -6,7 +6,7 @@ import contextlib import json import time from dataclasses import dataclass -from typing import Any, TypeAlias, cast +from typing import Any, Literal, TypeAlias, cast import httpx2 from cryptography.hazmat.primitives import serialization @@ -29,22 +29,30 @@ JWKKeyData: TypeAlias = dict[str, str | list[str]] SUPPORTED_JWS_HEADER_FIELDS = frozenset(JWS_HEADER_REGISTRY) -def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str): +def _key_type_for_algorithm(algorithm: str) -> Literal["oct", "RSA", "EC", "OKP"]: if algorithm.startswith("HS"): - return jwk.import_key(key, "oct") + return "oct" if algorithm.startswith(("RS", "PS")): - return jwk.import_key(key, "RSA") + return "RSA" if algorithm.startswith("ES"): - return jwk.import_key(key, "EC") + return "EC" + if algorithm in {"EdDSA", "Ed25519", "Ed448"}: + return "OKP" raise ValueError(f"Unsupported algorithm: {algorithm}.") +def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str): + return jwk.import_key(key, _key_type_for_algorithm(algorithm)) + + def _jwk_to_pem(key_data: JWKKeyData) -> str: key_type = key_data.get("kty") if key_type == "RSA": return jwk.import_key(key_data, "RSA").as_pem().decode("utf-8") if key_type == "EC": return jwk.import_key(key_data, "EC").as_pem().decode("utf-8") + if key_type == "OKP": + return jwk.import_key(key_data, "OKP").as_pem().decode("utf-8") raise ValueError(f"Unsupported JWK key type: {key_type!r}") @@ -72,6 +80,8 @@ class JWKData(TypedDict, total=False): alg: str # Algorithm (e.g., "RS256") n: str # Modulus (for RSA keys) e: str # Exponent (for RSA keys) + crv: str # Curve name (for EC and OKP keys) + x: str # Public key coordinate (for EC and OKP keys) x5c: list[str] # X.509 certificate chain (for JWKs) x5t: str # X.509 certificate thumbprint (for JWKs) @@ -194,10 +204,11 @@ def _looks_like_pem_public_key(key: str | bytes) -> bool: class JWTVerifier(TokenVerifier): """ - JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. + JWT token verifier supporting asymmetric (RSA/ECDSA/EdDSA) and symmetric (HMAC) algorithms. This verifier validates JWT tokens using various signing algorithms: - - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512): + - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512, + Ed25519, Ed448, and legacy EdDSA): Uses public/private key pairs. Ideal for external clients and services where only the authorization server has the private key. - **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both @@ -232,7 +243,7 @@ class JWTVerifier(TokenVerifier): jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS. issuer: Expected issuer claim value or list of allowed issuer values. audience: Expected audience claim value or list of allowed audience values. - algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. + algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512, Ed25519, Ed448, and legacy EdDSA. required_scopes: Scopes that must be present in validated tokens. base_url: Base URL passed to the parent TokenVerifier. ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only, @@ -275,6 +286,9 @@ class JWTVerifier(TokenVerifier): "PS256", "PS384", "PS512", + "EdDSA", + "Ed25519", + "Ed448", }: raise ValueError(f"Unsupported algorithm: {algorithm}.") @@ -347,19 +361,31 @@ class JWTVerifier(TokenVerifier): try: jwks_data = await self._fetch_jwks() - # Cache all usable keys. A key that cannot be converted (e.g. an - # unsupported kty like OKP/Ed25519) is skipped rather than failing - # the whole set โ€” per RFC 7517 ยง5, clients should ignore JWKs they - # don't understand. Otherwise one exotic key published by the - # authorization server would reject every token, including ones - # signed by supported keys in the same set (#4515). + # Cache all usable keys. A key that cannot be converted is skipped + # rather than failing the whole set โ€” per RFC 7517 ยง5, clients + # should ignore JWKs they don't understand. Otherwise one exotic + # key published by the authorization server would reject every + # token, including ones signed by supported keys in the same set + # (#4515). self._jwks_cache = {} skipped_kids: set[str] = set() + expected_key_type = _key_type_for_algorithm(self.algorithm) for key_data in jwks_data.get("keys", []): if not isinstance(key_data, dict): self.logger.debug("Skipping non-object JWKS entry: %r", key_data) continue key_kid = key_data.get("kid") + if key_data.get("kty") != expected_key_type: + self.logger.debug( + "Skipping JWKS key %r: key type %r is incompatible " + "with algorithm %s", + key_kid, + key_data.get("kty"), + self.algorithm, + ) + if key_kid: + skipped_kids.add(key_kid) + continue try: public_key = _jwk_to_pem(key_data) except (JoseError, TypeError, KeyError, ValueError) as e: 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, diff --git a/fastmcp_slim/fastmcp/server/mixins/transport.py b/fastmcp_slim/fastmcp/server/mixins/transport.py index 13bef1ced..b26e02d18 100644 --- a/fastmcp_slim/fastmcp/server/mixins/transport.py +++ b/fastmcp_slim/fastmcp/server/mixins/transport.py @@ -28,7 +28,6 @@ from fastmcp.server.http import ( from fastmcp.server.providers.base import Provider from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.server.providers.wrapped_provider import _WrappedProvider -from fastmcp.utilities.cli import log_server_banner from fastmcp.utilities.logging import get_logger, temporary_log_level if TYPE_CHECKING: @@ -230,6 +229,8 @@ class TransportMixin: # Display server banner if show_banner: + from fastmcp.utilities.cli import log_server_banner + log_server_banner(server=self) token = set_transport("stdio") @@ -337,6 +338,8 @@ class TransportMixin: # Display server banner if show_banner: + from fastmcp.utilities.cli import log_server_banner + log_server_banner(server=self) uvicorn_config_from_user = uvicorn_config or {} 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/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/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/logging.py b/fastmcp_slim/fastmcp/utilities/logging.py index d39d6ef4a..eebf4eda8 100644 --- a/fastmcp_slim/fastmcp/utilities/logging.py +++ b/fastmcp_slim/fastmcp/utilities/logging.py @@ -1,7 +1,9 @@ """Logging utilities for FastMCP.""" import contextlib +import importlib.util import logging +from pathlib import Path from typing import Any, Literal, cast from rich.console import Console @@ -11,6 +13,17 @@ from typing_extensions import override import fastmcp +def _get_package_path(package: str) -> str | None: + """Return a package directory without importing the package.""" + try: + spec = importlib.util.find_spec(package) + except ImportError: + return None + if spec is None or spec.origin is None: + return None + return str(Path(spec.origin).parent) + + def get_logger(name: str) -> logging.Logger: """Get a logger nested under FastMCP namespace. @@ -83,14 +96,11 @@ def configure_logging( # no path or level name to maximize width available for the traceback # suppress framework frames and limit the number of frames to 3 - import pydantic - - try: - import mcp - except ImportError: - tracebacks_suppress = [fastmcp, pydantic] - else: - tracebacks_suppress = [fastmcp, mcp, pydantic] + tracebacks_suppress = [ + package_path + for package in ("fastmcp", "mcp", "pydantic") + if (package_path := _get_package_path(package)) is not None + ] # Build traceback kwargs with defaults that can be overridden traceback_kwargs = { 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/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml index b009efd42..6d56f3e93 100644 --- a/fastmcp_slim/pyproject.toml +++ b/fastmcp_slim/pyproject.toml @@ -96,7 +96,7 @@ server = [ "griffelib>=2.0.0", "jsonref>=1.1.0", "jsonschema-path>=0.3.4", - "joserfc>=1.1.0", + "joserfc>=1.5.0", "openapi-pydantic>=0.5.1", "packaging>=24.0", "py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0", diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 7a39eb9df..20007f3e5 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -4,6 +4,9 @@ from typing import Any, cast from unittest.mock import MagicMock, patch import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from joserfc import jwk as jose_jwk from joserfc import jwt from joserfc.jws import JWSRegistry @@ -81,6 +84,49 @@ class SymmetricKeyHelper: return token +def create_okp_key_pair( + private_key: Ed25519PrivateKey | Ed448PrivateKey, +) -> tuple[str, str]: + """Serialize an EdDSA key pair as PEM strings.""" + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + public_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return private_pem, public_pem + + +def create_okp_token( + private_key: str, + algorithm: str, + *, + kid: str | None = None, +) -> str: + """Create a JWT signed by an OKP key.""" + header = {"alg": algorithm} + if kid is not None: + header["kid"] = kid + return jwt.encode( + header, + { + "sub": "test-user", + "iss": "https://test.example.com", + "aud": "https://api.example.com", + "exp": int(time.time()) + 3600, + }, + jose_jwk.import_key(private_key, "OKP"), + algorithms=[algorithm], + ) + + @pytest.fixture(scope="module") def symmetric_key_helper() -> SymmetricKeyHelper: """Generate a symmetric key helper for testing.""" @@ -485,6 +531,58 @@ class TestSymmetricKeyJWT: assert access_token is None +class TestEdDSAJWT: + """Tests for JWT verification using Edwards-curve keys.""" + + @pytest.mark.parametrize("algorithm", ["Ed25519", "Ed448"]) + async def test_static_public_key(self, algorithm: str): + """Fully specified EdDSA algorithms verify with a static public key.""" + if algorithm == "Ed25519": + private_key = Ed25519PrivateKey.generate() + else: + private_key = Ed448PrivateKey.generate() + private_pem, public_pem = create_okp_key_pair(private_key) + verifier = JWTVerifier( + public_key=public_pem, + issuer="https://test.example.com", + audience="https://api.example.com", + algorithm=algorithm, + ) + + access_token = await verifier.load_access_token( + create_okp_token(private_pem, algorithm) + ) + + assert access_token is not None + assert access_token.client_id == "test-user" + + @pytest.mark.filterwarnings( + "ignore:EdDSA is deprecated via RFC 9864:joserfc.errors.SecurityWarning" + ) + async def test_legacy_eddsa_jwks( + self, + httpx_mock: HTTPXMock, + ): + """Legacy EdDSA tokens verify against an Ed25519 JWKS entry.""" + private_pem, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate()) + public_jwk = jose_jwk.import_key(public_pem, "OKP").as_dict() + public_jwk.update(kid="ed25519-key", alg="EdDSA", use="sig") + httpx_mock.add_response(json={"keys": [public_jwk]}) + verifier = JWTVerifier( + jwks_uri="https://test.example.com/.well-known/jwks.json", + issuer="https://test.example.com", + audience="https://api.example.com", + algorithm="EdDSA", + ) + + access_token = await verifier.load_access_token( + create_okp_token(private_pem, "EdDSA", kid="ed25519-key") + ) + + assert access_token is not None + assert access_token.client_id == "test-user" + + def _create_token_without_sub( rsa_key_pair: RSAKeyPair, *, @@ -662,7 +760,7 @@ class TestBearerTokenJWKS: assert access_token.claims.get("iss") == issuer assert access_token.claims.get("aud") == audience - async def test_jwks_skips_unsupported_key_types( + async def test_jwks_skips_unusable_keys( self, rsa_key_pair: RSAKeyPair, jwks_provider: JWTVerifier, @@ -670,28 +768,19 @@ class TestBearerTokenJWKS: httpx_mock: HTTPXMock, mock_dns, ): - """An unsupported key type in the JWKS (e.g. OKP/Ed25519) must be - skipped, not poison the whole key set - #4515. - - Some authorization servers (e.g. Rauthy, Ory Hydra) publish an - Ed25519 key alongside RSA keys; tokens signed by the RSA keys must - still verify. - """ - okp_key = cast( + """An unusable key must not poison the whole key set - #4515.""" + malformed_key = cast( "JWKData", { - "kty": "OKP", - "crv": "Ed25519", - "kid": "ed25519-key", - "alg": "EdDSA", + "kty": "RSA", + "kid": "malformed-key", "use": "sig", - "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", }, ) mock_jwks_data["keys"][0]["kid"] = "test-key-1" - # Unsupported key FIRST, so an unguarded conversion loop would + # Malformed key FIRST, so an unguarded conversion loop would # abort before reaching the RSA key the token needs - mock_jwks_data["keys"].insert(0, okp_key) + mock_jwks_data["keys"].insert(0, malformed_key) httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( @@ -705,37 +794,60 @@ class TestBearerTokenJWKS: assert access_token is not None assert access_token.client_id == "test-user" - async def test_jwks_with_only_unsupported_keys_rejects_cleanly( + async def test_jwks_ignores_other_algorithm_key_types_without_kid( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: JWTVerifier, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + mock_dns, + ): + """Unrelated key types do not make a no-kid lookup ambiguous.""" + _, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate()) + okp_key = jose_jwk.import_key(public_pem, "OKP").as_dict() + okp_key.update(kid="ed25519-key", alg="Ed25519", use="sig") + mock_jwks_data["keys"].append(cast("JWKData", okp_key)) + httpx_mock.add_response(json=mock_jwks_data) + + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await jwks_provider.load_access_token(token) + + assert access_token is not None + assert access_token.client_id == "test-user" + + async def test_jwks_with_only_unusable_keys_rejects_cleanly( self, rsa_key_pair: RSAKeyPair, jwks_provider: JWTVerifier, httpx_mock: HTTPXMock, mock_dns, ): - """If every key in the JWKS is unsupported, verification fails + """If every key in the JWKS is unusable, verification fails cleanly (returns None) rather than crashing - #4515.""" - okp_only = { + unusable_only = { "keys": [ cast( "JWKData", { - "kty": "OKP", - "crv": "Ed25519", - "kid": "ed25519-key", - "alg": "EdDSA", + "kty": "RSA", + "kid": "malformed-key", "use": "sig", - "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", }, ) ] } - httpx_mock.add_response(json=okp_only) + httpx_mock.add_response(json=unusable_only) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", audience="https://api.example.com", - kid="ed25519-key", + kid="malformed-key", ) access_token = await jwks_provider.load_access_token(token) diff --git a/tests/server/http/test_startup_imports.py b/tests/server/http/test_startup_imports.py index 52af15fc1..addcd99b7 100644 --- a/tests/server/http/test_startup_imports.py +++ b/tests/server/http/test_startup_imports.py @@ -9,6 +9,51 @@ import textwrap import pytest +@pytest.mark.subprocess_heavy +def test_root_import_does_not_load_mcp_sdk() -> None: + script = textwrap.dedent( + """ + import sys + + import fastmcp + + assert fastmcp.settings is not None + assert "mcp" not in sys.modules + assert "fastmcp.exceptions" not in sys.modules + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.subprocess_heavy +def test_server_import_does_not_load_cli() -> None: + script = textwrap.dedent( + """ + import sys + + from fastmcp import FastMCP + + assert FastMCP is not None + assert "fastmcp.utilities.cli" not in sys.modules + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + @pytest.mark.subprocess_heavy def test_default_http_app_does_not_load_opt_in_integrations() -> None: script = textwrap.dedent( 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/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) 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_compat.py b/tests/test_compat.py index ff5356a64..a44991f02 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -10,6 +10,7 @@ from mcp import MCPError as SDKMCPError import fastmcp import fastmcp._compat as _compat from fastmcp import Client, FastMCP +from fastmcp import FastMCPDeprecationWarning as PublicWarning from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import FastMCPDeprecationWarning, MCPError, McpError @@ -30,6 +31,10 @@ def _reset_warn_once() -> None: _compat.install() +def test_deprecation_warning_is_same_from_public_imports() -> None: + assert PublicWarning is FastMCPDeprecationWarning + + @pytest.fixture(autouse=True) def fresh_shims(): _reset_warn_once() 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 diff --git a/tests/utilities/test_logging.py b/tests/utilities/test_logging.py index d15b9c355..c74b6c436 100644 --- a/tests/utilities/test_logging.py +++ b/tests/utilities/test_logging.py @@ -1,4 +1,7 @@ import logging +from pathlib import Path + +from rich.logging import RichHandler import fastmcp from fastmcp.utilities.logging import configure_logging, get_logger @@ -42,6 +45,19 @@ def test_configure_logging_with_traceback_kwargs(): assert len(logger.handlers) == 2 # One for normal logs, one for tracebacks +def test_configure_logging_suppresses_framework_package_paths(): + configure_logging(enable_rich_tracebacks=True) + + traceback_handler = logging.getLogger("fastmcp").handlers[-1] + assert isinstance(traceback_handler, RichHandler) + suppressed_packages = { + Path(path).name + for path in traceback_handler.tracebacks_suppress + if isinstance(path, str) + } + assert {"fastmcp", "mcp", "pydantic"} <= suppressed_packages + + def test_configure_logging_traceback_defaults_can_be_overridden(): """Test that default traceback settings can be overridden by kwargs.""" configure_logging( @@ -91,8 +107,6 @@ def test_configure_logging_with_rich_enabled(): # Should have two handlers when rich logging is enabled (normal + traceback) assert len(logger.handlers) == 2 # Both should be RichHandler instances - from rich.logging import RichHandler - assert all(isinstance(h, RichHandler) for h in logger.handlers) finally: fastmcp.settings.enable_rich_logging = original_enable_rich diff --git a/uv.lock b/uv.lock index 727bf1183..58fe0e134 100644 --- a/uv.lock +++ b/uv.lock @@ -1046,7 +1046,7 @@ requires-dist = [ { name = "httpx2", marker = "extra == 'client'", specifier = ">=2.5.0" }, { name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.5.0" }, { name = "httpx2", marker = "extra == 'server'", specifier = ">=2.5.0" }, - { name = "joserfc", marker = "extra == 'server'", specifier = ">=1.1.0" }, + { name = "joserfc", marker = "extra == 'server'", specifier = ">=1.5.0" }, { name = "jsonref", marker = "extra == 'gemini'", specifier = ">=1.1.0" }, { name = "jsonref", marker = "extra == 'server'", specifier = ">=1.1.0" }, { name = "jsonschema-path", marker = "extra == 'server'", specifier = ">=0.3.4" },