From 880d835cccd5e8b381aab1678685cb33ee64c0f8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:44:52 -0500 Subject: [PATCH 01/14] Add CIMD (Client ID Metadata Document) support for OAuth (#2871) --- docs/clients/auth/cimd.mdx | 137 +++ docs/clients/auth/oauth.mdx | 39 +- docs/development/v3-notes/v3-features.mdx | 47 + docs/docs.json | 1 + docs/patterns/cli.mdx | 82 ++ docs/servers/auth/oauth-proxy.mdx | 68 ++ docs/servers/auth/oidc-proxy.mdx | 16 + examples/auth/github_oauth/client.py | 12 +- loq.toml | 2 +- src/fastmcp/cli/auth.py | 13 + src/fastmcp/cli/cimd.py | 218 ++++ src/fastmcp/cli/cli.py | 4 + src/fastmcp/client/auth/oauth.py | 67 +- src/fastmcp/client/transports/http.py | 12 +- src/fastmcp/client/transports/sse.py | 12 +- src/fastmcp/server/auth/auth.py | 100 +- src/fastmcp/server/auth/cimd.py | 651 ++++++++++++ .../server/auth/oauth_proxy/consent.py | 12 +- src/fastmcp/server/auth/oauth_proxy/models.py | 79 +- src/fastmcp/server/auth/oauth_proxy/proxy.py | 117 ++- src/fastmcp/server/auth/oauth_proxy/ui.py | 32 + src/fastmcp/server/auth/oidc_proxy.py | 6 + src/fastmcp/server/auth/providers/jwt.py | 51 +- .../server/auth/redirect_validation.py | 159 ++- src/fastmcp/server/auth/ssrf.py | 307 ++++++ tests/cli/test_cimd_cli.py | 208 ++++ tests/client/auth/test_oauth_cimd.py | 164 +++ .../auth/oauth_proxy/test_oauth_proxy.py | 28 + tests/server/auth/test_cimd.py | 971 ++++++++++++++++++ tests/server/auth/test_jwt_provider.py | 61 +- .../test_oauth_proxy_redirect_validation.py | 127 ++- tests/server/auth/test_oauth_proxy_storage.py | 8 +- tests/server/auth/test_oidc_proxy.py | 16 +- tests/server/auth/test_redirect_validation.py | 59 ++ tests/server/auth/test_ssrf_protection.py | 447 ++++++++ tests/utilities/openapi/test_models.py | 6 +- 36 files changed, 4221 insertions(+), 118 deletions(-) create mode 100644 docs/clients/auth/cimd.mdx create mode 100644 src/fastmcp/cli/auth.py create mode 100644 src/fastmcp/cli/cimd.py create mode 100644 src/fastmcp/server/auth/cimd.py create mode 100644 src/fastmcp/server/auth/ssrf.py create mode 100644 tests/cli/test_cimd_cli.py create mode 100644 tests/client/auth/test_oauth_cimd.py create mode 100644 tests/server/auth/test_cimd.py create mode 100644 tests/server/auth/test_ssrf_protection.py diff --git a/docs/clients/auth/cimd.mdx b/docs/clients/auth/cimd.mdx new file mode 100644 index 000000000..6980c66f2 --- /dev/null +++ b/docs/clients/auth/cimd.mdx @@ -0,0 +1,137 @@ +--- +title: CIMD Authentication +sidebarTitle: CIMD +description: Use Client ID Metadata Documents for verifiable, domain-based client identity. +icon: id-badge +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + + +CIMD authentication is only relevant for HTTP-based transports and requires a server that advertises CIMD support. + + +With standard OAuth, your client registers dynamically with every server it connects to, receiving a fresh `client_id` each time. This works, but the server has no way to verify *who* your client actually is — any client can claim any name during registration. + +CIMD (Client ID Metadata Documents) flips this around. You host a small JSON document at an HTTPS URL you control, and that URL becomes your `client_id`. When your client connects to a server, the server fetches your metadata document and can verify your identity through your domain ownership. Users see a verified domain badge in the consent screen instead of an unverified client name. + +## Client Usage + +Pass your CIMD document URL to the `client_metadata_url` parameter of `OAuth`: + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_metadata_url="https://myapp.example.com/oauth/client.json", + ), +) as client: + await client.ping() +``` + +When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow. + + +You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. + + +## Creating a CIMD Document + +A CIMD document is a JSON file that describes your client. The most important field is `client_id`, which must exactly match the URL where you host the document. + +Use the FastMCP CLI to generate one: + +```bash +fastmcp auth cimd create \ + --name "My Application" \ + --redirect-uri "http://localhost:*/callback" \ + --client-id "https://myapp.example.com/oauth/client.json" +``` + +This produces: + +```json +{ + "client_id": "https://myapp.example.com/oauth/client.json", + "client_name": "My Application", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code"], + "response_types": ["code"] +} +``` + +If you omit `--client-id`, the CLI generates a placeholder value and reminds you to update it before hosting. + +### CLI Options + +The `create` command accepts these flags: + +| Flag | Description | +|------|-------------| +| `--name` | Human-readable client name (required) | +| `--redirect-uri`, `-r` | Allowed redirect URIs — can be specified multiple times (required) | +| `--client-id` | The URL where you'll host this document (sets `client_id` directly) | +| `--output`, `-o` | Write to a file instead of stdout | +| `--scope` | Space-separated list of scopes the client may request | +| `--client-uri` | URL of the client's home page | +| `--logo-uri` | URL of the client's logo image | +| `--no-pretty` | Output compact JSON | + +### Redirect URIs + +The `redirect_uris` field supports wildcard port matching for localhost. The pattern `http://localhost:*/callback` matches any port, which is useful for development clients that bind to random available ports (which is what FastMCP's `OAuth` helper does by default). + +## Hosting Requirements + +CIMD documents must be hosted at a publicly accessible HTTPS URL with a non-root path: + +- **HTTPS required** — HTTP URLs are rejected for security +- **Non-root path** — The URL must have a path component (e.g., `/oauth/client.json`, not just `/`) +- **Public accessibility** — The server must be able to fetch the document over the internet +- **Matching `client_id`** — The `client_id` field in the document must exactly match the hosting URL + +Common hosting options include static file hosting services like GitHub Pages, Cloudflare Pages, Vercel, or S3 — anywhere you can serve a JSON file over HTTPS. + +## Validating Your Document + +Before deploying, verify your hosted document passes validation: + +```bash +fastmcp auth cimd validate https://myapp.example.com/oauth/client.json +``` + +The validator fetches the document and checks that: +- The URL is valid (HTTPS, non-root path) +- The document is well-formed JSON conforming to the CIMD schema +- The `client_id` in the document matches the URL it was fetched from + +## How It Works + +When your client connects to a CIMD-enabled server, the flow works like this: + + + +Your client sends its `client_metadata_url` as the `client_id` in the OAuth authorization request. + + +The server sees that the `client_id` is an HTTPS URL with a path — the signature of a CIMD client — and skips Dynamic Client Registration. + + +The server fetches your JSON document from the URL, validates that `client_id` matches the URL, and extracts your client metadata (name, redirect URIs, scopes). + + +The standard OAuth flow continues: browser opens for user consent, authorization code exchange, token issuance. The consent screen shows your verified domain. + + + +The server caches your CIMD document according to HTTP cache headers, so subsequent requests don't require re-fetching. + +## Server Configuration + +CIMD is a server-side feature that your MCP server must support. FastMCP's OAuth proxy providers (GitHub, Google, Auth0, etc.) support CIMD by default. See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for server-side configuration, including private key JWT authentication and security details. diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 6a60fb0f8..25804adc3 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -41,20 +41,25 @@ To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `au from fastmcp import Client from fastmcp.client.auth import OAuth -oauth = OAuth(mcp_url="https://your-server.fastmcp.app/mcp") +oauth = OAuth(scopes=["user"]) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: await client.ping() ``` + +You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. + + #### `OAuth` Parameters -- **`mcp_url`** (`str`): The full URL of the target MCP server endpoint. Used to discover OAuth server metadata - **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings - **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"` +- **`client_metadata_url`** (`str`, optional): URL-based client identity (CIMD). See [CIMD Authentication](/clients/auth/cimd) for details - **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options - **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration - **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port +- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients ## OAuth Flow @@ -68,8 +73,8 @@ The client first checks the configured `token_storage` backend for existing, val If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`. - -If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. + +If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. Alternatively, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity instead of registering. A temporary local HTTP server is started on an available port (or the port specified via `callback_port`). This server's address (e.g., `http://127.0.0.1:/callback`) acts as the `redirect_uri` for the OAuth flow. @@ -115,10 +120,7 @@ encrypted_storage = FernetEncryptionWrapper( fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"]) ) -oauth = OAuth( - mcp_url="https://your-server.fastmcp.app/mcp", - token_storage=encrypted_storage -) +oauth = OAuth(token_storage=encrypted_storage) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: await client.ping() @@ -129,3 +131,24 @@ You can use any `AsyncKeyValue`-compatible backend from the [key-value library]( When selecting a storage backend, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have constraints that affect production suitability. + +## CIMD Authentication + + + +Client ID Metadata Documents (CIMD) provide an alternative to Dynamic Client Registration. Instead of registering with each server, your client hosts a static JSON document at an HTTPS URL. That URL becomes your client's identity, and servers can verify who you are through your domain ownership. + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_metadata_url="https://myapp.example.com/oauth/client.json", + ), +) as client: + await client.ping() +``` + +See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents. diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 2fa032e6e..05399fce6 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -73,6 +73,53 @@ fastmcp install stdio server.py The command automatically detects the project directory and generates the appropriate `uv run` invocation, making it easy to integrate FastMCP servers with MCP clients. +### CIMD (Client ID Metadata Documents) + +CIMD provides an alternative to Dynamic Client Registration for OAuth-authenticated MCP servers. Instead of registering with each server dynamically, clients host a static JSON document at an HTTPS URL. That URL becomes the client's `client_id`, and servers verify identity through domain ownership. + +**Client usage:** + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_metadata_url="https://myapp.example.com/oauth/client.json", + ), +) as client: + await client.ping() +``` + +The `OAuth` helper now supports deferred binding — `mcp_url` is optional when using `OAuth` with `Client(auth=...)`, since the transport provides the server URL automatically. + +**CLI tools for document management:** + +```bash +# Generate a CIMD document +fastmcp auth cimd create --name "My App" \ + --redirect-uri "http://localhost:*/callback" \ + --client-id "https://myapp.example.com/oauth/client.json" \ + --output client.json + +# Validate a hosted document +fastmcp auth cimd validate https://myapp.example.com/oauth/client.json +``` + +**Server-side support:** + +CIMD is enabled by default on `OAuthProxy` and its provider subclasses (GitHub, Google, etc.). The server-side implementation includes SSRF-hardened document fetching with DNS pinning, dual redirect URI validation (both CIMD document patterns and proxy patterns must match), HTTP cache-aware revalidation, and `private_key_jwt` assertion validation for clients that need stronger authentication than public client auth. + +Key details: +- CIMD URLs must be HTTPS with a non-root path +- `token_endpoint_auth_method` limited to `none` or `private_key_jwt` (no shared secrets) +- `redirect_uris` in CIMD documents support wildcard port patterns (`http://localhost:*/callback`) +- Servers fetch and cache documents with standard HTTP caching (ETag, Last-Modified, Cache-Control) +- CIMD is a protocol-level feature — any auth provider implementing the spec can support it + +Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) + ### MCP Apps (SDK Compatibility) Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. diff --git a/docs/docs.json b/docs/docs.json index f20343ca1..b9c1bc4dd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -198,6 +198,7 @@ "icon": "key", "pages": [ "clients/auth/oauth", + "clients/auth/cimd", "clients/auth/bearer" ] } diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 72badb870..8ccf9d01f 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -25,6 +25,7 @@ fastmcp --help | `install` | Install a server in MCP client applications | **Supports:** Local files and fastmcp.json configs. **Deps:** Creates an isolated environment; dependencies must be explicitly specified with `--with` and/or `--with-editable`. With fastmcp.json: Uses configured dependencies | | `inspect` | Generate a JSON report about a FastMCP server | **Supports:** Local files and fastmcp.json configs. **Deps:** Uses your current environment; you are responsible for ensuring all dependencies are available | | `project prepare` | Create a persistent uv project from fastmcp.json environment config | **Supports:** fastmcp.json configs only. **Deps:** Creates a uv project directory with all dependencies pre-installed for reuse with `--project` flag | +| `auth cimd` | Create and validate CIMD documents for OAuth authentication | N/A | | `version` | Display version information | N/A | ## `fastmcp list` @@ -750,6 +751,87 @@ The prepare command creates a uv project with: This is useful when you want to separate environment setup from server execution, such as in deployment scenarios where dependencies are installed once and the server is run multiple times. +## `fastmcp auth` + + + +Authentication-related utilities and configuration commands. + +### `fastmcp auth cimd create` + +Generate a CIMD (Client ID Metadata Document) for hosting. This creates a JSON document that you can host at an HTTPS URL to use as your OAuth client identity. + +```bash +fastmcp auth cimd create --name "My App" --redirect-uri "http://localhost:*/callback" +``` + +#### Options + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| Name | `--name` | **Required.** Human-readable name of the client application | +| Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (can specify multiple) | +| Client URI | `--client-uri` | URL of the client's home page | +| Logo URI | `--logo-uri` | URL of the client's logo image | +| Scope | `--scope` | Space-separated list of scopes the client may request | +| Output | `--output`, `-o` | Output file path (default: stdout) | +| Pretty | `--pretty` | Pretty-print JSON output (default: true) | + +#### Example + +```bash +# Generate document to stdout +fastmcp auth cimd create \ + --name "My Production App" \ + --redirect-uri "http://localhost:*/callback" \ + --redirect-uri "https://myapp.example.com/callback" \ + --client-uri "https://myapp.example.com" \ + --scope "read write" + +# Save to file +fastmcp auth cimd create \ + --name "My App" \ + --redirect-uri "http://localhost:*/callback" \ + --output client.json +``` + +The generated document includes a placeholder `client_id` that you must update to match the URL where you'll host the document before deploying. + +### `fastmcp auth cimd validate` + +Validate a hosted CIMD document by fetching it from its URL and checking that it conforms to the CIMD specification. + +```bash +fastmcp auth cimd validate https://myapp.example.com/oauth/client.json +``` + +#### Options + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| Timeout | `--timeout`, `-t` | HTTP request timeout in seconds (default: 10) | + +The validator checks: + +- The URL is a valid CIMD URL (HTTPS with non-root path) +- The document is valid JSON and conforms to the CIMD schema +- The `client_id` field in the document matches the URL +- No shared-secret authentication methods are used + +On success, it displays the document details: + +``` +→ Fetching https://myapp.example.com/oauth/client.json... +✓ Valid CIMD document + +Document details: + client_id: https://myapp.example.com/oauth/client.json + client_name: My App + token_endpoint_auth_method: none + redirect_uris: + • http://localhost:*/callback +``` + ## `fastmcp version` Display version information about FastMCP and related components. diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 3c5b328a7..86a8865f3 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -524,6 +524,74 @@ auth = OAuthProxy( Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use. +## CIMD Support + + + +The OAuth proxy supports **Client ID Metadata Documents (CIMD)**, an alternative to Dynamic Client Registration where clients host a static JSON document at an HTTPS URL. Instead of registering dynamically, clients simply provide their CIMD URL as their `client_id`, and the server fetches and validates the metadata. + +CIMD clients appear in the consent screen with a verified domain badge, giving users confidence about which application is requesting access. This provides stronger identity verification than DCR, where any client can claim any name. + +### How CIMD Works + +When a client presents an HTTPS URL as its `client_id` (for example, `https://myapp.example.com/oauth/client.json`), the OAuth proxy recognizes it as a CIMD client and: + +1. Fetches the JSON document from that URL +2. Validates that the document's `client_id` field matches the URL +3. Extracts client metadata (name, redirect URIs, scopes, etc.) +4. Stores the client persistently alongside DCR clients +5. Shows the verified domain in the consent screen + +This flow happens transparently. MCP clients that support CIMD simply provide their metadata URL instead of registering, and the OAuth proxy handles the rest. + +### CIMD Configuration + +CIMD support is enabled by default for `OAuthProxy`. + + + + Whether to accept CIMD URLs as client identifiers. When enabled, clients can use HTTPS URLs pointing to metadata documents as their `client_id` instead of registering via DCR. + + + +### Private Key JWT Authentication + +CIMD clients can authenticate using `private_key_jwt` instead of the default `none` authentication method. This provides cryptographic proof of client identity by signing JWT assertions with a private key, while the server verifies using the client's public key from their CIMD document. + +To use `private_key_jwt`, the CIMD document must include either a `jwks_uri` (URL to fetch the public key set) or inline `jwks` (the key set directly in the document): + +```json +{ + "client_id": "https://myapp.example.com/oauth/client.json", + "client_name": "My Secure App", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "private_key_jwt", + "jwks_uri": "https://myapp.example.com/.well-known/jwks.json" +} +``` + +The OAuth proxy validates JWT assertions according to RFC 7523, checking the signature, issuer, audience, subject claims, and preventing replay attacks via JTI tracking. + +### Security Considerations + +CIMD provides several security advantages over DCR: + +- **Verified identity**: The domain in the `client_id` URL is verified by HTTPS, so users know which organization is requesting access +- **No registration required**: Clients don't need to store or manage dynamically-issued credentials +- **Redirect URI enforcement**: CIMD documents must declare `redirect_uris`, which are enforced by the proxy (wildcard patterns supported) +- **SSRF protection**: The OAuth proxy blocks fetches to localhost, private IPs, and reserved addresses +- **Replay prevention**: For `private_key_jwt` clients, JTI claims are tracked to prevent assertion replay +- **Cache-aware fetching**: CIMD documents are cached according to HTTP cache headers and revalidated when required + +To disable CIMD support entirely (for example, to require all clients to register via DCR): + +```python +auth = OAuthProxy( + ..., + enable_cimd=False, +) +``` + ## Security ### Key and Storage Management diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 6941c7f79..86661bc16 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -232,6 +232,22 @@ OAuth scopes are configured with `required_scopes` to automatically request the Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. +## CIMD Support + + + +The OIDC proxy inherits full CIMD (Client ID Metadata Document) support from `OAuthProxy`. Clients can use HTTPS URLs as their `client_id` instead of registering dynamically, and the proxy will fetch and validate their metadata document. + +See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for complete details on how CIMD works, including private key JWT authentication and security considerations. + +The CIMD-related parameters available on `OIDCProxy` are: + + + + Whether to accept CIMD URLs as client identifiers. + + + ## Production Configuration For production deployments, load sensitive credentials from environment variables: diff --git a/examples/auth/github_oauth/client.py b/examples/auth/github_oauth/client.py index 5f1f39bb2..a7ab5c47e 100644 --- a/examples/auth/github_oauth/client.py +++ b/examples/auth/github_oauth/client.py @@ -8,14 +8,20 @@ To run: import asyncio -from fastmcp.client import Client +from fastmcp.client import Client, OAuth -SERVER_URL = "http://127.0.0.1:8000/mcp" +SERVER_URL = "http://localhost:8000/mcp" async def main(): try: - async with Client(SERVER_URL, auth="oauth") as client: + async with Client( + SERVER_URL, + auth=OAuth( + # Replace with your own CIMD document URL + client_metadata_url="https://www.jlowin.dev/mcp-client.json", + ), + ) as client: assert await client.ping() print("✅ Successfully authenticated!") diff --git a/loq.toml b/loq.toml index 4c6d7e28b..bbfe81827 100644 --- a/loq.toml +++ b/loq.toml @@ -76,7 +76,7 @@ max_lines = 1584 [[rules]] path = "src/fastmcp/server/auth/oauth_proxy/proxy.py" -max_lines = 1600 +max_lines = 1740 [[rules]] path = "tests/server/test_dependencies.py" diff --git a/src/fastmcp/cli/auth.py b/src/fastmcp/cli/auth.py new file mode 100644 index 000000000..4ea401b04 --- /dev/null +++ b/src/fastmcp/cli/auth.py @@ -0,0 +1,13 @@ +"""Authentication-related CLI commands.""" + +import cyclopts + +from fastmcp.cli.cimd import cimd_app + +auth_app = cyclopts.App( + name="auth", + help="Authentication-related utilities and configuration.", +) + +# Nest CIMD commands under auth +auth_app.command(cimd_app) diff --git a/src/fastmcp/cli/cimd.py b/src/fastmcp/cli/cimd.py new file mode 100644 index 000000000..d2def490c --- /dev/null +++ b/src/fastmcp/cli/cimd.py @@ -0,0 +1,218 @@ +"""CIMD (Client ID Metadata Document) CLI commands.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Annotated + +import cyclopts +from rich.console import Console + +from fastmcp.server.auth.cimd import ( + CIMDFetcher, + CIMDFetchError, + CIMDValidationError, +) +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli.cimd") +console = Console() + + +cimd_app = cyclopts.App( + name="cimd", + help="CIMD (Client ID Metadata Document) utilities for OAuth authentication.", +) + + +@cimd_app.command(name="create") +def create_command( + *, + name: Annotated[ + str, + cyclopts.Parameter(help="Human-readable name of the client application"), + ], + redirect_uri: Annotated[ + list[str], + cyclopts.Parameter( + name=["--redirect-uri", "-r"], + help="Allowed redirect URIs (can specify multiple)", + ), + ], + client_id: Annotated[ + str | None, + cyclopts.Parameter( + name="--client-id", + help="The URL where this document will be hosted (sets client_id directly)", + ), + ] = None, + client_uri: Annotated[ + str | None, + cyclopts.Parameter( + name="--client-uri", + help="URL of the client's home page", + ), + ] = None, + logo_uri: Annotated[ + str | None, + cyclopts.Parameter( + name="--logo-uri", + help="URL of the client's logo image", + ), + ] = None, + scope: Annotated[ + str | None, + cyclopts.Parameter( + name="--scope", + help="Space-separated list of scopes the client may request", + ), + ] = None, + output: Annotated[ + str | None, + cyclopts.Parameter( + name=["--output", "-o"], + help="Output file path (default: stdout)", + ), + ] = None, + pretty: Annotated[ + bool, + cyclopts.Parameter( + help="Pretty-print JSON output", + ), + ] = True, +) -> None: + """Generate a CIMD document for hosting. + + Create a Client ID Metadata Document that you can host at an HTTPS URL. + The URL where you host this document becomes your client_id. + + Example: + fastmcp cimd create --name "My App" -r "http://localhost:*/callback" + + After creating the document, host it at an HTTPS URL with a non-root path, + for example: https://myapp.example.com/oauth/client.json + """ + # Build the document + doc = { + "client_id": client_id or "https://YOUR-DOMAIN.com/path/to/client.json", + "client_name": name, + "redirect_uris": redirect_uri, + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code"], + "response_types": ["code"], + } + + # Add optional fields + if client_uri: + doc["client_uri"] = client_uri + if logo_uri: + doc["logo_uri"] = logo_uri + if scope: + doc["scope"] = scope + + # Format output + json_output = json.dumps(doc, indent=2) if pretty else json.dumps(doc) + + # Write output + if output: + output_path = Path(output).expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + f.write(json_output) + f.write("\n") + console.print(f"[green]✓[/green] CIMD document written to {output}") + if not client_id: + console.print( + "\n[yellow]Important:[/yellow] client_id is a placeholder. Update it to the URL where you will host this document, or re-run with --client-id." + ) + else: + print(json_output) + if not client_id: + # Print instructions to stderr so they don't interfere with piping + stderr_console = Console(stderr=True) + stderr_console.print( + "\n[yellow]Important:[/yellow] client_id is a placeholder." + " Update it to the URL where you will host this document," + " or re-run with --client-id." + ) + + +@cimd_app.command(name="validate") +def validate_command( + url: Annotated[ + str, + cyclopts.Parameter(help="URL of the CIMD document to validate"), + ], + *, + timeout: Annotated[ + float, + cyclopts.Parameter( + name=["--timeout", "-t"], + help="HTTP request timeout in seconds", + ), + ] = 10.0, +) -> None: + """Validate a hosted CIMD document. + + Fetches the document from the given URL and validates: + - URL is valid CIMD URL (HTTPS, non-root path) + - Document is valid JSON + - Document conforms to CIMD schema + - client_id in document matches the URL + + Example: + fastmcp cimd validate https://myapp.example.com/oauth/client.json + """ + + async def _validate() -> bool: + fetcher = CIMDFetcher(timeout=timeout) + + # Check URL format first + if not fetcher.is_cimd_client_id(url): + console.print(f"[red]✗[/red] Invalid CIMD URL: {url}") + console.print() + console.print("CIMD URLs must:") + console.print(" • Use HTTPS (not HTTP)") + console.print(" • Have a non-root path (e.g., /client.json, not just /)") + return False + + console.print(f"[blue]→[/blue] Fetching {url}...") + + try: + doc = await fetcher.fetch(url) + except CIMDFetchError as e: + console.print(f"[red]✗[/red] Failed to fetch document: {e}") + return False + except CIMDValidationError as e: + console.print(f"[red]✗[/red] Validation error: {e}") + return False + + # Success - show document details + console.print("[green]✓[/green] Valid CIMD document") + console.print() + console.print("[bold]Document details:[/bold]") + console.print(f" client_id: {doc.client_id}") + console.print(f" client_name: {doc.client_name or '(not set)'}") + console.print(f" token_endpoint_auth_method: {doc.token_endpoint_auth_method}") + + if doc.redirect_uris: + console.print(" redirect_uris:") + for uri in doc.redirect_uris: + console.print(f" • {uri}") + else: + console.print(" redirect_uris: (none)") + + if doc.scope: + console.print(f" scope: {doc.scope}") + + if doc.client_uri: + console.print(f" client_uri: {doc.client_uri}") + + return True + + success = asyncio.run(_validate()) + if not success: + sys.exit(1) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 147d9dc2d..5b9c24e4a 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -19,6 +19,7 @@ from rich.table import Table import fastmcp from fastmcp.cli import run as run_module +from fastmcp.cli.auth import auth_app from fastmcp.cli.client import call_command, discover_command, list_command from fastmcp.cli.generate import generate_cli_command from fastmcp.cli.install import install_app @@ -960,6 +961,9 @@ app.command(call_command, name="call") app.command(discover_command, name="discover") app.command(generate_cli_command, name="generate-cli") +# Add auth subcommand group (includes CIMD commands) +app.command(auth_app) + if __name__ == "__main__": app() diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 393844d07..9fc90b4e8 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -143,56 +143,82 @@ class OAuth(OAuthClientProvider): a browser for user authorization and running a local callback server. """ + _bound: bool + def __init__( self, - mcp_url: str, + mcp_url: str | None = None, scopes: str | list[str] | None = None, client_name: str = "FastMCP Client", token_storage: AsyncKeyValue | None = None, additional_client_metadata: dict[str, Any] | None = None, callback_port: int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, + client_metadata_url: str | None = None, ): """ Initialize OAuth client provider for an MCP server. Args: - mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/") + mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/"). + Optional when OAuth is passed to Client(auth=...), which provides + the URL automatically from the transport. scopes: OAuth scopes to request. Can be a space-separated string or a list of strings. client_name: Name for this client during registration token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided additional_client_metadata: Extra fields for OAuthClientMetadata callback_port: Fixed port for OAuth callback (default: random available port) + client_metadata_url: A CIMD (Client ID Metadata Document) URL. When + provided, this URL is used as the client_id instead of performing + Dynamic Client Registration. Must be an HTTPS URL with a non-root + path (e.g. "https://myapp.example.com/oauth/client.json"). """ - # Normalize the MCP URL (strip trailing slashes for consistency) + # Store config for deferred binding if mcp_url not yet known + self._scopes = scopes + self._client_name = client_name + self._token_storage = token_storage + self._additional_client_metadata = additional_client_metadata + self._callback_port = callback_port + self._client_metadata_url = client_metadata_url + self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient + self._bound = False + + if mcp_url is not None: + self._bind(mcp_url) + + def _bind(self, mcp_url: str) -> None: + """Bind this OAuth provider to a specific MCP server URL. + + Called automatically when mcp_url is provided to __init__, or by the + transport when OAuth is used without an explicit URL. + """ + if self._bound: + return + mcp_url = mcp_url.rstrip("/") - # Setup OAuth client - self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient - self.redirect_port = callback_port or find_available_port() + self.redirect_port = self._callback_port or find_available_port() redirect_uri = f"http://localhost:{self.redirect_port}/callback" scopes_str: str - if isinstance(scopes, list): - scopes_str = " ".join(scopes) - elif scopes is not None: - scopes_str = str(scopes) + if isinstance(self._scopes, list): + scopes_str = " ".join(self._scopes) + elif self._scopes is not None: + scopes_str = str(self._scopes) else: scopes_str = "" client_metadata = OAuthClientMetadata( - client_name=client_name, + client_name=self._client_name, redirect_uris=[AnyHttpUrl(redirect_uri)], grant_types=["authorization_code", "refresh_token"], response_types=["code"], - # token_endpoint_auth_method="client_secret_post", scope=scopes_str, - **(additional_client_metadata or {}), + **(self._additional_client_metadata or {}), ) - # Create server-specific token storage - token_storage = token_storage or MemoryStore() + token_storage = self._token_storage or MemoryStore() if isinstance(token_storage, MemoryStore): from warnings import warn @@ -204,23 +230,23 @@ class OAuth(OAuthClientProvider): stacklevel=2, ) - # Use full URL for token storage to properly separate tokens per MCP endpoint self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( async_key_value=token_storage, server_url=mcp_url ) - # Store full MCP URL for use in callback_handler display self.mcp_url = mcp_url - # Initialize parent class with full URL for proper OAuth metadata discovery super().__init__( server_url=mcp_url, client_metadata=client_metadata, storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, callback_handler=self.callback_handler, + client_metadata_url=self._client_metadata_url, ) + self._bound = True + async def _initialize(self) -> None: """Load stored tokens and client info, properly setting token expiry.""" # Call parent's _initialize to load tokens and client info @@ -298,6 +324,11 @@ class OAuth(OAuthClientProvider): If the OAuth flow fails due to invalid/stale client credentials, clears the cache and retries once with fresh registration. """ + if not self._bound: + raise RuntimeError( + "OAuth provider has no server URL. Either pass mcp_url to OAuth() " + "or use it with Client(auth=...) which provides the URL automatically." + ) try: # First attempt with potentially cached credentials async with aclosing(super().async_auth_flow(request)) as gen: diff --git a/src/fastmcp/client/transports/http.py b/src/fastmcp/client/transports/http.py index 89ad8fc62..83dbb7cc8 100644 --- a/src/fastmcp/client/transports/http.py +++ b/src/fastmcp/client/transports/http.py @@ -76,11 +76,17 @@ class StreamableHttpTransport(ClientTransport): self._get_session_id_cb: Callable[[], str | None] | None = None def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): + resolved: httpx.Auth | None if auth == "oauth": - auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + elif isinstance(auth, OAuth): + auth._bind(self.url) + resolved = auth elif isinstance(auth, str): - auth = BearerAuth(auth) - self.auth = auth + resolved = BearerAuth(auth) + else: + resolved = auth + self.auth: httpx.Auth | None = resolved @contextlib.asynccontextmanager async def connect_session( diff --git a/src/fastmcp/client/transports/sse.py b/src/fastmcp/client/transports/sse.py index ec932e6d2..45db01bee 100644 --- a/src/fastmcp/client/transports/sse.py +++ b/src/fastmcp/client/transports/sse.py @@ -48,11 +48,17 @@ class SSETransport(ClientTransport): self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): + resolved: httpx.Auth | None if auth == "oauth": - auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + elif isinstance(auth, OAuth): + auth._bind(self.url) + resolved = auth elif isinstance(auth, str): - auth = BearerAuth(auth) - self.auth = auth + resolved = BearerAuth(auth) + else: + resolved = auth + self.auth: httpx.Auth | None = resolved @contextlib.asynccontextmanager async def connect_session( diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index 9a804f05d..b8b8b1f8c 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse from mcp.server.auth.handlers.token import TokenErrorResponse @@ -9,7 +9,13 @@ from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend -from mcp.server.auth.middleware.client_auth import ClientAuthenticator +from mcp.server.auth.middleware.client_auth import ( + AuthenticationError, + ClientAuthenticator, +) +from mcp.server.auth.middleware.client_auth import ( + ClientAuthenticator as _SDKClientAuthenticator, +) from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) @@ -30,13 +36,18 @@ from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) +from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyHttpUrl, Field from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.requests import Request from starlette.routing import Route from fastmcp.utilities.logging import get_logger +if TYPE_CHECKING: + from fastmcp.server.auth.cimd import CIMDClientManager + logger = get_logger(__name__) @@ -108,6 +119,91 @@ class TokenHandler(_SDKTokenHandler): return response +# Expected assertion type for private_key_jwt +JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + +class PrivateKeyJWTClientAuthenticator(_SDKClientAuthenticator): + """Client authenticator with private_key_jwt support for CIMD clients. + + Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt` + authentication method per RFC 7523. This is required for CIMD (Client ID Metadata + Document) clients that use asymmetric keys for authentication. + + The authenticator: + 1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none) + 2. Adds private_key_jwt handling for CIMD clients + 3. Validates JWT assertions against client's JWKS + """ + + def __init__( + self, + provider: OAuthAuthorizationServerProvider[Any, Any, Any], + cimd_manager: CIMDClientManager, + token_endpoint_url: str, + ): + """Initialize the authenticator. + + Args: + provider: OAuth provider for client lookups + cimd_manager: CIMD manager for private_key_jwt validation + token_endpoint_url: Token endpoint URL for audience validation + """ + super().__init__(provider) + self._cimd_manager = cimd_manager + self._token_endpoint_url = token_endpoint_url + + async def authenticate_request( + self, request: Request + ) -> OAuthClientInformationFull: + """Authenticate a client from an HTTP request. + + Extends SDK authentication to support private_key_jwt for CIMD clients. + Delegates to SDK for client_secret_basic (Authorization header) and + client_secret_post (form body) authentication. + """ + form_data = await request.form() + client_id = form_data.get("client_id") + + # If client_id is not in form data, delegate to SDK + # This handles client_secret_basic which sends credentials in Authorization header + if not client_id: + return await super().authenticate_request(request) + + client = await self.provider.get_client(str(client_id)) + if not client: + raise AuthenticationError("Invalid client_id") + + # Handle private_key_jwt authentication for CIMD clients + if client.token_endpoint_auth_method == "private_key_jwt": + # Validate assertion parameters + assertion_type = form_data.get("client_assertion_type") + assertion = form_data.get("client_assertion") + + if assertion_type != JWT_BEARER_ASSERTION_TYPE: + raise AuthenticationError( + f"Invalid client_assertion_type: expected {JWT_BEARER_ASSERTION_TYPE}" + ) + + if not assertion or not isinstance(assertion, str): + raise AuthenticationError("Missing client_assertion") + + # Validate the JWT assertion using CIMD manager + try: + await self._cimd_manager.validate_private_key_jwt( + assertion=assertion, + client=client, + token_endpoint=self._token_endpoint_url, + ) + except ValueError as e: + raise AuthenticationError(f"Invalid client assertion: {e}") from e + + return client + + # Delegate to SDK for other authentication methods + return await super().authenticate_request(request) + + class AuthProvider(TokenVerifierProtocol): """Base class for all FastMCP authentication providers. diff --git a/src/fastmcp/server/auth/cimd.py b/src/fastmcp/server/auth/cimd.py new file mode 100644 index 000000000..49aa6687c --- /dev/null +++ b/src/fastmcp/server/auth/cimd.py @@ -0,0 +1,651 @@ +"""CIMD (Client ID Metadata Document) support for FastMCP. + +.. warning:: + **Beta Feature**: CIMD support is currently in beta. The API may change + in future releases. Please report any issues you encounter. + +CIMD is a simpler alternative to Dynamic Client Registration where clients +host a static JSON document at an HTTPS URL, and that URL becomes their +client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document + +This module provides: +- CIMDDocument: Pydantic model for CIMD document validation +- CIMDFetcher: Fetch and validate CIMD documents with SSRF protection +- CIMDClientManager: Manages CIMD client operations +""" + +from __future__ import annotations + +import fnmatch +import json +import time +from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import urlparse + +from pydantic import AnyHttpUrl, BaseModel, Field, field_validator + +from fastmcp.server.auth.ssrf import ( + SSRFError, + SSRFFetchError, + ssrf_safe_fetch, + validate_url, +) +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from fastmcp.server.auth.providers.jwt import JWTVerifier + +logger = get_logger(__name__) + + +class CIMDDocument(BaseModel): + """CIMD document per draft-parecki-oauth-client-id-metadata-document. + + The client metadata document is a JSON document containing OAuth client + metadata. The client_id property MUST match the URL where this document + is hosted. + + Key constraint: token_endpoint_auth_method MUST NOT use shared secrets + (client_secret_post, client_secret_basic, client_secret_jwt). + + redirect_uris is required and must contain at least one entry. + """ + + client_id: AnyHttpUrl = Field( + ..., + description="Must match the URL where this document is hosted", + ) + client_name: str | None = Field( + default=None, + description="Human-readable name of the client", + ) + client_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's home page", + ) + logo_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's logo image", + ) + redirect_uris: list[str] = Field( + ..., + description="Array of allowed redirect URIs (may include wildcards like http://localhost:*/callback)", + ) + token_endpoint_auth_method: Literal["none", "private_key_jwt"] = Field( + default="none", + description="Authentication method for token endpoint (no shared secrets allowed)", + ) + grant_types: list[str] = Field( + default_factory=lambda: ["authorization_code"], + description="OAuth grant types the client will use", + ) + response_types: list[str] = Field( + default_factory=lambda: ["code"], + description="OAuth response types the client will use", + ) + scope: str | None = Field( + default=None, + description="Space-separated list of scopes the client may request", + ) + contacts: list[str] | None = Field( + default=None, + description="Contact information for the client developer", + ) + tos_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's terms of service", + ) + policy_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's privacy policy", + ) + jwks_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's JSON Web Key Set (for private_key_jwt)", + ) + jwks: dict[str, Any] | None = Field( + default=None, + description="Client's JSON Web Key Set (for private_key_jwt)", + ) + software_id: str | None = Field( + default=None, + description="Unique identifier for the client software", + ) + software_version: str | None = Field( + default=None, + description="Version of the client software", + ) + + @field_validator("token_endpoint_auth_method") + @classmethod + def validate_auth_method(cls, v: str) -> str: + """Ensure no shared-secret auth methods are used.""" + forbidden = {"client_secret_post", "client_secret_basic", "client_secret_jwt"} + if v in forbidden: + raise ValueError( + f"CIMD documents cannot use shared-secret auth methods: {v}. " + "Use 'none' or 'private_key_jwt' instead." + ) + return v + + @field_validator("redirect_uris") + @classmethod + def validate_redirect_uris(cls, v: list[str]) -> list[str]: + """Ensure redirect_uris is non-empty and each entry is a valid URI.""" + if not v: + raise ValueError("CIMD documents must include at least one redirect_uri") + for uri in v: + if not uri or not uri.strip(): + raise ValueError("CIMD redirect_uris must be non-empty strings") + parsed = urlparse(uri) + if not parsed.scheme: + raise ValueError( + f"CIMD redirect_uri must have a scheme (e.g. http:// or https://): {uri!r}" + ) + if not parsed.netloc and not uri.startswith("urn:"): + raise ValueError(f"CIMD redirect_uri must have a host: {uri!r}") + return v + + +class CIMDValidationError(Exception): + """Raised when CIMD document validation fails.""" + + +class CIMDFetchError(Exception): + """Raised when CIMD document fetching fails.""" + + +class CIMDFetcher: + """Fetch and validate CIMD documents with SSRF protection. + + Delegates HTTP fetching to ssrf_safe_fetch which provides DNS pinning, + IP validation, size limits, and timeout enforcement. Documents are cached + with a simple TTL. + """ + + # Maximum response size (bytes) + MAX_RESPONSE_SIZE = 5120 # 5KB + # Default cache TTL (seconds) + DEFAULT_CACHE_TTL_SECONDS = 3600 + + def __init__( + self, + timeout: float = 10.0, + ): + """Initialize the CIMD fetcher. + + Args: + timeout: HTTP request timeout in seconds (default 10.0) + """ + self.timeout = timeout + self._cache: dict[str, tuple[CIMDDocument, float]] = {} + + def is_cimd_client_id(self, client_id: str) -> bool: + """Check if a client_id looks like a CIMD URL. + + CIMD URLs must be HTTPS with a host and non-root path. + """ + if not client_id: + return False + try: + parsed = urlparse(client_id) + return ( + parsed.scheme == "https" + and bool(parsed.netloc) + and parsed.path not in ("", "/") + ) + except (ValueError, AttributeError): + return False + + async def fetch(self, client_id_url: str) -> CIMDDocument: + """Fetch and validate a CIMD document with SSRF protection. + + Uses ssrf_safe_fetch for the HTTP layer, which provides: + - HTTPS only, DNS resolution with IP validation + - DNS pinning (connects to validated IP directly) + - Blocks private/loopback/link-local/multicast IPs + - Response size limit and timeout enforcement + - Redirects disabled + + Args: + client_id_url: The URL to fetch (also the expected client_id) + + Returns: + Validated CIMDDocument + + Raises: + CIMDValidationError: If document is invalid or URL blocked + CIMDFetchError: If document cannot be fetched + """ + cached = self._cache.get(client_id_url) + if cached is not None: + doc, expires_at = cached + if time.time() < expires_at: + return doc + + try: + content = await ssrf_safe_fetch( + client_id_url, + require_path=True, + max_size=self.MAX_RESPONSE_SIZE, + timeout=self.timeout, + overall_timeout=30.0, + ) + except SSRFError as e: + raise CIMDValidationError(str(e)) from e + except SSRFFetchError as e: + raise CIMDFetchError(str(e)) from e + + try: + data = json.loads(content) + except json.JSONDecodeError as e: + raise CIMDValidationError(f"CIMD document is not valid JSON: {e}") from e + + try: + doc = CIMDDocument.model_validate(data) + except Exception as e: + raise CIMDValidationError(f"Invalid CIMD document: {e}") from e + + if str(doc.client_id).rstrip("/") != client_id_url.rstrip("/"): + raise CIMDValidationError( + f"CIMD client_id mismatch: document says '{doc.client_id}' " + f"but was fetched from '{client_id_url}'" + ) + + # Validate jwks_uri if present (SSRF check for JWKS endpoint) + if doc.jwks_uri: + jwks_uri_str = str(doc.jwks_uri) + try: + await validate_url(jwks_uri_str) + except SSRFError as e: + raise CIMDValidationError( + f"CIMD jwks_uri failed SSRF validation: {e}" + ) from e + + logger.info( + "CIMD document fetched and validated: %s (client_name=%s)", + client_id_url, + doc.client_name, + ) + + self._cache[client_id_url] = (doc, time.time() + self.DEFAULT_CACHE_TTL_SECONDS) + return doc + + def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool: + """Validate that a redirect_uri is allowed by the CIMD document. + + Args: + doc: The CIMD document + redirect_uri: The redirect URI to validate + + Returns: + True if valid, False otherwise + """ + if not doc.redirect_uris: + # No redirect_uris specified - reject all + return False + + # Normalize for comparison + redirect_uri = redirect_uri.rstrip("/") + + for allowed in doc.redirect_uris: + allowed_str = allowed.rstrip("/") + if redirect_uri == allowed_str: + return True + + # Check for wildcard port matching (http://localhost:*/callback) + if "*" in allowed_str: + if fnmatch.fnmatch(redirect_uri, allowed_str): + return True + + return False + + +class CIMDAssertionValidator: + """Validates JWT assertions for private_key_jwt CIMD clients. + + Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client + Authentication and Authorization Grants) for CIMD client authentication. + + JTI replay protection uses TTL-based caching to ensure proper security: + - JTIs are cached with expiration matching the JWT's exp claim + - Expired JTIs are automatically cleaned up + - Maximum assertion lifetime is enforced (5 minutes) + """ + + # Maximum allowed assertion lifetime in seconds (RFC 7523 recommends short-lived) + MAX_ASSERTION_LIFETIME = 300 # 5 minutes + + def __init__(self): + # JTI cache: maps jti -> expiration timestamp + self._jti_cache: dict[str, float] = {} + self._jti_cache_max_size = 10000 + self._last_cleanup = time.monotonic() + self._cleanup_interval = 60 # Cleanup every 60 seconds + # Cache JWTVerifier per jwks_uri so JWKS keys are not re-fetched + # on every token exchange + self._verifier_cache: dict[str, JWTVerifier] = {} + self._verifier_cache_max_size = 100 + self.logger = get_logger(__name__) + + def _cleanup_expired_jtis(self) -> None: + """Remove expired JTIs from cache.""" + now = time.time() + expired = [jti for jti, exp in self._jti_cache.items() if exp < now] + for jti in expired: + del self._jti_cache[jti] + if expired: + self.logger.debug("Cleaned up %d expired JTIs from cache", len(expired)) + + def _maybe_cleanup(self) -> None: + """Periodically cleanup expired JTIs to prevent unbounded growth.""" + now = time.monotonic() + if now - self._last_cleanup > self._cleanup_interval: + self._cleanup_expired_jtis() + self._last_cleanup = now + + async def validate_assertion( + self, + assertion: str, + client_id: str, + token_endpoint: str, + cimd_doc: CIMDDocument, + ) -> bool: + """Validate JWT assertion from client. + + Args: + assertion: The JWT assertion string + client_id: Expected client_id (must match iss and sub claims) + token_endpoint: Token endpoint URL (must match aud claim) + cimd_doc: CIMD document containing JWKS for key verification + + Returns: + True if valid + + Raises: + ValueError: If validation fails + """ + from fastmcp.server.auth.providers.jwt import JWTVerifier as _JWTVerifier + + # Periodic cleanup of expired JTIs + self._maybe_cleanup() + + # 1. Validate CIMD document has key material and get/create verifier + if cimd_doc.jwks_uri: + jwks_uri_str = str(cimd_doc.jwks_uri) + cache_key = f"{jwks_uri_str}|{client_id}|{token_endpoint}" + verifier = self._verifier_cache.get(cache_key) + if verifier is None: + verifier = _JWTVerifier( + jwks_uri=jwks_uri_str, + issuer=client_id, + audience=token_endpoint, + ssrf_safe=True, + ) + if len(self._verifier_cache) >= self._verifier_cache_max_size: + oldest_key = next(iter(self._verifier_cache)) + del self._verifier_cache[oldest_key] + self._verifier_cache[cache_key] = verifier + elif cimd_doc.jwks: + # Inline JWKS — no caching since the key is embedded + public_key = self._extract_public_key_from_jwks(assertion, cimd_doc.jwks) + verifier = _JWTVerifier( + public_key=public_key, + issuer=client_id, + audience=token_endpoint, + ) + else: + raise ValueError( + "CIMD document must have jwks_uri or jwks for private_key_jwt" + ) + + # 2. Verify JWT using JWTVerifier (handles signature, exp, iss, aud) + access_token = await verifier.load_access_token(assertion) + if not access_token: + raise ValueError("Invalid JWT assertion") + + claims = access_token.claims + + # 3. Validate assertion lifetime (exp and iat) + now = time.time() + exp = claims.get("exp") + iat = claims.get("iat") + + if not exp: + raise ValueError("Assertion must include exp claim") + + # Validate exp is in the future (with small clock skew tolerance) + if exp < now - 30: # 30 second clock skew tolerance + raise ValueError("Assertion has expired") + + # If iat is present, validate it and check assertion lifetime + if iat: + if iat > now + 30: # 30 second clock skew tolerance + raise ValueError("Assertion iat is in the future") + if exp - iat > self.MAX_ASSERTION_LIFETIME: + raise ValueError( + f"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)" + ) + else: + # No iat, enforce max lifetime from now + if exp > now + self.MAX_ASSERTION_LIFETIME: + raise ValueError( + f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)" + ) + + # 4. Additional RFC 7523 validation: sub claim must equal client_id + if claims.get("sub") != client_id: + raise ValueError(f"Assertion sub claim must be {client_id}") + + # 5. Check jti for replay attacks (RFC 7523 requirement) + jti = claims.get("jti") + if not jti: + raise ValueError("Assertion must include jti claim") + + # Check if JTI was already used (and hasn't expired from cache) + if jti in self._jti_cache: + cached_exp = self._jti_cache[jti] + if cached_exp > now: # Still valid in cache + raise ValueError(f"Assertion replay detected: jti {jti} already used") + # Expired in cache, can be reused (clean it up) + del self._jti_cache[jti] + + # Add to cache with expiration time + # Use the assertion's exp claim so it stays cached until it would expire anyway + self._jti_cache[jti] = exp + + # Emergency size limit (shouldn't hit with proper TTL cleanup) + if len(self._jti_cache) > self._jti_cache_max_size: + self._cleanup_expired_jtis() + # If still over limit after cleanup, reject to prevent DoS + if len(self._jti_cache) > self._jti_cache_max_size: + self.logger.warning( + "JTI cache at max capacity (%d), possible attack", + self._jti_cache_max_size, + ) + raise ValueError("Server overloaded, please retry") + + self.logger.debug( + "JWT assertion validated successfully for client %s", client_id + ) + return True + + def _extract_public_key_from_jwks(self, token: str, jwks: dict) -> str: + """Extract public key from inline JWKS. + + Args: + token: JWT token to extract kid from + jwks: JWKS document containing keys + + Returns: + PEM-encoded public key + + Raises: + ValueError: If key cannot be found or extracted + """ + import base64 + import json + + from authlib.jose import JsonWebKey + + # Extract kid from token header + try: + header_b64 = token.split(".")[0] + header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding + header = json.loads(base64.urlsafe_b64decode(header_b64)) + kid = header.get("kid") + except Exception as e: + raise ValueError(f"Failed to extract key ID from token: {e}") from e + + # Find matching key in JWKS + keys = jwks.get("keys", []) + if not keys: + raise ValueError("JWKS document contains no keys") + + matching_key = None + for key in keys: + if kid and key.get("kid") == kid: + matching_key = key + break + + if not matching_key: + # If no kid match, try first key as fallback + if len(keys) == 1: + matching_key = keys[0] + self.logger.warning( + "No matching kid in JWKS, using single available key" + ) + else: + raise ValueError(f"No matching key found for kid={kid} in JWKS") + + # Convert JWK to PEM + try: + jwk = JsonWebKey.import_key(matching_key) + return jwk.as_pem().decode("utf-8") + except Exception as e: + raise ValueError(f"Failed to convert JWK to PEM: {e}") from e + + +class CIMDClientManager: + """Manages all CIMD client operations for OAuth proxy. + + This class encapsulates: + - CIMD client detection + - Document fetching and validation + - Synthetic OAuth client creation + - Private key JWT assertion validation + + This allows the OAuth proxy to delegate all CIMD-specific logic to a + single, focused manager class. + """ + + def __init__( + self, + enable_cimd: bool = True, + default_scope: str = "", + allowed_redirect_uri_patterns: list[str] | None = None, + ): + """Initialize CIMD client manager. + + Args: + enable_cimd: Whether CIMD support is enabled + default_scope: Default scope for CIMD clients if not specified in document + allowed_redirect_uri_patterns: Allowed redirect URI patterns (proxy's config) + """ + self.enabled = enable_cimd + self.default_scope = default_scope + self.allowed_redirect_uri_patterns = allowed_redirect_uri_patterns + + self._fetcher = CIMDFetcher() + self._assertion_validator = CIMDAssertionValidator() + self.logger = get_logger(__name__) + + def is_cimd_client_id(self, client_id: str) -> bool: + """Check if client_id is a CIMD URL. + + Args: + client_id: Client ID to check + + Returns: + True if client_id is an HTTPS URL (CIMD format) + """ + return self.enabled and self._fetcher.is_cimd_client_id(client_id) + + async def get_client(self, client_id_url: str): + """Fetch CIMD document and create synthetic OAuth client. + + Args: + client_id_url: HTTPS URL pointing to CIMD document + + Returns: + OAuthProxyClient with CIMD document attached, or None if fetch fails + + Note: + Return type is left untyped to avoid circular import with oauth_proxy. + Returns OAuthProxyClient instance or None. + """ + if not self.enabled: + return None + + try: + cimd_doc = await self._fetcher.fetch(client_id_url) + except (CIMDFetchError, CIMDValidationError) as e: + self.logger.warning("CIMD fetch failed for %s: %s", client_id_url, e) + return None + + # Import here to avoid circular dependency + from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient + + # Create synthetic client from CIMD document. + # Keep CIMD redirect_uris as strings on the document itself so wildcard + # patterns like http://localhost:*/callback remain valid. + redirect_uris = None + client = ProxyDCRClient( + client_id=client_id_url, + client_secret=None, + redirect_uris=redirect_uris, + grant_types=cimd_doc.grant_types, + scope=cimd_doc.scope or self.default_scope, + token_endpoint_auth_method=cimd_doc.token_endpoint_auth_method, + allowed_redirect_uri_patterns=self.allowed_redirect_uri_patterns, + client_name=cimd_doc.client_name, + cimd_document=cimd_doc, + cimd_fetched_at=time.time(), + ) + + self.logger.debug( + "CIMD client resolved: %s (name=%s)", + client_id_url, + cimd_doc.client_name, + ) + return client + + async def validate_private_key_jwt( + self, + assertion: str, + client, # OAuthProxyClient, untyped to avoid circular import + token_endpoint: str, + ) -> bool: + """Validate JWT assertion for private_key_jwt auth. + + Args: + assertion: JWT assertion string from client + client: OAuth proxy client (must have cimd_document) + token_endpoint: Token endpoint URL for aud validation + + Returns: + True if assertion is valid + + Raises: + ValueError: If client doesn't have CIMD document or validation fails + """ + if not hasattr(client, "cimd_document") or not client.cimd_document: + raise ValueError("Client must have CIMD document for private_key_jwt") + + cimd_doc = client.cimd_document + if cimd_doc.token_endpoint_auth_method != "private_key_jwt": + raise ValueError("CIMD document must specify private_key_jwt auth method") + + return await self._assertion_validator.validate_assertion( + assertion, client.client_id, token_endpoint, cimd_doc + ) diff --git a/src/fastmcp/server/auth/oauth_proxy/consent.py b/src/fastmcp/server/auth/oauth_proxy/consent.py index 6f47a5da7..87b63d88f 100644 --- a/src/fastmcp/server/auth/oauth_proxy/consent.py +++ b/src/fastmcp/server/auth/oauth_proxy/consent.py @@ -21,6 +21,7 @@ from pydantic import AnyUrl from starlette.requests import Request from starlette.responses import HTMLResponse, RedirectResponse +from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient from fastmcp.server.auth.oauth_proxy.ui import create_consent_html from fastmcp.utilities.logging import get_logger from fastmcp.utilities.ui import create_secure_html_response @@ -245,10 +246,17 @@ class ConsentMixin: txn["csrf_token"] = csrf_token txn["csrf_expires_at"] = csrf_expires_at - # Load client to get client_name if available + # Load client to get client_name and CIMD info if available client = await self.get_client(txn["client_id"]) client_name = getattr(client, "client_name", None) if client else None + # Detect CIMD clients for verified domain badge + is_cimd_client = False + cimd_domain: str | None = None + if isinstance(client, ProxyDCRClient) and client.cimd_document is not None: + is_cimd_client = True + cimd_domain = urlparse(txn["client_id"]).hostname + # Extract server metadata from app state fastmcp = getattr(request.app.state, "fastmcp_server", None) @@ -273,6 +281,8 @@ class ConsentMixin: server_icon_url=server_icon_url, server_website_url=server_website_url, csp_policy=self._consent_csp_policy, + is_cimd_client=is_cimd_client, + cimd_domain=cimd_domain, ) response = create_secure_html_response(html) # Store CSRF in cookie with short lifetime diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py index 53c939f2a..575c846ba 100644 --- a/src/fastmcp/server/auth/oauth_proxy/models.py +++ b/src/fastmcp/server/auth/oauth_proxy/models.py @@ -11,7 +11,11 @@ from typing import Any, Final from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull from pydantic import AnyUrl, BaseModel, Field -from fastmcp.server.auth.redirect_validation import validate_redirect_uri +from fastmcp.server.auth.cimd import CIMDDocument +from fastmcp.server.auth.redirect_validation import ( + matches_allowed_pattern, + validate_redirect_uri, +) # ------------------------------------------------------------------------- # Constants @@ -156,28 +160,77 @@ class ProxyDCRClient(OAuthClientInformationFull): allowed_redirect_uri_patterns: list[str] | None = Field(default=None) client_name: str | None = Field(default=None) + cimd_document: CIMDDocument | None = Field(default=None) + cimd_fetched_at: float | None = Field(default=None) def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: - """Validate redirect URI against allowed patterns. + """Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. - Since we're acting as a proxy and clients register dynamically, - we validate their redirect URIs against configurable patterns. - This is essential for cached token scenarios where the client may - reconnect with a different port. + For CIMD clients: validates against BOTH the CIMD document's redirect_uris + AND the proxy's allowed patterns (if configured). Both must pass. + + For DCR clients: validates against proxy patterns first, falling back to + base validation (registered redirect_uris) if patterns don't match. """ + if redirect_uri is None and self.cimd_document is not None: + cimd_redirect_uris = self.cimd_document.redirect_uris + if len(cimd_redirect_uris) == 1: + candidate = cimd_redirect_uris[0] + if "*" in candidate: + raise InvalidRedirectUriError( + "redirect_uri must be specified when CIMD redirect_uris uses wildcards." + ) + try: + return AnyUrl(candidate) + except Exception as e: + raise InvalidRedirectUriError( + f"Invalid CIMD redirect_uri: {e}" + ) from e + + raise InvalidRedirectUriError( + "redirect_uri must be specified when CIMD lists multiple redirect_uris." + ) + if redirect_uri is not None: - # Validate against allowed patterns - if validate_redirect_uri( - redirect_uri=redirect_uri, - allowed_patterns=self.allowed_redirect_uri_patterns, - ): + cimd_redirect_uris = ( + self.cimd_document.redirect_uris if self.cimd_document else None + ) + + if cimd_redirect_uris: + uri_str = str(redirect_uri) + cimd_match = any( + matches_allowed_pattern(uri_str, pattern) + for pattern in cimd_redirect_uris + ) + if not cimd_match: + raise InvalidRedirectUriError( + f"Redirect URI '{redirect_uri}' does not match CIMD redirect_uris." + ) + + if self.allowed_redirect_uri_patterns: + if not validate_redirect_uri( + redirect_uri=redirect_uri, + allowed_patterns=self.allowed_redirect_uri_patterns, + ): + raise InvalidRedirectUriError( + f"Redirect URI '{redirect_uri}' does not match allowed patterns." + ) + return redirect_uri - # If patterns are explicitly configured then reject non-matching URIs + pattern_matches = validate_redirect_uri( + redirect_uri=redirect_uri, + allowed_patterns=self.allowed_redirect_uri_patterns, + ) + + if pattern_matches: + return redirect_uri + + # Patterns configured but didn't match if self.allowed_redirect_uri_patterns: raise InvalidRedirectUriError( f"Redirect URI '{redirect_uri}' does not match allowed patterns." ) - # If no redirect_uri provided, use default behavior + # No redirect_uri provided or no patterns configured — use base validation return super().validate_redirect_uri(redirect_uri) diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index e1a24720f..27773ef25 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -32,6 +32,7 @@ from cryptography.fernet import Fernet from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue from key_value.aio.wrappers.encryption import FernetEncryptionWrapper +from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, @@ -40,6 +41,7 @@ from mcp.server.auth.provider import ( RefreshToken, TokenError, ) +from mcp.server.auth.routes import build_metadata, cors_middleware from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, @@ -52,7 +54,13 @@ from starlette.routing import Route from typing_extensions import override from fastmcp import settings -from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier +from fastmcp.server.auth.auth import ( + OAuthProvider, + PrivateKeyJWTClientAuthenticator, + TokenHandler, + TokenVerifier, +) +from fastmcp.server.auth.cimd import CIMDClientManager from fastmcp.server.auth.handlers.authorize import AuthorizationHandler from fastmcp.server.auth.jwt_issuer import ( JWTIssuer, @@ -248,6 +256,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): consent_csp_policy: str | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, + # CIMD (Client ID Metadata Document) support + enable_cimd: bool = True, ): """Initialize the OAuth proxy provider. @@ -302,6 +312,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): defaults: 1 hour if a refresh token is available (since we can refresh), or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). Set explicitly to override these defaults. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs. When True, clients can authenticate using HTTPS URLs as client + IDs, with metadata fetched from the URL. Supports private_key_jwt auth. """ # Always enable DCR since we implement it locally for MCP clients @@ -484,6 +497,15 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Use the provided token validator self._token_validator: TokenVerifier = token_verifier + # CIMD (Client ID Metadata Document) support + self._cimd_manager: CIMDClientManager | None = None + if enable_cimd: + self._cimd_manager = CIMDClientManager( + enable_cimd=True, + default_scope=self._default_scope_str, + allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, + ) + logger.debug( "Initialized OAuth proxy provider with upstream server %s", self._upstream_authorization_endpoint, @@ -559,15 +581,43 @@ class OAuthProxy(OAuthProvider, ConsentMixin): provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). + CIMD clients (URL-based client IDs) are looked up and cached automatically. """ # Load from storage - if not (client := await self._client_store.get(key=client_id)): - return None + client = await self._client_store.get(key=client_id) - if client.allowed_redirect_uri_patterns is None: - client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris + if client is not None: + if client.allowed_redirect_uri_patterns is None: + client.allowed_redirect_uri_patterns = ( + self._allowed_client_redirect_uris + ) - return client + # Refresh CIMD clients using HTTP cache-aware fetcher. + if self._cimd_manager is not None and client.cimd_document is not None: + try: + refreshed = await self._cimd_manager.get_client(client_id) + if refreshed is not None: + await self._client_store.put(key=client_id, value=refreshed) + return refreshed + except Exception as e: + logger.debug( + "CIMD refresh failed for %s, using cached client: %s", + client_id, + e, + ) + + return client + + # Client not in storage — try CIMD lookup for URL-based client IDs + if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id( + client_id + ): + cimd_client = await self._cimd_manager.get_client(client_id) + if cimd_client is not None: + await self._client_store.put(key=client_id, value=cimd_client) + return cimd_client + + return None @override async def register_client(self, client_info: OAuthClientInformationFull) -> None: @@ -1437,6 +1487,61 @@ class OAuthProxy(OAuthProvider, ConsentMixin): methods=["GET", "POST"], ) ) + elif ( + self._cimd_manager is not None + and isinstance(route, Route) + and route.path == "/token" + and route.methods is not None + and "POST" in route.methods + ): + # Replace the token endpoint authenticator with one that supports + # private_key_jwt for CIMD clients + token_endpoint_url = f"{self.base_url}/token" + cimd_authenticator = PrivateKeyJWTClientAuthenticator( + provider=self, + cimd_manager=self._cimd_manager, + token_endpoint_url=token_endpoint_url, + ) + token_handler = TokenHandler( + provider=self, client_authenticator=cimd_authenticator + ) + custom_routes.append( + Route( + path="/token", + endpoint=cors_middleware( + token_handler.handle, ["POST", "OPTIONS"] + ), + methods=["POST", "OPTIONS"], + ) + ) + elif ( + self._cimd_manager is not None + and isinstance(route, Route) + and route.path.startswith("/.well-known/oauth-authorization-server") + ): + client_registration_options = ( + self.client_registration_options or ClientRegistrationOptions() + ) + revocation_options = self.revocation_options or RevocationOptions() + metadata = build_metadata( + self.base_url, # ty: ignore[invalid-argument-type] + self.service_documentation_url, + client_registration_options, + revocation_options, + ) + metadata.client_id_metadata_document_supported = True + handler = MetadataHandler(metadata) + methods = route.methods or ["GET", "OPTIONS"] + + custom_routes.append( + Route( + path=route.path, + endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), + methods=methods, + name=route.name, + include_in_schema=route.include_in_schema, + ) + ) else: # Keep all other standard OAuth routes unchanged custom_routes.append(route) diff --git a/src/fastmcp/server/auth/oauth_proxy/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py index 3bae1a11c..4cbb3ec2c 100644 --- a/src/fastmcp/server/auth/oauth_proxy/ui.py +++ b/src/fastmcp/server/auth/oauth_proxy/ui.py @@ -32,6 +32,8 @@ def create_consent_html( server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None, + is_cimd_client: bool = False, + cimd_domain: str | None = None, ) -> str: """Create a styled HTML consent page for OAuth authorization requests. @@ -60,6 +62,17 @@ def create_consent_html( """ + # Build CIMD verified domain badge if applicable + cimd_badge = "" + if is_cimd_client and cimd_domain: + cimd_domain_escaped = html_module.escape(cimd_domain) + cimd_badge = f""" +
+ + Verified domain: {cimd_domain_escaped} +
+ """ + # Build redirect URI section (yellow box, centered) redirect_uri_escaped = html_module.escape(redirect_uri) redirect_section = f""" @@ -144,6 +157,7 @@ def create_consent_html( {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}

Application Access Request

{intro_box} + {cimd_badge} {redirect_section} {advanced_details} {form} @@ -152,6 +166,23 @@ def create_consent_html( """ # Additional styles needed for this page + cimd_badge_styles = """ + .cimd-badge { + background: #ecfdf5; + border: 1px solid #6ee7b7; + border-radius: 8px; + padding: 8px 16px; + margin-bottom: 16px; + font-size: 14px; + color: #065f46; + text-align: center; + } + .cimd-check { + color: #059669; + font-weight: bold; + margin-right: 4px; + } + """ additional_styles = ( INFO_BOX_STYLES + REDIRECT_SECTION_STYLES @@ -159,6 +190,7 @@ def create_consent_html( + DETAIL_BOX_STYLES + BUTTON_STYLES + TOOLTIP_STYLES + + cimd_badge_styles ) # Determine CSP policy to use diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 1bcdef4e4..d89ac0756 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -228,6 +228,8 @@ class OIDCProxy(OAuthProxy): extra_token_params: dict[str, str] | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, + # CIMD configuration + enable_cimd: bool = True, ) -> None: """Initialize the OIDC proxy provider. @@ -278,6 +280,9 @@ class OIDCProxy(OAuthProxy): doesn't return `expires_in` in the token response. If not set, uses smart defaults: 1 hour if a refresh token is available (since we can refresh), or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). + enable_cimd: Whether to enable CIMD (Client ID Metadata Document) client support. + When True, clients can use their metadata document URL as client_id instead of + Dynamic Client Registration. Default is True. """ if not config_url: raise ValueError("Missing required config URL") @@ -351,6 +356,7 @@ class OIDCProxy(OAuthProxy): "require_authorization_consent": require_authorization_consent, "consent_csp_policy": consent_csp_policy, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, + "enable_cimd": enable_cimd, } if redirect_path: diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index fd01c2f2c..828b9238f 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import time from dataclasses import dataclass from typing import Any, cast @@ -15,6 +16,7 @@ from pydantic import AnyHttpUrl, SecretStr from typing_extensions import TypedDict from fastmcp.server.auth import AccessToken, TokenVerifier +from fastmcp.server.auth.ssrf import SSRFError, SSRFFetchError, ssrf_safe_fetch from fastmcp.utilities.auth import decode_jwt_header, parse_scopes from fastmcp.utilities.logging import get_logger @@ -165,6 +167,7 @@ class JWTVerifier(TokenVerifier): algorithm: str | None = None, required_scopes: list[str] | None = None, base_url: AnyHttpUrl | str | None = None, + ssrf_safe: bool = False, ): """ Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint. @@ -177,6 +180,10 @@ class JWTVerifier(TokenVerifier): algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. 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, + public IPs, DNS pinning). Enable when the JWKS URI comes from + untrusted input (e.g. CIMD documents). Defaults to False so + operator-configured JWKS URIs (including localhost) work normally. Raises: ValueError: If neither or both of `public_key` and `jwks_uri` are provided, or if `algorithm` is unsupported. @@ -220,6 +227,7 @@ class JWTVerifier(TokenVerifier): self.audience = audience self.public_key = public_key self.jwks_uri = jwks_uri + self.ssrf_safe = ssrf_safe self.jwt = JsonWebToken([self.algorithm]) self.logger = get_logger(__name__) @@ -239,11 +247,11 @@ class JWTVerifier(TokenVerifier): kid = header.get("kid") return await self._get_jwks_key(kid) - except Exception as e: + except (ValueError, KeyError, IndexError, json.JSONDecodeError) as e: raise ValueError(f"Failed to extract key ID from token: {e}") from e async def _get_jwks_key(self, kid: str | None) -> str: - """Fetch key from JWKS with simple caching.""" + """Fetch key from JWKS with simple caching and SSRF protection.""" if not self.jwks_uri: raise ValueError("JWKS URI not configured") @@ -257,12 +265,9 @@ class JWTVerifier(TokenVerifier): # If no kid but only one key cached, use it return next(iter(self._jwks_cache.values())) - # Fetch JWKS + # Fetch JWKS — with SSRF protection when enabled (untrusted URIs) try: - async with httpx.AsyncClient() as client: - response = await client.get(self.jwks_uri) - response.raise_for_status() - jwks_data = response.json() + jwks_data = await self._fetch_jwks() # Cache all keys self._jwks_cache = {} @@ -298,11 +303,35 @@ class JWTVerifier(TokenVerifier): else: raise ValueError("No keys found in JWKS") + except (SSRFError, SSRFFetchError) as e: + self.logger.debug("JWKS fetch blocked by SSRF protection: %s", e) + raise ValueError(f"Failed to fetch JWKS: {e}") from e except httpx.HTTPError as e: raise ValueError(f"Failed to fetch JWKS: {e}") from e - except Exception as e: - self.logger.debug(f"JWKS fetch failed: {e}") - raise ValueError(f"Failed to fetch JWKS: {e}") from e + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JWKS JSON: {e}") from e + except (JoseError, TypeError, KeyError) as e: + self.logger.debug("JWKS key processing failed: %s", e) + raise ValueError(f"Failed to process JWKS: {e}") from e + + async def _fetch_jwks(self) -> dict[str, Any]: + """Fetch JWKS data, using SSRF-safe or standard fetch based on config.""" + if not self.jwks_uri: + raise ValueError("JWKS URI not configured") + + if self.ssrf_safe: + content = await ssrf_safe_fetch( + self.jwks_uri, + max_size=65536, + timeout=10.0, + overall_timeout=30.0, + ) + return json.loads(content) + else: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: + response = await client.get(self.jwks_uri) + response.raise_for_status() + return response.json() def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: """ @@ -435,7 +464,7 @@ class JWTVerifier(TokenVerifier): except JoseError: self.logger.debug("Token validation failed: JWT signature/format invalid") return None - except Exception as e: + except (ValueError, TypeError, KeyError, AttributeError) as e: self.logger.debug("Token validation failed: %s", str(e)) return None diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py index f49958ad5..4d011416f 100644 --- a/src/fastmcp/server/auth/redirect_validation.py +++ b/src/fastmcp/server/auth/redirect_validation.py @@ -1,19 +1,138 @@ -"""Utilities for validating client redirect URIs in OAuth flows.""" +"""Utilities for validating client redirect URIs in OAuth flows. + +This module provides secure redirect URI validation with wildcard support, +protecting against userinfo-based bypass attacks like http://localhost@evil.com. +""" import fnmatch +from urllib.parse import urlparse from pydantic import AnyUrl -def matches_allowed_pattern(uri: str, pattern: str) -> bool: - """Check if a URI matches an allowed pattern with wildcard support. +def _parse_host_port(netloc: str) -> tuple[str | None, str | None]: + """Parse host and port from netloc, handling wildcards. - Patterns support * wildcard matching: + Args: + netloc: The netloc component (e.g., "localhost:8080" or "localhost:*") + + Returns: + Tuple of (host, port_str) where port_str may be "*" or a number string + """ + # Handle userinfo (remove it for parsing, but we check separately) + if "@" in netloc: + netloc = netloc.split("@")[-1] + + # Handle IPv6 addresses [::1]:port + if netloc.startswith("["): + bracket_end = netloc.find("]") + if bracket_end == -1: + return netloc, None + host = netloc[1:bracket_end] + rest = netloc[bracket_end + 1 :] + if rest.startswith(":"): + return host, rest[1:] + return host, None + + # Handle regular host:port + if ":" in netloc: + host, port = netloc.rsplit(":", 1) + return host, port + + return netloc, None + + +def _match_host(uri_host: str | None, pattern_host: str | None) -> bool: + """Match host component, supporting *.example.com wildcard patterns. + + Args: + uri_host: The host from the URI being validated + pattern_host: The host pattern (may start with *.) + + Returns: + True if the host matches + """ + if not uri_host or not pattern_host: + return uri_host == pattern_host + + # Normalize to lowercase for comparison + uri_host = uri_host.lower() + pattern_host = pattern_host.lower() + + # Handle *.example.com wildcard subdomain patterns + if pattern_host.startswith("*."): + suffix = pattern_host[1:] # .example.com + # Only match actual subdomains (foo.example.com), NOT the base domain + return uri_host.endswith(suffix) and uri_host != pattern_host[2:] + + return uri_host == pattern_host + + +def _match_port( + uri_port: str | None, + pattern_port: str | None, + uri_scheme: str, +) -> bool: + """Match port component, supporting * wildcard for any port. + + Args: + uri_port: The port from the URI (None if default, string otherwise) + pattern_port: The port from the pattern (None if default, "*" for wildcard) + uri_scheme: The URI scheme (http/https) for default port handling + + Returns: + True if the port matches + """ + # Wildcard matches any port + if pattern_port == "*": + return True + + # Normalize None to default ports + default_port = "443" if uri_scheme == "https" else "80" + uri_effective = uri_port if uri_port else default_port + pattern_effective = pattern_port if pattern_port else default_port + + return uri_effective == pattern_effective + + +def _match_path(uri_path: str, pattern_path: str) -> bool: + """Match path component using fnmatch for wildcard support. + + Args: + uri_path: The path from the URI + pattern_path: The path pattern (may contain * wildcards) + + Returns: + True if the path matches + """ + # Normalize empty paths to / + uri_path = uri_path or "/" + pattern_path = pattern_path or "/" + + # Empty or root pattern path matches any path + # This makes http://localhost:* match http://localhost:3000/callback + if pattern_path == "/": + return True + + # Use fnmatch for path wildcards (e.g., /auth/*) + return fnmatch.fnmatch(uri_path, pattern_path) + + +def matches_allowed_pattern(uri: str, pattern: str) -> bool: + """Securely check if a URI matches an allowed pattern with wildcard support. + + This function parses both the URI and pattern as URLs, comparing each + component separately to prevent bypass attacks like userinfo injection. + + Patterns support wildcards: - http://localhost:* matches any localhost port - http://127.0.0.1:* matches any 127.0.0.1 port - https://*.example.com/* matches any subdomain of example.com - https://app.example.com/auth/* matches any path under /auth/ + Security: Rejects URIs with userinfo (user:pass@host) which could bypass + naive string matching (e.g., http://localhost@evil.com). + Args: uri: The redirect URI to validate pattern: The allowed pattern (may contain wildcards) @@ -21,8 +140,36 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool: Returns: True if the URI matches the pattern """ - # Use fnmatch for wildcard matching - return fnmatch.fnmatch(uri, pattern) + try: + uri_parsed = urlparse(uri) + pattern_parsed = urlparse(pattern) + except ValueError: + return False + + # SECURITY: Reject URIs with userinfo (user:pass@host) + # This prevents bypass attacks like http://localhost@evil.com/callback + # which would match http://localhost:* with naive fnmatch + if uri_parsed.username is not None or uri_parsed.password is not None: + return False + + # Scheme must match exactly + if uri_parsed.scheme.lower() != pattern_parsed.scheme.lower(): + return False + + # Parse host and port manually to handle wildcards + uri_host, uri_port = _parse_host_port(uri_parsed.netloc) + pattern_host, pattern_port = _parse_host_port(pattern_parsed.netloc) + + # Host must match (with subdomain wildcard support) + if not _match_host(uri_host, pattern_host): + return False + + # Port must match (with * wildcard support) + if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): + return False + + # Path must match (with fnmatch wildcards) + return _match_path(uri_parsed.path, pattern_parsed.path) def validate_redirect_uri( diff --git a/src/fastmcp/server/auth/ssrf.py b/src/fastmcp/server/auth/ssrf.py new file mode 100644 index 000000000..8009269c6 --- /dev/null +++ b/src/fastmcp/server/auth/ssrf.py @@ -0,0 +1,307 @@ +"""SSRF-safe HTTP utilities for FastMCP. + +This module provides SSRF-protected HTTP fetching with: +- DNS resolution and IP validation before requests +- DNS pinning to prevent rebinding TOCTOU attacks +- Support for both CIMD and JWKS fetches +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import socket +import time +from dataclasses import dataclass +from urllib.parse import urlparse + +import httpx + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +def format_ip_for_url(ip_str: str) -> str: + """Format IP address for use in URL (bracket IPv6 addresses). + + IPv6 addresses must be bracketed in URLs to distinguish the address from + the port separator. For example: https://[2001:db8::1]:443/path + + Args: + ip_str: IP address string + + Returns: + IP string suitable for URL (IPv6 addresses are bracketed) + """ + try: + ip = ipaddress.ip_address(ip_str) + if isinstance(ip, ipaddress.IPv6Address): + return f"[{ip_str}]" + return ip_str + except ValueError: + return ip_str + + +class SSRFError(Exception): + """Raised when an SSRF protection check fails.""" + + +class SSRFFetchError(Exception): + """Raised when SSRF-safe fetch fails.""" + + +def is_ip_allowed(ip_str: str) -> bool: + """Check if an IP address is allowed (must be globally routable unicast). + + Uses ip.is_global which catches: + - Private (10.x, 172.16-31.x, 192.168.x) + - Loopback (127.x, ::1) + - Link-local (169.254.x, fe80::) - includes AWS metadata! + - Reserved, unspecified + - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks + + Additionally blocks multicast addresses (not caught by is_global). + + Args: + ip_str: IP address string to check + + Returns: + True if the IP is allowed (public unicast internet), False if blocked + """ + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return False + + if not ip.is_global: + return False + + # Block multicast (not caught by is_global for some ranges) + if ip.is_multicast: + return False + + # IPv6-specific checks for embedded IPv4 addresses + if isinstance(ip, ipaddress.IPv6Address): + if ip.ipv4_mapped: + return is_ip_allowed(str(ip.ipv4_mapped)) + if ip.sixtofour: + return is_ip_allowed(str(ip.sixtofour)) + if ip.teredo: + server, client = ip.teredo + return is_ip_allowed(str(server)) and is_ip_allowed(str(client)) + + return True + + +async def resolve_hostname(hostname: str, port: int = 443) -> list[str]: + """Resolve hostname to IP addresses using DNS. + + Args: + hostname: Hostname to resolve + port: Port number (used for getaddrinfo) + + Returns: + List of resolved IP addresses + + Raises: + SSRFError: If resolution fails + """ + loop = asyncio.get_running_loop() + try: + infos = await loop.run_in_executor( + None, + lambda: socket.getaddrinfo( + hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM + ), + ) + ips = list({info[4][0] for info in infos}) + if not ips: + raise SSRFError(f"DNS resolution returned no addresses for {hostname}") + return ips + except socket.gaierror as e: + raise SSRFError(f"DNS resolution failed for {hostname}: {e}") from e + + +@dataclass +class ValidatedURL: + """A URL that has been validated for SSRF with resolved IPs.""" + + original_url: str + hostname: str + port: int + path: str + resolved_ips: list[str] + + +async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: + """Validate URL for SSRF and resolve to IPs. + + Args: + url: URL to validate + require_path: If True, require non-root path (for CIMD) + + Returns: + ValidatedURL with resolved IPs + + Raises: + SSRFError: If URL is invalid or resolves to blocked IPs + """ + try: + parsed = urlparse(url) + except (ValueError, AttributeError) as e: + raise SSRFError(f"Invalid URL: {e}") from e + + if parsed.scheme != "https": + raise SSRFError(f"URL must use HTTPS, got: {parsed.scheme}") + + if not parsed.netloc: + raise SSRFError("URL must have a host") + + if require_path and parsed.path in ("", "/"): + raise SSRFError("URL must have a non-root path") + + hostname = parsed.hostname or parsed.netloc + port = parsed.port or 443 + + # Resolve and validate IPs + resolved_ips = await resolve_hostname(hostname, port) + + blocked = [ip for ip in resolved_ips if not is_ip_allowed(ip)] + if blocked: + raise SSRFError( + f"URL resolves to blocked IP address(es): {blocked}. " + f"Private, loopback, link-local, and reserved IPs are not allowed." + ) + + return ValidatedURL( + original_url=url, + hostname=hostname, + port=port, + path=parsed.path + ("?" + parsed.query if parsed.query else ""), + resolved_ips=resolved_ips, + ) + + +async def ssrf_safe_fetch( + url: str, + *, + require_path: bool = False, + max_size: int = 5120, + timeout: float = 10.0, + overall_timeout: float = 30.0, +) -> bytes: + """Fetch URL with comprehensive SSRF protection and DNS pinning. + + Security measures: + 1. HTTPS only + 2. DNS resolution with IP validation + 3. Connects to validated IP directly (DNS pinning prevents rebinding) + 4. Response size limit + 5. Redirects disabled + 6. Overall timeout + + Args: + url: URL to fetch + require_path: If True, require non-root path + max_size: Maximum response size in bytes (default 5KB) + timeout: Per-operation timeout in seconds + overall_timeout: Overall timeout for entire operation + + Returns: + Response body as bytes + + Raises: + SSRFError: If SSRF validation fails + SSRFFetchError: If fetch fails + """ + start_time = time.monotonic() + + # Validate URL and resolve DNS + validated = await validate_url(url, require_path=require_path) + + last_error: Exception | None = None + + for pinned_ip in validated.resolved_ips: + elapsed = time.monotonic() - start_time + if elapsed > overall_timeout: + raise SSRFFetchError(f"Overall timeout exceeded: {url}") + remaining = max(1.0, overall_timeout - elapsed) + + pinned_url = ( + f"https://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}" + ) + + logger.debug( + "SSRF-safe fetch: %s -> %s (pinned to %s)", + url, + pinned_url, + pinned_ip, + ) + + try: + # Use httpx with streaming to enforce size limit during download + async with ( + httpx.AsyncClient( + timeout=httpx.Timeout( + connect=min(timeout, remaining), + read=min(timeout, remaining), + write=min(timeout, remaining), + pool=min(timeout, remaining), + ), + follow_redirects=False, + verify=True, + ) as client, + client.stream( + "GET", + pinned_url, + headers={"Host": validated.hostname}, + extensions={"sni_hostname": validated.hostname}, + ) as response, + ): + if time.monotonic() - start_time > overall_timeout: + raise SSRFFetchError(f"Overall timeout exceeded: {url}") + + if response.status_code != 200: + raise SSRFFetchError(f"HTTP {response.status_code} fetching {url}") + + # Check Content-Length header first if available + content_length = response.headers.get("content-length") + if content_length: + try: + size = int(content_length) + if size > max_size: + raise SSRFFetchError( + f"Response too large: {size} bytes (max {max_size})" + ) + except ValueError: + pass + + # Stream the response and enforce size limit during download + chunks = [] + total = 0 + async for chunk in response.aiter_bytes(): + if time.monotonic() - start_time > overall_timeout: + raise SSRFFetchError(f"Overall timeout exceeded: {url}") + total += len(chunk) + if total > max_size: + raise SSRFFetchError( + f"Response too large: exceeded {max_size} bytes" + ) + chunks.append(chunk) + + return b"".join(chunks) + + except httpx.TimeoutException as e: + last_error = e + continue + except httpx.RequestError as e: + last_error = e + continue + + if last_error is not None: + if isinstance(last_error, httpx.TimeoutException): + raise SSRFFetchError(f"Timeout fetching {url}") from last_error + raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error + + raise SSRFFetchError(f"Error fetching {url}: no resolved IPs succeeded") diff --git a/tests/cli/test_cimd_cli.py b/tests/cli/test_cimd_cli.py new file mode 100644 index 000000000..301c440ed --- /dev/null +++ b/tests/cli/test_cimd_cli.py @@ -0,0 +1,208 @@ +"""Tests for the CIMD CLI commands (create and validate).""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import AnyHttpUrl + +from fastmcp.cli.cimd import create_command, validate_command +from fastmcp.server.auth.cimd import CIMDDocument, CIMDFetchError, CIMDValidationError + + +class TestCIMDCreateCommand: + """Tests for `fastmcp auth cimd create`.""" + + def test_minimal_output(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + ) + doc = json.loads(capsys.readouterr().out) + assert doc["client_name"] == "Test App" + assert doc["redirect_uris"] == ["http://localhost:*/callback"] + assert doc["token_endpoint_auth_method"] == "none" + assert doc["grant_types"] == ["authorization_code"] + assert doc["response_types"] == ["code"] + # Placeholder client_id + assert "YOUR-DOMAIN" in doc["client_id"] + + def test_with_client_id(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_id="https://myapp.example.com/client.json", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["client_id"] == "https://myapp.example.com/client.json" + + def test_with_output_file(self, tmp_path): + output_file = tmp_path / "client.json" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_id="https://example.com/client.json", + output=str(output_file), + ) + doc = json.loads(output_file.read_text()) + assert doc["client_id"] == "https://example.com/client.json" + assert doc["client_name"] == "Test App" + + def test_relative_path_resolved(self, tmp_path, monkeypatch): + """Relative paths should be resolved against cwd.""" + monkeypatch.chdir(tmp_path) + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + output="./subdir/client.json", + ) + resolved = tmp_path / "subdir" / "client.json" + assert resolved.exists() + doc = json.loads(resolved.read_text()) + assert doc["client_name"] == "Test App" + + def test_with_scope(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + scope="read write", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["scope"] == "read write" + + def test_with_client_uri(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_uri="https://example.com", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["client_uri"] == "https://example.com" + + def test_with_logo_uri(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + logo_uri="https://example.com/logo.png", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["logo_uri"] == "https://example.com/logo.png" + + def test_multiple_redirect_uris(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=[ + "http://localhost:*/callback", + "https://myapp.example.com/callback", + ], + ) + doc = json.loads(capsys.readouterr().out) + assert len(doc["redirect_uris"]) == 2 + + def test_no_pretty(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + pretty=False, + ) + output = capsys.readouterr().out.strip() + # Compact JSON has no newlines within the object + assert "\n" not in output + doc = json.loads(output) + assert doc["client_name"] == "Test App" + + def test_placeholder_warning_on_stderr(self, capsys: pytest.CaptureFixture[str]): + """When outputting to stdout with no --client-id, warning goes to stderr.""" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + ) + captured = capsys.readouterr() + # stdout has valid JSON + json.loads(captured.out) + # stderr has the warning (Rich Console writes to stderr) + assert "placeholder" in captured.err + + def test_no_warning_with_client_id(self, capsys: pytest.CaptureFixture[str]): + """No placeholder warning when --client-id is provided.""" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_id="https://example.com/client.json", + ) + captured = capsys.readouterr() + assert "placeholder" not in captured.err + + def test_optional_fields_omitted_when_none( + self, capsys: pytest.CaptureFixture[str] + ): + """Optional fields like scope, client_uri, logo_uri are omitted if not given.""" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + ) + doc = json.loads(capsys.readouterr().out) + assert "scope" not in doc + assert "client_uri" not in doc + assert "logo_uri" not in doc + + +class TestCIMDValidateCommand: + """Tests for `fastmcp auth cimd validate`.""" + + def test_invalid_url_format(self, capsys: pytest.CaptureFixture[str]): + with pytest.raises(SystemExit, match="1"): + validate_command("http://insecure.com/client.json") + captured = capsys.readouterr() + assert "Invalid CIMD URL" in captured.out + + def test_root_path_rejected(self, capsys: pytest.CaptureFixture[str]): + with pytest.raises(SystemExit, match="1"): + validate_command("https://example.com/") + captured = capsys.readouterr() + assert "Invalid CIMD URL" in captured.out + + def test_success(self, capsys: pytest.CaptureFixture[str]): + mock_doc = CIMDDocument( + client_id=AnyHttpUrl("https://myapp.example.com/client.json"), + client_name="Test App", + redirect_uris=["http://localhost:*/callback"], + token_endpoint_auth_method="none", + grant_types=["authorization_code"], + response_types=["code"], + ) + with patch.object(CIMDDocument, "__init__", return_value=None): + pass + mock_fetch = AsyncMock(return_value=mock_doc) + with patch( + "fastmcp.cli.cimd.CIMDFetcher.fetch", + mock_fetch, + ): + validate_command("https://myapp.example.com/client.json") + captured = capsys.readouterr() + assert "Valid CIMD document" in captured.out + assert "Test App" in captured.out + + def test_fetch_error(self, capsys: pytest.CaptureFixture[str]): + mock_fetch = AsyncMock(side_effect=CIMDFetchError("Connection refused")) + with patch( + "fastmcp.cli.cimd.CIMDFetcher.fetch", + mock_fetch, + ): + with pytest.raises(SystemExit, match="1"): + validate_command("https://myapp.example.com/client.json") + captured = capsys.readouterr() + assert "Failed to fetch" in captured.out + + def test_validation_error(self, capsys: pytest.CaptureFixture[str]): + mock_fetch = AsyncMock(side_effect=CIMDValidationError("client_id mismatch")) + with patch( + "fastmcp.cli.cimd.CIMDFetcher.fetch", + mock_fetch, + ): + with pytest.raises(SystemExit, match="1"): + validate_command("https://myapp.example.com/client.json") + captured = capsys.readouterr() + assert "Validation error" in captured.out diff --git a/tests/client/auth/test_oauth_cimd.py b/tests/client/auth/test_oauth_cimd.py new file mode 100644 index 000000000..04818af60 --- /dev/null +++ b/tests/client/auth/test_oauth_cimd.py @@ -0,0 +1,164 @@ +"""Tests for CIMD (Client ID Metadata Document) support in the OAuth client.""" + +from __future__ import annotations + +import warnings + +import httpx +import pytest + +from fastmcp.client.auth import OAuth +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.client.transports.sse import SSETransport + +VALID_CIMD_URL = "https://myapp.example.com/oauth/client.json" +MCP_SERVER_URL = "https://mcp-server.example.com/mcp" + + +class TestOAuthClientMetadataURL: + """Tests for the client_metadata_url parameter on OAuth.""" + + def test_stored_on_instance(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._client_metadata_url == VALID_CIMD_URL + + def test_none_by_default(self): + oauth = OAuth() + assert oauth._client_metadata_url is None + + def test_passed_to_parent_on_bind(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + oauth._bind(MCP_SERVER_URL) + assert oauth.context.client_metadata_url == VALID_CIMD_URL + + def test_none_metadata_url_on_parent(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth(mcp_url=MCP_SERVER_URL) + assert oauth.context.client_metadata_url is None + + def test_unbound_when_no_mcp_url(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + + def test_bound_when_mcp_url_provided(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth( + mcp_url=MCP_SERVER_URL, + client_metadata_url=VALID_CIMD_URL, + ) + assert oauth._bound is True + + def test_invalid_cimd_url_rejected(self): + """CIMD URLs must be HTTPS with a non-root path.""" + with pytest.raises(ValueError, match="valid HTTPS URL"): + OAuth( + mcp_url=MCP_SERVER_URL, + client_metadata_url="http://insecure.com/client.json", + ) + + def test_root_path_cimd_url_rejected(self): + with pytest.raises(ValueError, match="valid HTTPS URL"): + OAuth( + mcp_url=MCP_SERVER_URL, + client_metadata_url="https://example.com/", + ) + + +class TestOAuthBind: + """Tests for the _bind() deferred initialization.""" + + def test_bind_sets_bound_true(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL) + assert oauth._bound is True + + def test_bind_idempotent(self): + """Second call to _bind is a no-op.""" + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL) + oauth._bind("https://other-server.example.com/mcp") + # First binding wins + assert oauth.mcp_url == MCP_SERVER_URL + + def test_bind_sets_mcp_url(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL + "/") + # Trailing slash stripped + assert oauth.mcp_url == MCP_SERVER_URL + + def test_bind_creates_token_storage(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert not hasattr(oauth, "token_storage_adapter") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL) + assert hasattr(oauth, "token_storage_adapter") + + async def test_unbound_raises_runtime_error(self): + """async_auth_flow should fail clearly when OAuth is not bound.""" + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + request = httpx.Request("GET", MCP_SERVER_URL) + with pytest.raises(RuntimeError, match="no server URL"): + async for _ in oauth.async_auth_flow(request): + pass + + def test_scopes_forwarded_as_list(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth( + client_metadata_url=VALID_CIMD_URL, + scopes=["read", "write"], + ) + oauth._bind(MCP_SERVER_URL) + assert oauth.context.client_metadata.scope == "read write" + + def test_scopes_forwarded_as_string(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth( + client_metadata_url=VALID_CIMD_URL, + scopes="read write", + ) + oauth._bind(MCP_SERVER_URL) + assert oauth.context.client_metadata.scope == "read write" + + +class TestOAuthBindFromTransport: + """Tests that transports call _bind() on OAuth instances.""" + + def test_http_transport_binds_oauth(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + StreamableHttpTransport(MCP_SERVER_URL, auth=oauth) + assert oauth._bound is True + assert oauth.mcp_url == MCP_SERVER_URL + + def test_sse_transport_binds_oauth(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + SSETransport(MCP_SERVER_URL, auth=oauth) + assert oauth._bound is True + assert oauth.mcp_url == MCP_SERVER_URL + + def test_http_transport_oauth_string_still_works(self): + """auth="oauth" should still create a new OAuth instance.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + transport = StreamableHttpTransport(MCP_SERVER_URL, auth="oauth") + assert isinstance(transport.auth, OAuth) + assert transport.auth._bound is True diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index 27c31e198..b605e50fd 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -1,6 +1,8 @@ """Tests for OAuth proxy initialization and configuration.""" +import httpx from key_value.aio.stores.memory import MemoryStore +from starlette.applications import Starlette from fastmcp.server.auth.oauth_proxy import OAuthProxy @@ -72,3 +74,29 @@ class TestOAuthProxyInitialization: client_storage=MemoryStore(), ) assert proxy._redirect_path == "/auth/callback" + + async def test_metadata_advertises_cimd_support(self, jwt_verifier): + """OAuth metadata should advertise CIMD support when enabled.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + upstream_client_secret="secret-456", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + enable_cimd=True, + ) + + app = Starlette(routes=proxy.get_routes()) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, base_url="https://api.example.com" + ) as client: + response = await client.get("/.well-known/oauth-authorization-server") + + assert response.status_code == 200 + metadata = response.json() + assert metadata.get("client_id_metadata_document_supported") is True diff --git a/tests/server/auth/test_cimd.py b/tests/server/auth/test_cimd.py new file mode 100644 index 000000000..d3c3e316e --- /dev/null +++ b/tests/server/auth/test_cimd.py @@ -0,0 +1,971 @@ +"""Unit tests for CIMD (Client ID Metadata Document) functionality.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import AnyHttpUrl, ValidationError + +from fastmcp.server.auth.cimd import ( + CIMDAssertionValidator, + CIMDClientManager, + CIMDDocument, + CIMDFetcher, + CIMDFetchError, + CIMDValidationError, +) +from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient + +# Standard public IP used for DNS mocking in tests +TEST_PUBLIC_IP = "93.184.216.34" + + +class TestCIMDDocument: + """Tests for CIMDDocument model validation.""" + + def test_valid_minimal_document(self): + """Test that minimal valid document passes validation.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + assert str(doc.client_id) == "https://example.com/client.json" + assert doc.token_endpoint_auth_method == "none" + assert doc.grant_types == ["authorization_code"] + assert doc.response_types == ["code"] + + def test_valid_full_document(self): + """Test that full document passes validation.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + client_name="My App", + client_uri=AnyHttpUrl("https://example.com"), + logo_uri=AnyHttpUrl("https://example.com/logo.png"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="none", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="read write", + ) + assert doc.client_name == "My App" + assert doc.scope == "read write" + + def test_private_key_jwt_auth_method_allowed(self): + """Test that private_key_jwt is allowed for CIMD.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"), + ) + assert doc.token_endpoint_auth_method == "private_key_jwt" + + def test_client_secret_basic_rejected(self): + """Test that client_secret_basic is rejected for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="client_secret_basic", # type: ignore[arg-type] - testing invalid value + ) + # Literal type rejects invalid values before custom validator + assert "token_endpoint_auth_method" in str(exc_info.value) + + def test_client_secret_post_rejected(self): + """Test that client_secret_post is rejected for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="client_secret_post", # type: ignore[arg-type] - testing invalid value + ) + assert "token_endpoint_auth_method" in str(exc_info.value) + + def test_client_secret_jwt_rejected(self): + """Test that client_secret_jwt is rejected for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="client_secret_jwt", # type: ignore[arg-type] - testing invalid value + ) + assert "token_endpoint_auth_method" in str(exc_info.value) + + def test_missing_redirect_uris_rejected(self): + """Test that redirect_uris is required for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument(client_id=AnyHttpUrl("https://example.com/client.json")) + assert "redirect_uris" in str(exc_info.value) + + def test_empty_redirect_uris_rejected(self): + """Test that empty redirect_uris is rejected.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=[], + ) + assert "redirect_uris" in str(exc_info.value) + + def test_redirect_uri_without_scheme_rejected(self): + """Test that redirect_uris without a scheme are rejected.""" + with pytest.raises(ValidationError, match="must have a scheme"): + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["/just/a/path"], + ) + + def test_redirect_uri_without_host_rejected(self): + """Test that redirect_uris without a host are rejected.""" + with pytest.raises(ValidationError, match="must have a host"): + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://"], + ) + + def test_redirect_uri_whitespace_only_rejected(self): + """Test that whitespace-only redirect_uris are rejected.""" + with pytest.raises(ValidationError, match="non-empty"): + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=[" "], + ) + + +class TestCIMDFetcher: + """Tests for CIMDFetcher.""" + + @pytest.fixture + def fetcher(self): + """Create a CIMDFetcher for testing.""" + return CIMDFetcher() + + def test_is_cimd_client_id_valid_urls(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id accepts valid CIMD URLs.""" + assert fetcher.is_cimd_client_id("https://example.com/client.json") + assert fetcher.is_cimd_client_id("https://example.com/path/to/client") + assert fetcher.is_cimd_client_id("https://sub.example.com/cimd.json") + + def test_is_cimd_client_id_rejects_http(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id rejects HTTP URLs.""" + assert not fetcher.is_cimd_client_id("http://example.com/client.json") + + def test_is_cimd_client_id_rejects_root_path(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id rejects URLs with no path.""" + assert not fetcher.is_cimd_client_id("https://example.com/") + assert not fetcher.is_cimd_client_id("https://example.com") + + def test_is_cimd_client_id_rejects_non_url(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id rejects non-URL strings.""" + assert not fetcher.is_cimd_client_id("client-123") + assert not fetcher.is_cimd_client_id("my-client") + assert not fetcher.is_cimd_client_id("") + assert not fetcher.is_cimd_client_id("not a url") + + def test_validate_redirect_uri_exact_match(self, fetcher: CIMDFetcher): + """Test redirect_uri validation with exact match.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback") + assert not fetcher.validate_redirect_uri(doc, "http://localhost:4000/callback") + + def test_validate_redirect_uri_wildcard_match(self, fetcher: CIMDFetcher): + """Test redirect_uri validation with wildcard port.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:*/callback"], + ) + assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback") + assert fetcher.validate_redirect_uri(doc, "http://localhost:8080/callback") + assert not fetcher.validate_redirect_uri(doc, "http://localhost:3000/other") + + +class TestCIMDFetcherHTTP: + """Tests for CIMDFetcher HTTP fetching (using httpx mock). + + Note: With SSRF protection and DNS pinning, HTTP requests go to the resolved IP + instead of the hostname. These tests mock DNS resolution to return a public IP + and configure httpx_mock to expect the IP-based URL. + """ + + @pytest.fixture + def fetcher(self): + """Create a CIMDFetcher for testing.""" + return CIMDFetcher() + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_fetch_success(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test successful CIMD document fetch.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + + # With DNS pinning, request goes to IP. Match any URL. + httpx_mock.add_response( + json=doc_data, + headers={ + "content-type": "application/json", + "content-length": "200", + }, + ) + + doc = await fetcher.fetch(url) + assert str(doc.client_id) == url + assert doc.client_name == "Test App" + + async def test_fetch_ttl_cache(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test that fetched documents are cached and served from cache within TTL.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + + assert first.client_id == second.client_id + assert len(httpx_mock.get_requests()) == 1 + + async def test_fetch_client_id_mismatch( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Test that client_id mismatch is rejected.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": "https://other.com/client.json", # Different URL + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "100"}, + ) + + with pytest.raises(CIMDValidationError) as exc_info: + await fetcher.fetch(url) + assert "mismatch" in str(exc_info.value).lower() + + async def test_fetch_http_error(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test handling of HTTP errors.""" + url = "https://example.com/client.json" + httpx_mock.add_response(status_code=404) + + with pytest.raises(CIMDFetchError) as exc_info: + await fetcher.fetch(url) + assert "404" in str(exc_info.value) + + async def test_fetch_invalid_json(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test handling of invalid JSON response.""" + url = "https://example.com/client.json" + httpx_mock.add_response( + content=b"not json", + headers={"content-length": "10"}, + ) + + with pytest.raises(CIMDValidationError) as exc_info: + await fetcher.fetch(url) + assert "JSON" in str(exc_info.value) + + async def test_fetch_invalid_document( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Test handling of invalid CIMD document.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "client_secret_basic", # Not allowed + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "100"}, + ) + + with pytest.raises(CIMDValidationError) as exc_info: + await fetcher.fetch(url) + assert "Invalid CIMD document" in str(exc_info.value) + + +class TestCIMDAssertionValidator: + """Tests for CIMDAssertionValidator (private_key_jwt support).""" + + @pytest.fixture + def validator(self): + """Create a CIMDAssertionValidator for testing.""" + return CIMDAssertionValidator() + + @pytest.fixture + def key_pair(self): + """Generate RSA key pair for testing.""" + from fastmcp.server.auth.providers.jwt import RSAKeyPair + + return RSAKeyPair.generate() + + @pytest.fixture + def jwks(self, key_pair): + """Create JWKS from key pair.""" + import base64 + + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + + # Load public key + public_key = serialization.load_pem_public_key( + key_pair.public_key.encode(), backend=default_backend() + ) + + # Get RSA public numbers + from cryptography.hazmat.primitives.asymmetric import rsa + + if isinstance(public_key, rsa.RSAPublicKey): + numbers = public_key.public_numbers() + + # Convert to JWK format + return { + "keys": [ + { + "kty": "RSA", + "kid": "test-key-1", + "use": "sig", + "alg": "RS256", + "n": base64.urlsafe_b64encode( + numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + "e": base64.urlsafe_b64encode( + numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + } + ] + } + + @pytest.fixture + def cimd_doc_with_jwks_uri(self): + """Create CIMD document with jwks_uri.""" + return CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"), + ) + + @pytest.fixture + def cimd_doc_with_inline_jwks(self, jwks): + """Create CIMD document with inline JWKS.""" + return CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks=jwks, + ) + + async def test_valid_assertion_with_jwks_uri( + self, validator, key_pair, cimd_doc_with_jwks_uri, httpx_mock + ): + """Test that valid JWT assertion passes validation (jwks_uri).""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Mock JWKS endpoint + import base64 + + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + + public_key = serialization.load_pem_public_key( + key_pair.public_key.encode(), backend=default_backend() + ) + from cryptography.hazmat.primitives.asymmetric import rsa + + assert isinstance(public_key, rsa.RSAPublicKey) + numbers = public_key.public_numbers() + + jwks = { + "keys": [ + { + "kty": "RSA", + "kid": "test-key-1", + "use": "sig", + "alg": "RS256", + "n": base64.urlsafe_b64encode( + numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + "e": base64.urlsafe_b64encode( + numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + } + ] + } + + # Mock DNS resolution for SSRF-safe fetch + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + httpx_mock.add_response(json=jwks) + + # Create valid assertion (use short lifetime for security compliance) + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "unique-jti-123"}, + expires_in_seconds=60, # 1 minute (max allowed is 300s) + kid="test-key-1", + ) + + # Should validate successfully + assert await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_jwks_uri + ) + + async def test_valid_assertion_with_inline_jwks( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that valid JWT assertion passes validation (inline JWKS).""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create valid assertion (use short lifetime for security compliance) + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "unique-jti-456"}, + expires_in_seconds=60, # 1 minute (max allowed is 300s) + kid="test-key-1", + ) + + # Should validate successfully + assert await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + + async def test_rejects_wrong_issuer( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that wrong issuer is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion with wrong issuer + assertion = key_pair.create_token( + subject=client_id, + issuer="https://attacker.com", # Wrong! + audience=token_endpoint, + additional_claims={"jti": "unique-jti-789"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "Invalid JWT assertion" in str(exc_info.value) + + async def test_rejects_wrong_audience( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that wrong audience is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion with wrong audience + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience="https://wrong-endpoint.com/token", # Wrong! + additional_claims={"jti": "unique-jti-abc"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "Invalid JWT assertion" in str(exc_info.value) + + async def test_rejects_wrong_subject( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that wrong subject claim is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion with wrong subject + assertion = key_pair.create_token( + subject="https://different-client.com", # Wrong! + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "unique-jti-def"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "sub claim must be" in str(exc_info.value) + + async def test_rejects_missing_jti( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that missing jti claim is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion without jti + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + # No jti! + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "jti claim" in str(exc_info.value) + + async def test_rejects_replayed_jti( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that replayed JTI is detected and rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "replayed-jti"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + # First use should succeed + assert await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + + # Second use with same jti should fail (replay attack) + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "replay" in str(exc_info.value).lower() + + async def test_rejects_expired_token( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that expired tokens are rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create expired assertion (expired 1 hour ago) + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "expired-jti"}, + expires_in_seconds=-3600, # Negative = expired + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "Invalid JWT assertion" in str(exc_info.value) + + +class TestCIMDClientManager: + """Tests for CIMDClientManager.""" + + @pytest.fixture + def manager(self): + """Create a CIMDClientManager for testing.""" + return CIMDClientManager(enable_cimd=True) + + @pytest.fixture + def disabled_manager(self): + """Create a disabled CIMDClientManager for testing.""" + return CIMDClientManager(enable_cimd=False) + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + def test_is_cimd_client_id_enabled(self, manager): + """Test CIMD URL detection when enabled.""" + assert manager.is_cimd_client_id("https://example.com/client.json") + assert not manager.is_cimd_client_id("regular-client-id") + + def test_is_cimd_client_id_disabled(self, disabled_manager): + """Test CIMD URL detection when disabled.""" + assert not disabled_manager.is_cimd_client_id("https://example.com/client.json") + assert not disabled_manager.is_cimd_client_id("regular-client-id") + + async def test_get_client_success(self, manager, httpx_mock, mock_dns): + """Test successful CIMD client creation.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + client = await manager.get_client(url) + assert client is not None + assert client.client_id == url + assert client.client_name == "Test App" + # Verify it uses proxy's patterns (None by default), not document's redirect_uris + assert client.allowed_redirect_uri_patterns is None + + async def test_get_client_disabled(self, disabled_manager): + """Test that get_client returns None when disabled.""" + client = await disabled_manager.get_client("https://example.com/client.json") + assert client is None + + async def test_get_client_fetch_failure(self, manager, httpx_mock, mock_dns): + """Test that get_client returns None on fetch failure.""" + url = "https://example.com/client.json" + httpx_mock.add_response(status_code=404) + + client = await manager.get_client(url) + assert client is None + + # Trust policy and consent bypass tests removed - functionality removed from CIMD + + +class TestCIMDClientManagerGetClientOptions: + """Tests for CIMDClientManager.get_client with default_scope and allowed patterns.""" + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_default_scope_applied_when_doc_has_no_scope( + self, httpx_mock, mock_dns + ): + """When the CIMD document omits scope, the manager's default_scope is used.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + # No scope field + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + manager = CIMDClientManager( + enable_cimd=True, + default_scope="read write admin", + ) + client = await manager.get_client(url) + assert client is not None + assert client.scope == "read write admin" + + async def test_doc_scope_takes_precedence_over_default(self, httpx_mock, mock_dns): + """When the CIMD document specifies scope, it wins over the default.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + "scope": "custom-scope", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + manager = CIMDClientManager( + enable_cimd=True, + default_scope="default-scope", + ) + client = await manager.get_client(url) + assert client is not None + assert client.scope == "custom-scope" + + async def test_allowed_redirect_uri_patterns_stored_on_client( + self, httpx_mock, mock_dns + ): + """Proxy's allowed_redirect_uri_patterns are forwarded to the created client.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + patterns = ["http://localhost:*", "https://app.example.com/*"] + manager = CIMDClientManager( + enable_cimd=True, + allowed_redirect_uri_patterns=patterns, + ) + client = await manager.get_client(url) + assert client is not None + assert client.allowed_redirect_uri_patterns == patterns + + async def test_cimd_document_attached_to_client(self, httpx_mock, mock_dns): + """The fetched CIMDDocument is attached to the created client.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Attached Doc App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + manager = CIMDClientManager(enable_cimd=True) + client = await manager.get_client(url) + assert client is not None + assert client.cimd_document is not None + assert client.cimd_document.client_name == "Attached Doc App" + assert str(client.cimd_document.client_id) == url + + +class TestCIMDClientManagerValidatePrivateKeyJwt: + """Tests for CIMDClientManager.validate_private_key_jwt wrapper.""" + + @pytest.fixture + def manager(self): + return CIMDClientManager(enable_cimd=True) + + async def test_missing_cimd_document_raises(self, manager): + """validate_private_key_jwt raises ValueError if client has no cimd_document.""" + + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=None, + ) + with pytest.raises(ValueError, match="must have CIMD document"): + await manager.validate_private_key_jwt( + "fake.jwt.token", + client, + "https://oauth.example.com/token", + ) + + async def test_wrong_auth_method_raises(self, manager): + """validate_private_key_jwt raises ValueError if auth method is not private_key_jwt.""" + + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="none", # Not private_key_jwt + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + with pytest.raises(ValueError, match="private_key_jwt"): + await manager.validate_private_key_jwt( + "fake.jwt.token", + client, + "https://oauth.example.com/token", + ) + + async def test_success_delegates_to_assertion_validator(self, manager): + """On success, validate_private_key_jwt delegates to the assertion validator.""" + + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"), + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + + manager._assertion_validator.validate_assertion = AsyncMock(return_value=True) + + result = await manager.validate_private_key_jwt( + "test.jwt.assertion", + client, + "https://oauth.example.com/token", + ) + assert result is True + manager._assertion_validator.validate_assertion.assert_awaited_once_with( + "test.jwt.assertion", + "https://example.com/client.json", + "https://oauth.example.com/token", + cimd_doc, + ) + + +class TestCIMDRedirectUriEnforcement: + """Tests for CIMD redirect_uri validation security. + + Verifies that CIMD clients enforce BOTH: + 1. CIMD document's redirect_uris + 2. Proxy's allowed_redirect_uri_patterns + """ + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_cimd_redirect_uris_enforced(self, httpx_mock, mock_dns): + """Test that CIMD document redirect_uris are enforced. + + Even if proxy patterns allow http://localhost:*, a CIMD client + should only accept URIs declared in its document. + """ + from mcp.shared.auth import InvalidRedirectUriError + from pydantic import AnyUrl + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + # CIMD only declares port 3000 + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + # Proxy allows any localhost port + manager = CIMDClientManager( + enable_cimd=True, + allowed_redirect_uri_patterns=["http://localhost:*"], + ) + client = await manager.get_client(url) + assert client is not None + + # Declared URI should work + validated = client.validate_redirect_uri( + AnyUrl("http://localhost:3000/callback") + ) + assert str(validated) == "http://localhost:3000/callback" + + # Different port should fail (not in CIMD redirect_uris) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:4000/callback")) + + async def test_proxy_patterns_also_checked(self, httpx_mock, mock_dns): + """Test that proxy patterns are checked even for CIMD clients. + + A CIMD client should not be able to use a redirect_uri that's + in its document but not allowed by proxy patterns. + """ + from mcp.shared.auth import InvalidRedirectUriError + from pydantic import AnyUrl + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + # CIMD declares both localhost and external URI + "redirect_uris": [ + "http://localhost:3000/callback", + "https://evil.com/callback", + ], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + # Proxy only allows localhost + manager = CIMDClientManager( + enable_cimd=True, + allowed_redirect_uri_patterns=["http://localhost:*"], + ) + client = await manager.get_client(url) + assert client is not None + + # Localhost should work (in CIMD and matches pattern) + validated = client.validate_redirect_uri( + AnyUrl("http://localhost:3000/callback") + ) + assert str(validated) == "http://localhost:3000/callback" + + # Evil.com should fail (in CIMD but doesn't match proxy patterns) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("https://evil.com/callback")) diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 14a81299e..bced42a1f 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -1,5 +1,6 @@ from collections.abc import AsyncGenerator from typing import Any +from unittest.mock import patch import httpx import pytest @@ -10,6 +11,9 @@ from fastmcp.client.auth.bearer import BearerAuth from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair from fastmcp.utilities.tests import run_server_async +# Standard public IP used for DNS mocking in tests +TEST_PUBLIC_IP = "93.184.216.34" + class SymmetricKeyHelper: """Helper class for generating symmetric key JWT tokens for testing.""" @@ -378,7 +382,11 @@ class TestSymmetricKeyJWT: class TestBearerTokenJWKS: - """Tests for JWKS URI functionality.""" + """Tests for JWKS URI functionality. + + Note: With SSRF protection, JWKS fetches validate DNS and connect to the + resolved IP. Tests mock DNS resolution to return a public IP. + """ @pytest.fixture def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier: @@ -402,18 +410,25 @@ class TestBearerTokenJWKS: return {"keys": [jwk_data]} + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + async def test_jwks_token_validation( self, rsa_key_pair: RSAKeyPair, jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): """Test token validation using JWKS URI.""" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) username = "test-user" issuer = "https://test.example.com" @@ -440,11 +455,9 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = RSAKeyPair.generate().create_token( subject="test-user", issuer="https://test.example.com", @@ -460,12 +473,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -483,12 +494,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -505,12 +514,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -527,12 +534,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -549,6 +554,7 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"] = [ # type: ignore[typeddict-item] { @@ -561,10 +567,7 @@ class TestBearerTokenJWKS: }, ] - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 20d4afdd7..391977b88 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -1,14 +1,20 @@ """Tests for OAuth proxy redirect URI validation.""" +from unittest.mock import patch + import pytest from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import InvalidRedirectUriError -from pydantic import AnyUrl +from pydantic import AnyHttpUrl, AnyUrl from fastmcp.server.auth.auth import TokenVerifier +from fastmcp.server.auth.cimd import CIMDDocument from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient +# Standard public IP used for DNS mocking in tests +TEST_PUBLIC_IP = "93.184.216.34" + class MockTokenVerifier(TokenVerifier): """Mock token verifier for testing.""" @@ -133,6 +139,38 @@ class TestProxyDCRClient: result = client.validate_redirect_uri(None) assert result == AnyUrl("http://localhost:3000") + def test_cimd_none_redirect_uri_single_exact(self): + """CIMD clients may omit redirect_uri only when a single exact URI exists.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + + result = client.validate_redirect_uri(None) + assert result == AnyUrl("http://localhost:3000/callback") + + def test_cimd_none_redirect_uri_wildcard_rejected(self): + """CIMD clients must specify redirect_uri when only wildcard patterns exist.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:*/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(None) + class TestOAuthProxyRedirectValidation: """Test OAuth proxy with redirect URI validation.""" @@ -240,3 +278,90 @@ class TestOAuthProxyRedirectValidation: # Get an unregistered client client = await proxy.get_client("unknown-client") assert client is None + + +class TestOAuthProxyCIMDClient: + """Test that CIMD clients obtained via proxy carry their document and apply dual validation.""" + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_proxy_get_client_returns_cimd_client(self, httpx_mock, mock_dns): + """CIMD client obtained via proxy's get_client has cimd_document attached.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "CIMD App", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + client = await proxy.get_client(url) + assert isinstance(client, ProxyDCRClient) + assert client.cimd_document is not None + assert client.cimd_document.client_name == "CIMD App" + assert client.client_id == url + + async def test_proxy_cimd_dual_redirect_validation(self, httpx_mock, mock_dns): + """CIMD client from proxy enforces both CIMD redirect_uris and proxy patterns.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Dual Validation App", + "redirect_uris": [ + "http://localhost:3000/callback", + "https://evil.com/callback", + ], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + allowed_client_redirect_uris=["http://localhost:*"], + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + client = await proxy.get_client(url) + assert client is not None + + # In CIMD AND matches proxy pattern → accepted + assert client.validate_redirect_uri(AnyUrl("http://localhost:3000/callback")) + + # In CIMD but NOT in proxy pattern → rejected + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("https://evil.com/callback")) + + # NOT in CIMD but matches proxy pattern → rejected + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:9999/other")) diff --git a/tests/server/auth/test_oauth_proxy_storage.py b/tests/server/auth/test_oauth_proxy_storage.py index cc1808c3f..7c898823e 100644 --- a/tests/server/auth/test_oauth_proxy_storage.py +++ b/tests/server/auth/test_oauth_proxy_storage.py @@ -112,7 +112,7 @@ class TestOAuthProxyStorage: async def test_proxy_dcr_client_redirect_validation( self, jwt_verifier: TokenVerifier, temp_storage: AsyncKeyValue ): - """Test that ProxyDCRClient is created with redirect URI patterns.""" + """Test that OAuthProxyClient is created with redirect URI patterns.""" proxy = OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", @@ -132,11 +132,11 @@ class TestOAuthProxyStorage: ) await proxy.register_client(client_info) - # Get client back - should be ProxyDCRClient + # Get client back - should be OAuthProxyClient client = await proxy.get_client("test-proxy-client") assert client is not None - # ProxyDCRClient should validate dynamic localhost ports + # OAuthProxyClient should validate dynamic localhost ports validated = client.validate_redirect_uri( AnyUrl("http://localhost:12345/callback") ) @@ -205,5 +205,7 @@ class TestOAuthProxyStorage: "client_id_issued_at": None, "client_secret_expires_at": None, "allowed_redirect_uri_patterns": None, + "cimd_document": None, + "cimd_fetched_at": None, } ) diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index b8e373e40..e3dd95e1c 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -15,10 +15,10 @@ TEST_ISSUER = "https://example.com" TEST_AUTHORIZATION_ENDPOINT = "https://example.com/authorize" TEST_TOKEN_ENDPOINT = "https://example.com/oauth/token" -TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration" +TEST_CONFIG_URL = AnyHttpUrl("https://example.com/.well-known/openid-configuration") TEST_CLIENT_ID = "test-client-id" TEST_CLIENT_SECRET = "test-client-secret" -TEST_BASE_URL = "https://example.com:8000/" +TEST_BASE_URL = AnyHttpUrl("https://example.com:8000/") # ============================================================================= @@ -366,7 +366,7 @@ def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds) mock_get.return_value = mock_response config = OIDCConfiguration.get_oidc_configuration( - config_url=AnyHttpUrl(TEST_CONFIG_URL), + config_url=TEST_CONFIG_URL, strict=strict, timeout_seconds=timeout_seconds, ) @@ -376,7 +376,7 @@ def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds) mock_get.assert_called_once() call_args = mock_get.call_args - assert call_args[0][0] == TEST_CONFIG_URL + assert str(call_args[0][0]) == str(TEST_CONFIG_URL) return call_args @@ -415,7 +415,7 @@ class TestGetOIDCConfiguration: mock_get.return_value = mock_response OIDCConfiguration.get_oidc_configuration( - config_url=AnyHttpUrl(TEST_CONFIG_URL), + config_url=TEST_CONFIG_URL, strict=False, timeout_seconds=10, ) @@ -423,7 +423,7 @@ class TestGetOIDCConfiguration: mock_get.assert_called_once() call_args = mock_get.call_args - assert call_args[0][0] == TEST_CONFIG_URL + assert str(call_args[0][0]) == str(TEST_CONFIG_URL) def validate_proxy(mock_get, proxy, oidc_config): @@ -431,13 +431,13 @@ def validate_proxy(mock_get, proxy, oidc_config): mock_get.assert_called_once() call_args = mock_get.call_args - assert str(call_args[0][0]) == TEST_CONFIG_URL + assert str(call_args[0][0]) == str(TEST_CONFIG_URL) assert proxy._upstream_authorization_endpoint == TEST_AUTHORIZATION_ENDPOINT assert proxy._upstream_token_endpoint == TEST_TOKEN_ENDPOINT assert proxy._upstream_client_id == TEST_CLIENT_ID assert proxy._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET - assert str(proxy.base_url) == TEST_BASE_URL + assert str(proxy.base_url) == str(TEST_BASE_URL) assert proxy.oidc_config == oidc_config diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py index 87071a91f..10945d2fb 100644 --- a/tests/server/auth/test_redirect_validation.py +++ b/tests/server/auth/test_redirect_validation.py @@ -109,6 +109,65 @@ class TestValidateRedirectUri: assert not validate_redirect_uri(uri, patterns) +class TestSecurityBypass: + """Test protection against redirect URI security bypass attacks.""" + + def test_userinfo_bypass_blocked(self): + """Test that userinfo-style bypasses are blocked. + + Attack: http://localhost@evil.com/callback would match http://localhost:* + with naive string matching, but actually points to evil.com. + """ + pattern = "http://localhost:*" + + # These should be blocked - the "host" is actually in the userinfo + assert not matches_allowed_pattern( + "http://localhost@evil.com/callback", pattern + ) + assert not matches_allowed_pattern( + "http://localhost:3000@malicious.io/callback", pattern + ) + assert not matches_allowed_pattern( + "http://user:pass@localhost:3000/callback", pattern + ) + + def test_userinfo_bypass_with_subdomain_pattern(self): + """Test userinfo bypass with subdomain wildcard patterns.""" + pattern = "https://*.example.com/callback" + + # Blocked: userinfo tricks + assert not matches_allowed_pattern( + "https://app.example.com@attacker.com/callback", pattern + ) + assert not matches_allowed_pattern( + "https://user:pass@app.example.com/callback", pattern + ) + + def test_legitimate_uris_still_work(self): + """Test that legitimate URIs work after security hardening.""" + pattern = "http://localhost:*" + assert matches_allowed_pattern("http://localhost:3000/callback", pattern) + assert matches_allowed_pattern("http://localhost:8080/auth", pattern) + + pattern = "https://*.example.com/callback" + assert matches_allowed_pattern("https://app.example.com/callback", pattern) + + def test_scheme_mismatch_blocked(self): + """Test that scheme mismatches are blocked.""" + assert not matches_allowed_pattern( + "http://localhost:3000/callback", "https://localhost:*" + ) + assert not matches_allowed_pattern( + "https://localhost:3000/callback", "http://localhost:*" + ) + + def test_host_mismatch_blocked(self): + """Test that host mismatches are blocked even with wildcards.""" + pattern = "http://localhost:*" + assert not matches_allowed_pattern("http://127.0.0.1:3000/callback", pattern) + assert not matches_allowed_pattern("http://example.com:3000/callback", pattern) + + class TestDefaultPatterns: """Test the default localhost patterns constant.""" diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py new file mode 100644 index 000000000..79cf926a0 --- /dev/null +++ b/tests/server/auth/test_ssrf_protection.py @@ -0,0 +1,447 @@ +"""Tests for SSRF-safe HTTP utilities. + +This module tests the ssrf.py module which provides SSRF-protected HTTP fetching. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from fastmcp.server.auth.ssrf import ( + SSRFError, + SSRFFetchError, + is_ip_allowed, + ssrf_safe_fetch, + validate_url, +) + + +class TestIsIPAllowed: + """Tests for is_ip_allowed function.""" + + def test_public_ipv4_allowed(self): + """Public IPv4 addresses should be allowed.""" + assert is_ip_allowed("8.8.8.8") is True + assert is_ip_allowed("1.1.1.1") is True + assert is_ip_allowed("93.184.216.34") is True + + def test_private_ipv4_blocked(self): + """Private IPv4 addresses should be blocked.""" + assert is_ip_allowed("192.168.1.1") is False + assert is_ip_allowed("10.0.0.1") is False + assert is_ip_allowed("172.16.0.1") is False + + def test_loopback_blocked(self): + """Loopback addresses should be blocked.""" + assert is_ip_allowed("127.0.0.1") is False + assert is_ip_allowed("::1") is False + + def test_link_local_blocked(self): + """Link-local addresses (AWS metadata) should be blocked.""" + assert is_ip_allowed("169.254.169.254") is False + + def test_rfc6598_cgnat_blocked(self): + """RFC6598 Carrier-Grade NAT addresses should be blocked.""" + assert is_ip_allowed("100.64.0.1") is False + assert is_ip_allowed("100.100.100.100") is False + + def test_ipv4_mapped_ipv6_blocked_if_private(self): + """IPv4-mapped IPv6 addresses should check the embedded IPv4.""" + assert is_ip_allowed("::ffff:127.0.0.1") is False + assert is_ip_allowed("::ffff:192.168.1.1") is False + + +class TestValidateURL: + """Tests for validate_url function.""" + + async def test_http_rejected(self): + """HTTP URLs should be rejected (HTTPS required).""" + with pytest.raises(SSRFError, match="must use HTTPS"): + await validate_url("http://example.com/path") + + async def test_missing_host_rejected(self): + """URLs without host should be rejected.""" + with pytest.raises(SSRFError, match="must have a host"): + await validate_url("https:///path") + + async def test_root_path_rejected_when_required(self): + """Root paths should be rejected when require_path=True.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ): + with pytest.raises(SSRFError, match="non-root path"): + await validate_url("https://example.com/", require_path=True) + + async def test_private_ip_rejected(self): + """URLs resolving to private IPs should be rejected.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["192.168.1.1"], + ): + with pytest.raises(SSRFError, match="blocked IP"): + await validate_url("https://example.com/path") + + +class TestSSRFSafeFetch: + """Tests for ssrf_safe_fetch function.""" + + async def test_private_ip_blocked(self): + """Fetch to private IP should be blocked.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["192.168.1.1"], + ): + with pytest.raises(SSRFError, match="blocked IP"): + await ssrf_safe_fetch("https://internal.example.com/api") + + async def test_cgnat_blocked(self): + """Fetch to RFC6598 CGNAT IP should be blocked.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["100.64.0.1"], + ): + with pytest.raises(SSRFError, match="blocked IP"): + await ssrf_safe_fetch("https://cgnat.example.com/api") + + async def test_connects_to_pinned_ip(self): + """Verify connection uses pinned IP, not re-resolved DNS.""" + resolved_ip = "93.184.216.34" + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ip], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "15"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b'{"data": "test"}' + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + await ssrf_safe_fetch("https://example.com/api") + + # Verify URL contains pinned IP + call_args = mock_client.stream.call_args + url_called = call_args[0][1] + assert resolved_ip in url_called + + async def test_fallback_to_second_ip(self): + """If the first IP fails, the next resolved IP should be tried.""" + resolved_ips = ["2001:4860:4860::8888", "93.184.216.34"] + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=resolved_ips, + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + request = httpx.Request("GET", "https://example.com/api") + + first_client = AsyncMock() + first_client.stream = MagicMock( + side_effect=httpx.RequestError("boom", request=request) + ) + first_client.__aenter__.return_value = first_client + first_client.__aexit__ = AsyncMock(return_value=None) + + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "2"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b"ok" + + mock_stream.aiter_bytes = aiter_bytes + + second_client = AsyncMock() + second_client.stream = MagicMock(return_value=mock_stream) + second_client.__aenter__.return_value = second_client + second_client.__aexit__ = AsyncMock(return_value=None) + + mock_client_class.side_effect = [first_client, second_client] + + content = await ssrf_safe_fetch("https://example.com/api") + assert content == b"ok" + + call_args = second_client.stream.call_args + url_called = call_args[0][1] + assert resolved_ips[1] in url_called + + async def test_host_header_set(self): + """Verify Host header is set to original hostname.""" + resolved_ip = "93.184.216.34" + original_host = "example.com" + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ip], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "15"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b'{"data": "test"}' + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + await ssrf_safe_fetch(f"https://{original_host}/api") + + # Verify Host header + call_kwargs = mock_client.stream.call_args[1] + assert call_kwargs["headers"]["Host"] == original_host + + async def test_response_size_limit(self): + """Verify response size limit is enforced via streaming.""" + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + # Response larger than default 5KB (no Content-Length, so streaming enforces) + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {} # No Content-Length to force streaming check + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + # Yield 10KB total + for _ in range(10): + yield b"x" * 1024 + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SSRFFetchError, match="too large"): + await ssrf_safe_fetch("https://example.com/api") + + +class TestJWKSSSRFProtection: + """Tests for SSRF protection in JWTVerifier JWKS fetching.""" + + async def test_jwks_private_ip_blocked(self): + """JWKS fetch to private IP should be blocked.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier + + verifier = JWTVerifier( + jwks_uri="https://internal.example.com/.well-known/jwks.json", + issuer="https://issuer.example.com", + ssrf_safe=True, + ) + + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["192.168.1.1"], + ): + with pytest.raises(ValueError, match="Failed to fetch JWKS"): + # Create a dummy token to trigger JWKS fetch + await verifier._get_jwks_key("test-kid") + + async def test_jwks_cgnat_blocked(self): + """JWKS fetch to RFC6598 CGNAT IP should be blocked.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier + + verifier = JWTVerifier( + jwks_uri="https://cgnat.example.com/.well-known/jwks.json", + issuer="https://issuer.example.com", + ssrf_safe=True, + ) + + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["100.64.0.1"], + ): + with pytest.raises(ValueError, match="Failed to fetch JWKS"): + await verifier._get_jwks_key("test-kid") + + async def test_jwks_loopback_blocked(self): + """JWKS fetch to loopback should be blocked.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier + + verifier = JWTVerifier( + jwks_uri="https://localhost/.well-known/jwks.json", + issuer="https://issuer.example.com", + ssrf_safe=True, + ) + + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["127.0.0.1"], + ): + with pytest.raises(ValueError, match="Failed to fetch JWKS"): + await verifier._get_jwks_key("test-kid") + + +class TestIPv6URLFormatting: + """Tests for proper IPv6 address bracketing in URLs.""" + + def test_format_ip_for_url_ipv4(self): + """IPv4 addresses should not be bracketed.""" + from fastmcp.server.auth.ssrf import format_ip_for_url + + assert format_ip_for_url("8.8.8.8") == "8.8.8.8" + assert format_ip_for_url("192.168.1.1") == "192.168.1.1" + + def test_format_ip_for_url_ipv6(self): + """IPv6 addresses should be bracketed for URL use.""" + from fastmcp.server.auth.ssrf import format_ip_for_url + + assert format_ip_for_url("2001:db8::1") == "[2001:db8::1]" + assert format_ip_for_url("::1") == "[::1]" + assert format_ip_for_url("fe80::1") == "[fe80::1]" + + def test_format_ip_for_url_invalid(self): + """Invalid IP strings should be returned unchanged.""" + from fastmcp.server.auth.ssrf import format_ip_for_url + + assert format_ip_for_url("not-an-ip") == "not-an-ip" + assert format_ip_for_url("") == "" + + async def test_ipv6_pinned_url_is_valid(self): + """Verify IPv6 addresses are properly bracketed in pinned URLs.""" + resolved_ipv6 = "2001:4860:4860::8888" + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ipv6], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "10"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b'{"key": 1}' + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + await ssrf_safe_fetch("https://example.com/api") + + # Verify the URL contains bracketed IPv6 address + call_args = mock_client.stream.call_args + url_called = call_args[0][1] + + # IPv6 should be bracketed: https://[2001:4860:4860::8888]:443/path + assert f"[{resolved_ipv6}]" in url_called, ( + f"Expected bracketed IPv6 [{resolved_ipv6}] in URL, got {url_called}" + ) + + +class TestStreamingResponseSizeLimit: + """Tests for streaming-based response size enforcement.""" + + async def test_size_limit_enforced_during_streaming(self): + """Verify that size limit is enforced as chunks are received, not after.""" + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + chunks_yielded = [] + + async def aiter_bytes(): + # Yield chunks that exceed the limit + for i in range(10): + chunk = b"x" * 1024 # 1KB per chunk + chunks_yielded.append(chunk) + yield chunk + + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {} # No content-length to force streaming check + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SSRFFetchError, match="too large"): + await ssrf_safe_fetch("https://example.com/api", max_size=5120) + + # Verify we stopped after exceeding the limit (should be ~6 chunks for 5KB limit) + # This confirms we're enforcing during streaming, not after downloading all + assert len(chunks_yielded) <= 7, ( + f"Downloaded {len(chunks_yielded)} chunks (expected <=7 for streaming enforcement)" + ) + + async def test_content_length_header_checked_first(self): + """Verify Content-Length header is checked before streaming.""" + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "10240"} # 10KB + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + # aiter_bytes should never be called if Content-Length is checked + mock_stream.aiter_bytes = MagicMock( + side_effect=AssertionError("Should not stream") + ) + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SSRFFetchError, match="too large"): + await ssrf_safe_fetch("https://example.com/api", max_size=5120) diff --git a/tests/utilities/openapi/test_models.py b/tests/utilities/openapi/test_models.py index cc4baadb3..4361635c2 100644 --- a/tests/utilities/openapi/test_models.py +++ b/tests/utilities/openapi/test_models.py @@ -4,8 +4,10 @@ import pytest from inline_snapshot import snapshot from fastmcp.utilities.openapi.models import ( + HttpMethod, HTTPRoute, ParameterInfo, + ParameterLocation, RequestBodyInfo, ResponseInfo, ) @@ -51,7 +53,7 @@ class TestParameterInfo: assert param.style == "deepObject" @pytest.mark.parametrize("location", ["path", "query", "header", "cookie"]) - def test_valid_parameter_locations(self, location): + def test_valid_parameter_locations(self, location: ParameterLocation): """Test that all valid parameter locations are accepted.""" param = ParameterInfo( name="test", @@ -286,7 +288,7 @@ class TestHTTPRoute: @pytest.mark.parametrize( "method", ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] ) - def test_valid_http_methods(self, method): + def test_valid_http_methods(self, method: HttpMethod): """Test that all valid HTTP methods are accepted.""" route = HTTPRoute( path="/test", From 30832ced1cd17e679f60cb88dd86ae7b7d63f403 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 6 Feb 2026 23:13:26 +0000 Subject: [PATCH 02/14] Add ResponseLimitingMiddleware for tool response size control (#3072) --- docs/servers/middleware.mdx | 44 +++++ .../server/middleware/response_limiting.py | 125 ++++++++++++++ .../middleware/test_response_limiting.py | 155 ++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 src/fastmcp/server/middleware/response_limiting.py create mode 100644 tests/server/middleware/test_response_limiting.py diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index a70107816..ddf283fb9 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -555,6 +555,50 @@ my_tool = Tool.from_function(fn=my_tool_fn, name="my_tool") mcp.add_middleware(ToolInjectionMiddleware(tools=[my_tool])) ``` +### Response Limiting + + + +```python +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware +``` + +Large tool responses can overwhelm LLM context windows or cause memory issues. You can add response-limiting middleware to enforce size constraints on tool outputs. + +```python +from fastmcp import FastMCP +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware + +mcp = FastMCP("MyServer") + +# Limit all tool responses to 500KB +mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) + +@mcp.tool +def search(query: str) -> str: + # This could return a very large result + return "x" * 1_000_000 # 1MB response + +# When called, the response will be truncated to ~500KB with: +# "...\n\n[Response truncated due to size limit]" +``` + +When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source. + +```python +# Limit only specific tools +mcp.add_middleware(ResponseLimitingMiddleware( + max_size=100_000, + tools=["search", "fetch_data"], +)) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `max_size` | `int` | `1_000_000` | Maximum response size in bytes (1MB default) | +| `truncation_suffix` | `str` | `"\n\n[Response truncated due to size limit]"` | Suffix appended to truncated responses | +| `tools` | `list[str] \| None` | `None` | Limit only these tools (None = all tools) | + ### Combining Middleware Order matters. Place middleware that should run first (on the way in) earliest: diff --git a/src/fastmcp/server/middleware/response_limiting.py b/src/fastmcp/server/middleware/response_limiting.py new file mode 100644 index 000000000..df83e81a0 --- /dev/null +++ b/src/fastmcp/server/middleware/response_limiting.py @@ -0,0 +1,125 @@ +"""Response limiting middleware for controlling tool response sizes.""" + +from __future__ import annotations + +import logging + +import mcp.types as mt +import pydantic_core +from mcp.types import TextContent + +from fastmcp.tools.tool import ToolResult + +from .middleware import CallNext, Middleware, MiddlewareContext + +__all__ = ["ResponseLimitingMiddleware"] + +logger = logging.getLogger(__name__) + + +class ResponseLimitingMiddleware(Middleware): + """Middleware that limits the response size of tool calls. + + Intercepts tool call responses and enforces size limits. If a response + exceeds the limit, it extracts text content, truncates it, and returns + a single TextContent block. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.middleware.response_limiting import ( + ResponseLimitingMiddleware, + ) + + mcp = FastMCP("MyServer") + + # Limit all tool responses to 500KB + mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) + + # Limit only specific tools + mcp.add_middleware( + ResponseLimitingMiddleware( + max_size=100_000, + tools=["search", "fetch_data"], + ) + ) + ``` + """ + + def __init__( + self, + *, + max_size: int = 1_000_000, + truncation_suffix: str = "\n\n[Response truncated due to size limit]", + tools: list[str] | None = None, + ) -> None: + """Initialize response limiting middleware. + + Args: + max_size: Maximum response size in bytes. Defaults to 1MB (1,000,000). + truncation_suffix: Suffix to append when truncating responses. + Defaults to "\\n\\n[Response truncated due to size limit]". + tools: List of tool names to apply limiting to. If None, applies to all. + """ + if max_size <= 0: + raise ValueError(f"max_size must be positive, got {max_size}") + self.max_size = max_size + self.truncation_suffix = truncation_suffix + self.tools = set(tools) if tools is not None else None + + def _truncate_to_result(self, text: str) -> ToolResult: + """Truncate text to fit within max_size and wrap in ToolResult.""" + suffix_bytes = len(self.truncation_suffix.encode("utf-8")) + # Account for JSON wrapper overhead: {"content":[{"type":"text","text":"..."}]} + overhead = 50 + target_size = self.max_size - suffix_bytes - overhead + + if target_size <= 0: + # Edge case: max_size too small for even the suffix + truncated = self.truncation_suffix + else: + # Truncate to target size, preserving UTF-8 boundaries + encoded = text.encode("utf-8") + if len(encoded) <= target_size: + truncated = text + self.truncation_suffix + else: + truncated = ( + encoded[:target_size].decode("utf-8", errors="ignore") + + self.truncation_suffix + ) + + return ToolResult(content=[TextContent(type="text", text=truncated)]) + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + """Intercept tool calls and limit response size.""" + result = await call_next(context) + + # Check if we should limit this tool + if self.tools is not None and context.message.name not in self.tools: + return result + + # Measure serialized size + serialized = pydantic_core.to_json(result, fallback=str) + if len(serialized) <= self.max_size: + return result + + # Over limit: extract text, truncate, return single TextContent + logger.warning( + "Tool %r response exceeds size limit: %d bytes > %d bytes, truncating", + context.message.name, + len(serialized), + self.max_size, + ) + + texts = [b.text for b in result.content if isinstance(b, TextContent)] + text = ( + "\n\n".join(texts) + if texts + else serialized.decode("utf-8", errors="replace") + ) + + return self._truncate_to_result(text) diff --git a/tests/server/middleware/test_response_limiting.py b/tests/server/middleware/test_response_limiting.py new file mode 100644 index 000000000..4e89e05de --- /dev/null +++ b/tests/server/middleware/test_response_limiting.py @@ -0,0 +1,155 @@ +"""Tests for ResponseLimitingMiddleware.""" + +import pytest +from mcp.types import ImageContent, TextContent + +from fastmcp import Client, FastMCP +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware +from fastmcp.tools.tool import ToolResult + + +class TestResponseLimitingMiddleware: + """Tests for ResponseLimitingMiddleware.""" + + @pytest.fixture + def mcp_server(self) -> FastMCP: + """Create a basic MCP server for testing.""" + return FastMCP("test-server") + + async def test_response_under_limit_passes_unchanged(self, mcp_server: FastMCP): + """Test that responses under the limit pass through unchanged.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=1_000_000)) + + @mcp_server.tool() + def small_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="hello world")]) + + async with Client(mcp_server) as client: + result = await client.call_tool("small_tool", {}) + assert len(result.content) == 1 + assert result.content[0].text == "hello world" + + async def test_response_over_limit_is_truncated(self, mcp_server: FastMCP): + """Test that responses over the limit are truncated.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=500)) + + @mcp_server.tool() + def large_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + async with Client(mcp_server) as client: + result = await client.call_tool("large_tool", {}) + assert len(result.content) == 1 + assert "[Response truncated due to size limit]" in result.content[0].text + # Verify truncated result fits within limit + assert len(result.content[0].text.encode("utf-8")) < 500 + + async def test_tool_filtering(self, mcp_server: FastMCP): + """Test that tool filtering only applies to specified tools.""" + mcp_server.add_middleware( + ResponseLimitingMiddleware(max_size=100, tools=["limited_tool"]) + ) + + @mcp_server.tool() + def limited_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + @mcp_server.tool() + def unlimited_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="y" * 10_000)]) + + async with Client(mcp_server) as client: + # Limited tool should be truncated + result = await client.call_tool("limited_tool", {}) + assert "[Response truncated" in result.content[0].text + + # Unlimited tool should pass through + result = await client.call_tool("unlimited_tool", {}) + assert "y" * 100 in result.content[0].text + + async def test_empty_tools_list_limits_nothing(self, mcp_server: FastMCP): + """Test that empty tools list means no tools are limited.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=100, tools=[])) + + @mcp_server.tool() + def any_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + async with Client(mcp_server) as client: + result = await client.call_tool("any_tool", {}) + # Should NOT be truncated + assert "[Response truncated" not in result.content[0].text + + async def test_custom_truncation_suffix(self, mcp_server: FastMCP): + """Test that custom truncation suffix is applied.""" + mcp_server.add_middleware( + ResponseLimitingMiddleware(max_size=200, truncation_suffix="\n[CUT]") + ) + + @mcp_server.tool() + def large_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + async with Client(mcp_server) as client: + result = await client.call_tool("large_tool", {}) + assert "[CUT]" in result.content[0].text + + async def test_multiple_text_blocks_combined(self, mcp_server: FastMCP): + """Test that multiple text blocks are combined when truncating.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=300)) + + @mcp_server.tool() + def multi_block() -> ToolResult: + return ToolResult( + content=[ + TextContent(type="text", text="First: " + "a" * 500), + TextContent(type="text", text="Second: " + "b" * 500), + ] + ) + + async with Client(mcp_server) as client: + result = await client.call_tool("multi_block", {}) + # Both blocks should be joined and truncated + assert len(result.content) == 1 + assert "[Response truncated" in result.content[0].text + + async def test_binary_only_content_serialized(self, mcp_server: FastMCP): + """Test that binary-only responses fall back to serialized content.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=200)) + + @mcp_server.tool() + def binary_tool() -> ToolResult: + return ToolResult( + content=[ + ImageContent(type="image", data="x" * 10_000, mimeType="image/png") + ] + ) + + async with Client(mcp_server) as client: + result = await client.call_tool("binary_tool", {}) + # Should be truncated (using serialized fallback) + assert len(result.content) == 1 + assert "[Response truncated" in result.content[0].text + + async def test_default_max_size_is_1mb(self): + """Test that the default max size is 1MB.""" + middleware = ResponseLimitingMiddleware() + assert middleware.max_size == 1_000_000 + + def test_invalid_max_size_raises(self): + """Test that zero or negative max_size raises ValueError.""" + with pytest.raises(ValueError, match="max_size must be positive"): + ResponseLimitingMiddleware(max_size=0) + with pytest.raises(ValueError, match="max_size must be positive"): + ResponseLimitingMiddleware(max_size=-100) + + def test_utf8_truncation_preserves_characters(self): + """Test that UTF-8 truncation doesn't break multi-byte characters.""" + middleware = ResponseLimitingMiddleware(max_size=100) + # Text with multi-byte characters (emoji) + text = "Hello 🌍 World 🎉 Test " * 100 + result = middleware._truncate_to_result(text) + # Should not raise and should be valid UTF-8 + content = result.content[0] + assert isinstance(content, TextContent) + content.text.encode("utf-8") From 32c6826e13409db27ea340ba0c2f26bba65b80f5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:15:57 -0500 Subject: [PATCH 03/14] Add note about output_schema incongruity when responses are truncated (#3099) --- docs/servers/middleware.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index ddf283fb9..cea5d3c27 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -585,6 +585,10 @@ def search(query: str) -> str: When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source. + +If a tool defines an `output_schema`, truncated responses will no longer conform to that schema — the client will receive a plain `TextContent` block instead of the expected structured output. Keep this in mind when setting size limits for tools with structured responses. + + ```python # Limit only specific tools mcp.add_middleware(ResponseLimitingMiddleware( From b8d789c1b4dcba7b7606c7d06abcb7b7e163beec Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:20:17 -0500 Subject: [PATCH 04/14] Document token passthrough security in OAuth Proxy docs (#3100) --- docs/servers/auth/oauth-proxy.mdx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 86a8865f3..5b97f4452 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -416,7 +416,7 @@ sequenceDiagram Note over Client, Proxy: Token Exchange Client->>Proxy: 11. POST /token with code
code_verifier=CLIENT_VERIFIER - Proxy-->>Client: 12. Returns stored provider tokens + Proxy-->>Client: 12. Returns FastMCP JWT tokens ``` The flow diagram above illustrates the complete OAuth proxy pattern. Let's understand each phase: @@ -447,7 +447,7 @@ After user authorization, the provider redirects back to the proxy's fixed callb ### Token Exchange Phase -Finally, the client exchanges its authorization code with the proxy to receive the provider's tokens. The proxy validates the client's PKCE verifier before returning the stored tokens. +Finally, the client exchanges its authorization code with the proxy. The proxy validates the client's PKCE verifier, then issues its own FastMCP JWT tokens (rather than forwarding the upstream provider's tokens). See [Token Architecture](#token-architecture) for details on this design. This entire flow is transparent to the MCP client—it experiences a standard OAuth flow with dynamic registration, unaware that a proxy is managing the complexity behind the scenes. @@ -475,6 +475,8 @@ When a client makes an MCP request with its FastMCP token: This two-tier validation ensures that FastMCP tokens can only be used with this server (via audience validation) while maintaining full upstream token security. +This architecture also prevents [token passthrough](#token-passthrough) — see the [Security](#security) section for details. + **Token expiry alignment:** FastMCP token lifetimes match the upstream token lifetimes. When the upstream token expires, the FastMCP token also expires, maintaining consistent security boundaries. @@ -628,6 +630,20 @@ The consent page automatically displays your server's name, icon, and website UR - [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem) - Official specification guidance - [Confused Deputy Attacks Explained](https://den.dev/blog/mcp-confused-deputy-api-management/) - Detailed walkthrough by Den Delimarsky +### Token Passthrough + +[Token passthrough](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#token-passthrough) occurs when an intermediary exposes upstream tokens to downstream clients, allowing those clients to impersonate the intermediary or access services they shouldn't reach. + +#### Client-facing mitigation + +The OAuth proxy's [token factory architecture](#token-architecture) prevents this by design. MCP clients only ever receive FastMCP-issued JWTs — the upstream provider token is never sent to the client. A FastMCP JWT is scoped to your server and cannot be used to access the upstream provider directly, even if intercepted. + +#### Calling downstream services + +When your MCP server needs to call other APIs on behalf of the authenticated user, avoid forwarding the upstream token directly — this reintroduces the token passthrough problem in the other direction. Instead, use a token exchange flow like [OAuth 2.0 Token Exchange (RFC 8693)](https://datatracker.ietf.org/doc/html/rfc8693) or your provider's equivalent (such as Azure's [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)) to obtain a new token scoped to the downstream service. + +The upstream token is available in your tool functions via `get_access_token()` or the `CurrentAccessToken` dependency, which you can use as the assertion for a token exchange. The exchanged token will be scoped to the specific downstream service and identify your MCP server as the authorized intermediary, maintaining proper audience boundaries throughout the chain. + ## Production Configuration For production deployments, load sensitive credentials from environment variables: From 6a358902f282c001266a2d32ffecbd079a0e3ede Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:52:21 -0500 Subject: [PATCH 05/14] Fix compress_schema to preserve additionalProperties: false for MCP compatibility (#3102) Changes: - Changed default of prune_additional_properties from True to False in compress_schema - Added test demonstrating MCP client compatibility requirement - Updated existing tests to explicitly enable pruning when needed - Added additionalProperties: false to manually constructed schemas in tool_transform - Updated inline snapshots to reflect new behavior Fixes #3008 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/tools/tool_transform.py | 2 + src/fastmcp/utilities/json_schema.py | 6 ++- tests/tools/tool/test_tool.py | 8 +++ tests/tools/tool_transform/test_schemas.py | 5 ++ tests/utilities/test_json_schema.py | 58 ++++++++++++++++++++-- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 85ea9e02e..f22010750 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -685,6 +685,7 @@ class TransformedTool(Tool): "type": "object", "properties": new_props, "required": list(new_required), + "additionalProperties": False, } if parent_defs: @@ -868,6 +869,7 @@ class TransformedTool(Tool): "type": "object", "properties": merged_props, "required": list(final_required), + "additionalProperties": False, } if merged_defs: diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index 4ebf9d126..f302e1cd1 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -364,7 +364,7 @@ def _single_pass_optimize( def compress_schema( schema: dict[str, Any], prune_params: list[str] | None = None, - prune_additional_properties: bool = True, + prune_additional_properties: bool = False, prune_titles: bool = False, ) -> dict[str, Any]: """ @@ -378,7 +378,9 @@ def compress_schema( Args: schema: The schema to compress prune_params: List of parameter names to remove from properties - prune_additional_properties: Whether to remove additionalProperties: false + prune_additional_properties: Whether to remove additionalProperties: false. + Defaults to False to maintain MCP client compatibility, as some clients + (e.g., Claude) require additionalProperties: false for strict validation. prune_titles: Whether to remove title fields from the schema """ # Dereference $ref - this inlines all definitions and removes $defs diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py index 37499914e..dd75d7443 100644 --- a/tests/tools/tool/test_tool.py +++ b/tests/tools/tool/test_tool.py @@ -30,6 +30,7 @@ class TestToolFromFunction: "description": "Add two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "a": {"type": "integer"}, "b": {"type": "integer"}, @@ -83,6 +84,7 @@ class TestToolFromFunction: "description": "Fetch data from URL.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": {"url": {"type": "string"}}, "required": ["url"], "type": "object", @@ -117,6 +119,7 @@ class TestToolFromFunction: "description": "Adds two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "x": {"type": "integer"}, "y": {"type": "integer"}, @@ -153,6 +156,7 @@ class TestToolFromFunction: "description": "Adds two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "x": {"type": "integer"}, "y": {"type": "integer"}, @@ -192,6 +196,7 @@ class TestToolFromFunction: "description": "Create a new user.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "user": { "properties": { @@ -270,6 +275,7 @@ class TestToolFromFunction: "name": "my_tool", "tags": set(), "parameters": { + "additionalProperties": False, "properties": {"x": {"title": "X"}}, "required": ["x"], "type": "object", @@ -302,6 +308,7 @@ class TestToolFromFunction: "description": "Add two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "_a": {"type": "integer"}, "_b": {"type": "integer"}, @@ -353,6 +360,7 @@ class TestToolFromFunction: "description": "Add two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "x": {"type": "integer"}, "y": {"type": "integer"}, diff --git a/tests/tools/tool_transform/test_schemas.py b/tests/tools/tool_transform/test_schemas.py index 8b3db954a..51cb89f76 100644 --- a/tests/tools/tool_transform/test_schemas.py +++ b/tests/tools/tool_transform/test_schemas.py @@ -383,6 +383,7 @@ class TestInputSchema: "field2": {"type": "boolean"}, }, "required": [], + "additionalProperties": False, } ) @@ -424,6 +425,7 @@ class TestInputSchema: } }, "required": ["used_param"], + "additionalProperties": False, } ) @@ -464,6 +466,7 @@ class TestInputSchema: } }, "required": ["renamed_input"], + "additionalProperties": False, } ) @@ -508,6 +511,7 @@ class TestInputSchema: }, }, "required": IsList("param_b", "param_a", check_order=False), + "additionalProperties": False, } ) @@ -530,5 +534,6 @@ class TestInputSchema: } }, "required": ["param_a"], + "additionalProperties": False, } ) diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 9c9e77775..436beb6a2 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -228,13 +228,14 @@ class TestCompressSchema: assert result["required"] == ["bar"] def test_pruning_additional_properties(self): - """Test pruning additionalProperties when False.""" + """Test pruning additionalProperties when explicitly enabled.""" schema = { "type": "object", "properties": {"foo": {"type": "string"}}, "additionalProperties": False, } - result = compress_schema(schema) + # Must explicitly enable pruning now (default changed for MCP compatibility) + result = compress_schema(schema, prune_additional_properties=True) assert "additionalProperties" not in result def test_disable_pruning_additional_properties(self): @@ -263,7 +264,9 @@ class TestCompressSchema: "unused_def": {"type": "number"}, }, } - result = compress_schema(schema, prune_params=["remove"]) + result = compress_schema( + schema, prune_params=["remove"], prune_additional_properties=True + ) # Check that parameter was removed assert "remove" not in result["properties"] # Check that required list was updated @@ -296,7 +299,7 @@ class TestCompressSchema: assert "title" not in result["properties"]["bar"]["properties"]["nested"] def test_prune_nested_additional_properties(self): - """Test pruning additionalProperties: false at all levels.""" + """Test pruning additionalProperties: false at all levels when explicitly enabled.""" schema = { "type": "object", "additionalProperties": False, @@ -313,7 +316,7 @@ class TestCompressSchema: }, }, } - result = compress_schema(schema) + result = compress_schema(schema, prune_additional_properties=True) assert "additionalProperties" not in result assert "additionalProperties" not in result["properties"]["foo"] assert ( @@ -393,6 +396,51 @@ class TestCompressSchema: ) assert "title" not in compressed["properties"]["normal_field"] + def test_mcp_client_compatibility_requires_additional_properties(self): + """Test that compress_schema preserves additionalProperties: false for MCP clients. + + MCP clients like Claude require strict JSON schemas with additionalProperties: false. + When tools use Pydantic models with extra="forbid", this constraint must be preserved. + + Without this, MCP clients return: + "Invalid schema for function 'X': In context=('properties', 'Y'), + 'additionalProperties' is required to be supplied and to be false" + + See: https://github.com/jlowin/fastmcp/issues/3008 + """ + # Schema representing a Pydantic model with extra="forbid" + schema = { + "type": "object", + "properties": { + "graph_table": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "columns": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["name"], + "additionalProperties": False, + } + }, + "required": ["graph_table"], + "additionalProperties": False, + } + + # By default, compress_schema should NOT strip additionalProperties: false + # This is the new expected behavior for MCP compatibility + result = compress_schema(schema) + + # Root level should preserve additionalProperties: false + assert result.get("additionalProperties") is False, ( + "Root additionalProperties: false was removed, breaking MCP compatibility" + ) + + # Nested object should also preserve additionalProperties: false + graph_table = result["properties"]["graph_table"] + assert graph_table.get("additionalProperties") is False, ( + "Nested additionalProperties: false was removed, breaking MCP compatibility" + ) + class TestResolveRootRef: """Tests for the resolve_root_ref function. From 85eff33b81248dbc7ff907822ec473f351367a2a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:08:08 -0500 Subject: [PATCH 06/14] Infer MIME types from OpenAPI response definitions (#3101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Infer mime_type from OpenAPI response content types for resources 🤖 Generated with Claude Code https://claude.ai/code/session_01FZD5ZT8WiQqfBu39ybuQis * Handle media types without schemas in MIME inference 🤖 Generated with Claude Code https://claude.ai/code/session_01FZD5ZT8WiQqfBu39ybuQis --------- Co-authored-by: Claude --- .../server/providers/openapi/components.py | 58 ++- .../server/providers/openapi/provider.py | 3 + src/fastmcp/utilities/openapi/parser.py | 4 + .../openapi/test_openapi_features.py | 357 ++++++++++++++++++ 4 files changed, 421 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 43c58b956..4e6d18f1e 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -33,10 +33,64 @@ __all__ = [ "OpenAPIResource", "OpenAPIResourceTemplate", "OpenAPITool", + "_extract_mime_type_from_route", ] logger = get_logger(__name__) +# Default MIME type when no response content type can be inferred +_DEFAULT_MIME_TYPE = "application/json" + + +def _extract_mime_type_from_route(route: HTTPRoute) -> str: + """Extract the primary MIME type from an HTTPRoute's response definitions. + + Looks for the first successful response (2xx) and returns its content type. + Prefers JSON-compatible types when multiple are available. + Falls back to "application/json" when no response content type is declared. + """ + if not route.responses: + return _DEFAULT_MIME_TYPE + + # Priority order for success status codes + success_codes = ["200", "201", "202", "204"] + + response_info = None + for status_code in success_codes: + if status_code in route.responses: + response_info = route.responses[status_code] + break + + # If no explicit success codes, try any 2xx response + if response_info is None: + for status_code, resp_info in route.responses.items(): + if status_code.startswith("2"): + response_info = resp_info + break + + if response_info is None or not response_info.content_schema: + return _DEFAULT_MIME_TYPE + + # If there's only one content type, use it directly + content_types = list(response_info.content_schema.keys()) + if len(content_types) == 1: + return content_types[0] + + # When multiple types exist, prefer JSON-compatible types + json_compatible_types = [ + "application/json", + "application/vnd.api+json", + "application/hal+json", + "application/ld+json", + "text/json", + ] + for ct in json_compatible_types: + if ct in response_info.content_schema: + return ct + + # Fall back to the first available content type + return content_types[0] + def _slugify(text: str) -> str: """Convert text to a URL-friendly slug format. @@ -294,6 +348,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): description: str, parameters: dict[str, Any], tags: set[str] | None = None, + mime_type: str = _DEFAULT_MIME_TYPE, ): super().__init__( uri_template=uri_template, @@ -301,6 +356,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): description=description, parameters=parameters, tags=tags or set(), + mime_type=mime_type, ) self._client = client self._route = route @@ -325,6 +381,6 @@ class OpenAPIResourceTemplate(ResourceTemplate): uri=uri, name=f"{self.name}-{'-'.join(uri_parts)}", description=self.description or f"Resource for {self._route.path}", - mime_type="application/json", + mime_type=self.mime_type, tags=set(self._route.tags or []), ) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index a93be1a41..ac79af400 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -17,6 +17,7 @@ from fastmcp.server.providers.openapi.components import ( OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, + _extract_mime_type_from_route, _slugify, ) from fastmcp.server.providers.openapi.routing import ( @@ -288,6 +289,7 @@ class OpenAPIProvider(Provider): uri=resource_uri, name=resource_name, description=enhanced_description, + mime_type=_extract_mime_type_from_route(route), tags=set(route.tags or []) | tags, ) @@ -356,6 +358,7 @@ class OpenAPIProvider(Provider): description=enhanced_description, parameters=template_params_schema, tags=set(route.tags or []) | tags, + mime_type=_extract_mime_type_from_route(route), ) if self._mcp_component_fn is not None: diff --git a/src/fastmcp/utilities/openapi/parser.py b/src/fastmcp/utilities/openapi/parser.py index e284295fa..40adf8d27 100644 --- a/src/fastmcp/utilities/openapi/parser.py +++ b/src/fastmcp/utilities/openapi/parser.py @@ -506,6 +506,10 @@ class OpenAPIParser( f"Failed to extract schema for media type '{media_type_str}' " f"in response {status_code}: {e}" ) + else: + # Record the media type even without a schema so MIME + # type inference can still use the declared content type. + resp_info.content_schema.setdefault(media_type_str, {}) extracted_responses[str(status_code)] = resp_info except ValueError as e: diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index b466268fe..f55a1e038 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -6,6 +6,9 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.providers.openapi.components import _extract_mime_type_from_route +from fastmcp.server.providers.openapi.routing import MCPType, RouteMap +from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo def create_openapi_server( @@ -412,3 +415,357 @@ class TestResponseSchemas: # Let's just check the tool exists and has basic properties assert get_user_tool.description is not None assert get_user_tool.name == "get_user" + + +class TestMimeTypeExtraction: + """Test MIME type extraction from route responses.""" + + def test_json_response(self): + """JSON content type is correctly extracted.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "200": ResponseInfo( + content_schema={"application/json": {"type": "object"}} + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_text_plain_response(self): + """Plain text content type is correctly extracted.""" + route = HTTPRoute( + path="/health", + method="GET", + responses={ + "200": ResponseInfo(content_schema={"text/plain": {"type": "string"}}) + }, + ) + assert _extract_mime_type_from_route(route) == "text/plain" + + def test_text_html_response(self): + """HTML content type is correctly extracted.""" + route = HTTPRoute( + path="/page", + method="GET", + responses={ + "200": ResponseInfo(content_schema={"text/html": {"type": "string"}}) + }, + ) + assert _extract_mime_type_from_route(route) == "text/html" + + def test_image_response(self): + """Image content type is correctly extracted.""" + route = HTTPRoute( + path="/avatar", + method="GET", + responses={ + "200": ResponseInfo( + content_schema={"image/png": {"type": "string", "format": "binary"}} + ) + }, + ) + assert _extract_mime_type_from_route(route) == "image/png" + + def test_no_responses_defaults_to_json(self): + """Empty responses default to application/json.""" + route = HTTPRoute(path="/items", method="GET", responses={}) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_no_content_schema_defaults_to_json(self): + """Response without content_schema defaults to application/json.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={"204": ResponseInfo(description="No content")}, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_prefers_json_when_multiple_types(self): + """When both JSON and other types exist, JSON is preferred.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "200": ResponseInfo( + content_schema={ + "text/html": {"type": "string"}, + "application/json": {"type": "object"}, + } + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_non_standard_2xx_code(self): + """Falls back to any 2xx status code when standard ones are missing.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "206": ResponseInfo( + content_schema={ + "application/octet-stream": { + "type": "string", + "format": "binary", + } + } + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/octet-stream" + + def test_ignores_error_responses(self): + """Only error responses (no 2xx) results in default.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "404": ResponseInfo( + content_schema={"application/json": {"type": "object"}} + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_201_response(self): + """201 Created response content type is extracted.""" + route = HTTPRoute( + path="/items", + method="POST", + responses={ + "201": ResponseInfo(content_schema={"text/plain": {"type": "string"}}) + }, + ) + assert _extract_mime_type_from_route(route) == "text/plain" + + def test_media_type_without_schema(self): + """Media type declared without a schema still infers MIME type.""" + route = HTTPRoute( + path="/health", + method="GET", + responses={"200": ResponseInfo(content_schema={"text/plain": {}})}, + ) + assert _extract_mime_type_from_route(route) == "text/plain" + + +class TestResourceTemplateMimeType: + """Test that OpenAPIResourceTemplate uses inferred MIME types.""" + + @pytest.fixture + def text_plain_spec(self): + """OpenAPI spec with a text/plain resource template endpoint.""" + return { + "openapi": "3.0.0", + "info": {"title": "Text API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/documents/{id}": { + "get": { + "operationId": "get_document", + "summary": "Get document content", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": { + "200": { + "description": "Document content", + "content": { + "text/plain": {"schema": {"type": "string"}} + }, + } + }, + } + } + }, + } + + @pytest.fixture + def html_spec(self): + """OpenAPI spec with a text/html resource endpoint.""" + return { + "openapi": "3.0.0", + "info": {"title": "HTML API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/pages/{slug}": { + "get": { + "operationId": "get_page", + "summary": "Get HTML page", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": { + "200": { + "description": "HTML page", + "content": { + "text/html": {"schema": {"type": "string"}} + }, + } + }, + } + } + }, + } + + async def test_resource_template_text_plain_mime_type(self, text_plain_spec): + """Resource template should reflect text/plain from OpenAPI spec.""" + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=text_plain_spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + templates = await mcp_client.list_resource_templates() + assert len(templates) == 1 + assert templates[0].mimeType == "text/plain" + + async def test_resource_template_html_mime_type(self, html_spec): + """Resource template should reflect text/html from OpenAPI spec.""" + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=html_spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + templates = await mcp_client.list_resource_templates() + assert len(templates) == 1 + assert templates[0].mimeType == "text/html" + + async def test_resource_template_defaults_json_mime_type(self): + """Resource template defaults to application/json for JSON responses.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "JSON API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "summary": "Get user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ], + "responses": { + "200": { + "description": "User data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + } + }, + } + }, + } + } + }, + } + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + templates = await mcp_client.list_resource_templates() + assert len(templates) == 1 + assert templates[0].mimeType == "application/json" + + +class TestResourceMimeType: + """Test that OpenAPIResource uses inferred MIME types.""" + + async def test_resource_text_plain_mime_type(self): + """Static resource should reflect text/plain from OpenAPI spec.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Health API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/health": { + "get": { + "operationId": "healthcheck", + "summary": "Health check", + "responses": { + "200": { + "description": "Health status", + "content": { + "text/plain": {"schema": {"type": "string"}} + }, + } + }, + } + } + }, + } + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + resources = await mcp_client.list_resources() + assert len(resources) == 1 + assert resources[0].mimeType == "text/plain" + + async def test_resource_mime_type_without_schema(self): + """Resource with media type but no schema still infers MIME type.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Health API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/health": { + "get": { + "operationId": "healthcheck", + "summary": "Health check", + "responses": { + "200": { + "description": "Health status", + "content": {"text/plain": {}}, + } + }, + } + } + }, + } + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + resources = await mcp_client.list_resources() + assert len(resources) == 1 + assert resources[0].mimeType == "text/plain" From ad3b1b9d1b1584edcdcc111bf2b122041d028813 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:08:23 -0500 Subject: [PATCH 07/14] Fix CIMD redirect allowlist bypass and cache revalidation (#3098) * Harden CIMD redirect and cache handling * Preserve CIMD cache policy on 304 revalidation * Refresh 304 cache expiry from cached lifetime --- docs/servers/auth/oauth-proxy.mdx | 2 +- src/fastmcp/server/auth/cimd.py | 172 ++++++++++++- src/fastmcp/server/auth/oauth_proxy/models.py | 19 +- src/fastmcp/server/auth/ssrf.py | 55 +++- tests/server/auth/test_cimd.py | 238 ++++++++++++++++++ .../test_oauth_proxy_redirect_validation.py | 34 +++ 6 files changed, 502 insertions(+), 18 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 5b97f4452..b07092a1d 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -585,7 +585,7 @@ CIMD provides several security advantages over DCR: - **Replay prevention**: For `private_key_jwt` clients, JTI claims are tracked to prevent assertion replay - **Cache-aware fetching**: CIMD documents are cached according to HTTP cache headers and revalidated when required -To disable CIMD support entirely (for example, to require all clients to register via DCR): +CIMD is enabled by default. To disable it entirely (for example, to require all clients to register via DCR), set `enable_cimd=False` explicitly: ```python auth = OAuthProxy( diff --git a/src/fastmcp/server/auth/cimd.py b/src/fastmcp/server/auth/cimd.py index 49aa6687c..caef56f96 100644 --- a/src/fastmcp/server/auth/cimd.py +++ b/src/fastmcp/server/auth/cimd.py @@ -19,6 +19,10 @@ from __future__ import annotations import fnmatch import json import time +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timezone +from email.utils import parsedate_to_datetime from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urlparse @@ -27,7 +31,7 @@ from pydantic import AnyHttpUrl, BaseModel, Field, field_validator from fastmcp.server.auth.ssrf import ( SSRFError, SSRFFetchError, - ssrf_safe_fetch, + ssrf_safe_fetch_response, validate_url, ) from fastmcp.utilities.logging import get_logger @@ -155,12 +159,37 @@ class CIMDFetchError(Exception): """Raised when CIMD document fetching fails.""" +@dataclass +class _CIMDCacheEntry: + """Cached CIMD document and associated HTTP cache metadata.""" + + doc: CIMDDocument + etag: str | None + last_modified: str | None + expires_at: float + freshness_lifetime: float + must_revalidate: bool + + +@dataclass +class _CIMDCachePolicy: + """Normalized cache directives parsed from HTTP response headers.""" + + etag: str | None + last_modified: str | None + expires_at: float + freshness_lifetime: float + no_store: bool + must_revalidate: bool + + class CIMDFetcher: """Fetch and validate CIMD documents with SSRF protection. - Delegates HTTP fetching to ssrf_safe_fetch which provides DNS pinning, - IP validation, size limits, and timeout enforcement. Documents are cached - with a simple TTL. + Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS + pinning, IP validation, size limits, and timeout enforcement. Documents are + cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with + a TTL fallback when response headers do not define caching behavior. """ # Maximum response size (bytes) @@ -178,7 +207,65 @@ class CIMDFetcher: timeout: HTTP request timeout in seconds (default 10.0) """ self.timeout = timeout - self._cache: dict[str, tuple[CIMDDocument, float]] = {} + self._cache: dict[str, _CIMDCacheEntry] = {} + + def _parse_cache_policy( + self, headers: Mapping[str, str], now: float + ) -> _CIMDCachePolicy: + """Parse HTTP cache headers and derive cache behavior.""" + normalized = {k.lower(): v for k, v in headers.items()} + cache_control = normalized.get("cache-control", "") + directives = { + part.strip().lower() for part in cache_control.split(",") if part.strip() + } + + no_store = "no-store" in directives + must_revalidate = "no-cache" in directives + max_age: int | None = None + + for directive in directives: + if directive.startswith("max-age="): + value = directive.removeprefix("max-age=").strip() + try: + max_age = max(0, int(value)) + except ValueError: + logger.debug( + "Ignoring invalid Cache-Control max-age value: %s", value + ) + break + + expires_at: float | None = None + if max_age is not None: + expires_at = now + max_age + elif "expires" in normalized: + try: + dt = parsedate_to_datetime(normalized["expires"]) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + expires_at = dt.timestamp() + except (TypeError, ValueError): + logger.debug( + "Ignoring invalid Expires header on CIMD response: %s", + normalized["expires"], + ) + + if expires_at is None: + expires_at = now + self.DEFAULT_CACHE_TTL_SECONDS + freshness_lifetime = max(0.0, expires_at - now) + + return _CIMDCachePolicy( + etag=normalized.get("etag"), + last_modified=normalized.get("last-modified"), + expires_at=expires_at, + freshness_lifetime=freshness_lifetime, + no_store=no_store, + must_revalidate=must_revalidate, + ) + + def _has_freshness_headers(self, headers: Mapping[str, str]) -> bool: + """Return True when response includes cache freshness directives.""" + normalized = {k.lower() for k in headers} + return "cache-control" in normalized or "expires" in normalized def is_cimd_client_id(self, client_id: str) -> bool: """Check if a client_id looks like a CIMD URL. @@ -200,7 +287,7 @@ class CIMDFetcher: async def fetch(self, client_id_url: str) -> CIMDDocument: """Fetch and validate a CIMD document with SSRF protection. - Uses ssrf_safe_fetch for the HTTP layer, which provides: + Uses ssrf_safe_fetch_response for the HTTP layer, which provides: - HTTPS only, DNS resolution with IP validation - DNS pinning (connects to validated IP directly) - Blocks private/loopback/link-local/multicast IPs @@ -218,26 +305,76 @@ class CIMDFetcher: CIMDFetchError: If document cannot be fetched """ cached = self._cache.get(client_id_url) + now = time.time() + request_headers: dict[str, str] | None = None + allowed_status_codes = {200} + if cached is not None: - doc, expires_at = cached - if time.time() < expires_at: - return doc + if not cached.must_revalidate and now < cached.expires_at: + return cached.doc + + request_headers = {} + if cached.etag: + request_headers["If-None-Match"] = cached.etag + if cached.last_modified: + request_headers["If-Modified-Since"] = cached.last_modified + if request_headers: + allowed_status_codes = {200, 304} try: - content = await ssrf_safe_fetch( + response = await ssrf_safe_fetch_response( client_id_url, require_path=True, max_size=self.MAX_RESPONSE_SIZE, timeout=self.timeout, overall_timeout=30.0, + request_headers=request_headers, + allowed_status_codes=allowed_status_codes, ) except SSRFError as e: raise CIMDValidationError(str(e)) from e except SSRFFetchError as e: raise CIMDFetchError(str(e)) from e + if response.status_code == 304: + if cached is None: + raise CIMDFetchError( + "CIMD server returned 304 Not Modified without cached document" + ) + + now = time.time() + if self._has_freshness_headers(response.headers): + policy = self._parse_cache_policy(response.headers, now) + else: + # RFC allows 304 to omit unchanged headers. Preserve existing + # cache policy rather than resetting to fallback defaults. + policy = _CIMDCachePolicy( + etag=None, + last_modified=None, + expires_at=now + cached.freshness_lifetime, + freshness_lifetime=cached.freshness_lifetime, + no_store=False, + must_revalidate=cached.must_revalidate, + ) + + if not policy.no_store: + self._cache[client_id_url] = _CIMDCacheEntry( + doc=cached.doc, + etag=policy.etag or cached.etag, + last_modified=policy.last_modified or cached.last_modified, + expires_at=policy.expires_at, + freshness_lifetime=policy.freshness_lifetime, + must_revalidate=policy.must_revalidate, + ) + else: + self._cache.pop(client_id_url, None) + return cached.doc + + now = time.time() + policy = self._parse_cache_policy(response.headers, now) + try: - data = json.loads(content) + data = json.loads(response.content) except json.JSONDecodeError as e: raise CIMDValidationError(f"CIMD document is not valid JSON: {e}") from e @@ -268,7 +405,18 @@ class CIMDFetcher: doc.client_name, ) - self._cache[client_id_url] = (doc, time.time() + self.DEFAULT_CACHE_TTL_SECONDS) + if not policy.no_store: + self._cache[client_id_url] = _CIMDCacheEntry( + doc=doc, + etag=policy.etag, + last_modified=policy.last_modified, + expires_at=policy.expires_at, + freshness_lifetime=policy.freshness_lifetime, + must_revalidate=policy.must_revalidate, + ) + else: + self._cache.pop(client_id_url, None) + return doc def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool: diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py index 575c846ba..7525b6a0b 100644 --- a/src/fastmcp/server/auth/oauth_proxy/models.py +++ b/src/fastmcp/server/auth/oauth_proxy/models.py @@ -181,12 +181,27 @@ class ProxyDCRClient(OAuthClientInformationFull): "redirect_uri must be specified when CIMD redirect_uris uses wildcards." ) try: - return AnyUrl(candidate) + resolved = AnyUrl(candidate) except Exception as e: raise InvalidRedirectUriError( f"Invalid CIMD redirect_uri: {e}" ) from e + # Respect proxy-level redirect URI restrictions even when the + # client omits redirect_uri and we fall back to CIMD defaults. + if ( + self.allowed_redirect_uri_patterns is not None + and not validate_redirect_uri( + redirect_uri=resolved, + allowed_patterns=self.allowed_redirect_uri_patterns, + ) + ): + raise InvalidRedirectUriError( + f"Redirect URI '{resolved}' does not match allowed patterns." + ) + + return resolved + raise InvalidRedirectUriError( "redirect_uri must be specified when CIMD lists multiple redirect_uris." ) @@ -207,7 +222,7 @@ class ProxyDCRClient(OAuthClientInformationFull): f"Redirect URI '{redirect_uri}' does not match CIMD redirect_uris." ) - if self.allowed_redirect_uri_patterns: + if self.allowed_redirect_uri_patterns is not None: if not validate_redirect_uri( redirect_uri=redirect_uri, allowed_patterns=self.allowed_redirect_uri_patterns, diff --git a/src/fastmcp/server/auth/ssrf.py b/src/fastmcp/server/auth/ssrf.py index 8009269c6..39c28e959 100644 --- a/src/fastmcp/server/auth/ssrf.py +++ b/src/fastmcp/server/auth/ssrf.py @@ -12,6 +12,7 @@ import asyncio import ipaddress import socket import time +from collections.abc import Mapping from dataclasses import dataclass from urllib.parse import urlparse @@ -134,6 +135,15 @@ class ValidatedURL: resolved_ips: list[str] +@dataclass +class SSRFFetchResponse: + """Response payload from an SSRF-safe fetch.""" + + content: bytes + status_code: int + headers: dict[str, str] + + async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: """Validate URL for SSRF and resolve to IPs. @@ -215,12 +225,39 @@ async def ssrf_safe_fetch( SSRFError: If SSRF validation fails SSRFFetchError: If fetch fails """ + response = await ssrf_safe_fetch_response( + url, + require_path=require_path, + max_size=max_size, + timeout=timeout, + overall_timeout=overall_timeout, + allowed_status_codes={200}, + ) + return response.content + + +async def ssrf_safe_fetch_response( + url: str, + *, + require_path: bool = False, + max_size: int = 5120, + timeout: float = 10.0, + overall_timeout: float = 30.0, + request_headers: Mapping[str, str] | None = None, + allowed_status_codes: set[int] | None = None, +) -> SSRFFetchResponse: + """Fetch URL with SSRF protection and return response metadata. + + This is equivalent to :func:`ssrf_safe_fetch` but returns response headers + and status code, and supports conditional request headers. + """ start_time = time.monotonic() # Validate URL and resolve DNS validated = await validate_url(url, require_path=require_path) last_error: Exception | None = None + expected_statuses = allowed_status_codes or {200} for pinned_ip in validated.resolved_ips: elapsed = time.monotonic() - start_time @@ -239,6 +276,14 @@ async def ssrf_safe_fetch( pinned_ip, ) + headers = {"Host": validated.hostname} + if request_headers: + for key, value in request_headers.items(): + # Host must remain pinned to the validated hostname. + if key.lower() == "host": + continue + headers[key] = value + try: # Use httpx with streaming to enforce size limit during download async with ( @@ -255,14 +300,14 @@ async def ssrf_safe_fetch( client.stream( "GET", pinned_url, - headers={"Host": validated.hostname}, + headers=headers, extensions={"sni_hostname": validated.hostname}, ) as response, ): if time.monotonic() - start_time > overall_timeout: raise SSRFFetchError(f"Overall timeout exceeded: {url}") - if response.status_code != 200: + if response.status_code not in expected_statuses: raise SSRFFetchError(f"HTTP {response.status_code} fetching {url}") # Check Content-Length header first if available @@ -290,7 +335,11 @@ async def ssrf_safe_fetch( ) chunks.append(chunk) - return b"".join(chunks) + return SSRFFetchResponse( + content=b"".join(chunks), + status_code=response.status_code, + headers=dict(response.headers), + ) except httpx.TimeoutException as e: last_error = e diff --git a/tests/server/auth/test_cimd.py b/tests/server/auth/test_cimd.py index d3c3e316e..111d863c7 100644 --- a/tests/server/auth/test_cimd.py +++ b/tests/server/auth/test_cimd.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from unittest.mock import AsyncMock, patch import pytest @@ -247,6 +248,243 @@ class TestCIMDFetcherHTTP: assert first.client_id == second.client_id assert len(httpx_mock.get_requests()) == 1 + async def test_fetch_cache_control_max_age( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Cache-Control max-age should prevent refetch before expiry.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Max-Age App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"cache-control": "max-age=60", "content-length": "200"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + + assert first.client_name == second.client_name + assert len(httpx_mock.get_requests()) == 1 + + async def test_fetch_etag_revalidation_304( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Expired cache should revalidate with ETag and accept 304.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "ETag App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "max-age=0", + "etag": '"v1"', + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={ + "cache-control": "max-age=120", + "etag": '"v1"', + "content-length": "0", + }, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "ETag App" + assert second.client_name == "ETag App" + assert len(requests) == 2 + assert requests[1].headers.get("if-none-match") == '"v1"' + + async def test_fetch_last_modified_revalidation_304( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Expired cache should revalidate with Last-Modified and accept 304.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Last-Modified App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + last_modified = "Wed, 21 Oct 2015 07:28:00 GMT" + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "max-age=0", + "last-modified": last_modified, + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={"cache-control": "max-age=120", "content-length": "0"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "Last-Modified App" + assert second.client_name == "Last-Modified App" + assert len(requests) == 2 + assert requests[1].headers.get("if-modified-since") == last_modified + + async def test_fetch_cache_control_no_store( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Cache-Control no-store should prevent storing CIMD documents.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "No-Store App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"cache-control": "no-store", "content-length": "200"}, + ) + httpx_mock.add_response( + json=doc_data, + headers={"cache-control": "no-store", "content-length": "200"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + + assert first.client_name == second.client_name + assert len(httpx_mock.get_requests()) == 2 + + async def test_fetch_cache_control_no_cache( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Cache-Control no-cache should force revalidation on each fetch.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "No-Cache App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "no-cache", + "etag": '"v2"', + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={ + "cache-control": "no-cache", + "etag": '"v2"', + "content-length": "0", + }, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "No-Cache App" + assert second.client_name == "No-Cache App" + assert len(requests) == 2 + assert requests[1].headers.get("if-none-match") == '"v2"' + + async def test_fetch_304_without_cache_headers_preserves_policy( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """304 responses without cache headers should not reset cached policy.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "No-Header-304 App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "no-cache", + "etag": '"v3"', + "content-length": "200", + }, + ) + # Intentionally omit cache-control/expires on 304. + httpx_mock.add_response( + status_code=304, + headers={"content-length": "0"}, + ) + httpx_mock.add_response( + status_code=304, + headers={"content-length": "0"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + third = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "No-Header-304 App" + assert second.client_name == "No-Header-304 App" + assert third.client_name == "No-Header-304 App" + assert len(requests) == 3 + assert requests[1].headers.get("if-none-match") == '"v3"' + assert requests[2].headers.get("if-none-match") == '"v3"' + + async def test_fetch_304_without_cache_headers_refreshes_cached_freshness( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """A header-less 304 should renew freshness using cached lifetime.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Headerless 304 Freshness App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "max-age=60", + "etag": '"v4"', + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={"content-length": "0"}, + ) + + first = await fetcher.fetch(url) + + # Simulate cache expiry so the next request triggers revalidation. + cached_entry = fetcher._cache[url] + cached_entry.expires_at = time.time() - 1 + + second = await fetcher.fetch(url) + third = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "Headerless 304 Freshness App" + assert second.client_name == "Headerless 304 Freshness App" + assert third.client_name == "Headerless 304 Freshness App" + assert len(requests) == 2 + assert requests[1].headers.get("if-none-match") == '"v4"' + async def test_fetch_client_id_mismatch( self, fetcher: CIMDFetcher, httpx_mock, mock_dns ): diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 391977b88..47ecfbe8d 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -155,6 +155,23 @@ class TestProxyDCRClient: result = client.validate_redirect_uri(None) assert result == AnyUrl("http://localhost:3000/callback") + def test_cimd_none_redirect_uri_respects_proxy_patterns(self): + """CIMD fallback redirect_uri must still satisfy proxy allowlist patterns.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["https://evil.com/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + allowed_redirect_uri_patterns=["http://localhost:*"], + ) + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(None) + def test_cimd_none_redirect_uri_wildcard_rejected(self): """CIMD clients must specify redirect_uri when only wildcard patterns exist.""" cimd_doc = CIMDDocument( @@ -171,6 +188,23 @@ class TestProxyDCRClient: with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(None) + def test_cimd_empty_proxy_allowlist_rejects_redirect_uri(self): + """An explicit empty proxy allowlist should reject all CIMD redirect URIs.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + allowed_redirect_uri_patterns=[], + ) + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:3000/callback")) + class TestOAuthProxyRedirectValidation: """Test OAuth proxy with redirect URI validation.""" From 931d6f878cf394d78ba5a3e7c7950885cdcd156b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:08:50 -0500 Subject: [PATCH 08/14] Remove require_auth; fix auth docs re: component-level enforcement (#3103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code https://claude.ai/code/session_01WWzwcBfLWnxoN9XNs5Fhxr Co-authored-by: Claude --- docs/development/v3-notes/v3-features.mdx | 11 ++- docs/servers/authorization.mdx | 90 ++++++++++--------- src/fastmcp/server/auth/__init__.py | 2 - src/fastmcp/server/auth/authorization.py | 20 +---- .../server/middleware/authorization.py | 15 ++-- tests/server/auth/test_authorization.py | 66 ++++++-------- 6 files changed, 88 insertions(+), 116 deletions(-) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 05399fce6..432b191bd 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -778,11 +778,11 @@ v3.0 introduces callable-based authorization for tools, resources, and prompts ( ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth, require_scopes +from fastmcp.server.auth import require_scopes mcp = FastMCP() -@mcp.tool(auth=require_auth) +@mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) @@ -796,10 +796,10 @@ def admin_prompt(): ... ```python from fastmcp.server.middleware import AuthMiddleware -from fastmcp.server.auth import require_auth, restrict_tag +from fastmcp.server.auth import require_scopes, restrict_tag -# Require auth for all components -mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) +# Require specific scope for all components +mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) # Tag-based restrictions mcp = FastMCP(middleware=[ @@ -808,7 +808,6 @@ mcp = FastMCP(middleware=[ ``` Built-in checks: -- `require_auth`: Requires any valid token - `require_scopes(*scopes)`: Requires specific OAuth scopes - `restrict_tag(tag, scopes)`: Requires scopes only for tagged components diff --git a/docs/servers/authorization.mdx b/docs/servers/authorization.mdx index ac65f21cb..0ad0d569e 100644 --- a/docs/servers/authorization.mdx +++ b/docs/servers/authorization.mdx @@ -18,6 +18,10 @@ The authorization model centers on a simple concept: callable functions that rec Authorization relies on OAuth tokens which are only available with HTTP transports (SSE, Streamable HTTP). In STDIO mode, there's no OAuth mechanism, so `get_access_token()` returns `None` and all auth checks are skipped. + +When an `AuthProvider` is configured, all requests to the MCP endpoint must carry a valid token—unauthenticated requests are rejected at the transport level before any auth checks run. Authorization checks therefore differentiate between authenticated users based on their scopes and claims, not between authenticated and unauthenticated users. + + ## Auth Checks An auth check is any callable that accepts an `AuthContext` and returns a boolean. The `AuthContext` provides access to the current token (if any) and the component being accessed. @@ -31,27 +35,11 @@ def my_custom_check(ctx: AuthContext) -> bool: return ctx.token is not None and "special" in ctx.token.scopes ``` -FastMCP provides three built-in auth checks that cover common authorization patterns. - -### require_auth - -The simplest check verifies that any valid authentication token is present. Unauthenticated requests are denied. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_auth - -mcp = FastMCP("Protected Server") - -@mcp.tool(auth=require_auth) -def protected_operation() -> str: - """Only accessible to authenticated users.""" - return "Success" -``` +FastMCP provides two built-in auth checks that cover common authorization patterns. ### require_scopes -For scope-based authorization, `require_scopes` checks that the token contains all specified OAuth scopes. When multiple scopes are provided, all must be present (AND logic). +Scope-based authorization checks that the token contains all specified OAuth scopes. When multiple scopes are provided, all must be present (AND logic). ```python from fastmcp import FastMCP @@ -103,13 +91,13 @@ Multiple auth checks can be combined by passing a list. All checks must pass for ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth, require_scopes +from fastmcp.server.auth import require_scopes mcp = FastMCP("Combined Auth Server") -@mcp.tool(auth=[require_auth, require_scopes("admin")]) +@mcp.tool(auth=[require_scopes("admin"), require_scopes("write")]) def secure_admin_action() -> str: - """Requires authentication AND the 'admin' scope.""" + """Requires both 'admin' AND 'write' scopes.""" return "Secure admin action" ``` @@ -169,18 +157,18 @@ def require_verified_email(ctx: AuthContext) -> bool: ## Component-Level Authorization -The `auth` parameter on decorators controls visibility of individual components. When auth checks fail for the current request, the component is hidden from list responses—it simply doesn't appear. +The `auth` parameter on decorators controls visibility and access for individual components. When auth checks fail for the current request, the component is hidden from list responses and direct access returns not-found. ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth, require_scopes +from fastmcp.server.auth import require_scopes mcp = FastMCP("Component Auth Server") -@mcp.tool(auth=require_auth) -def authenticated_tool() -> str: - """Only visible to authenticated users.""" - return "Authenticated" +@mcp.tool(auth=require_scopes("write")) +def write_tool() -> str: + """Only visible to users with 'write' scope.""" + return "Written" @mcp.resource("secret://data", auth=require_scopes("read")) def secret_resource() -> str: @@ -193,38 +181,59 @@ def admin_prompt() -> str: return "Admin prompt content" ``` - -Component-level `auth` only controls visibility in list operations. It does not block direct access. Use `AuthMiddleware` to enforce authorization on execution. - + +Component-level `auth` controls both visibility (list filtering) and access (direct lookups return not-found for unauthorized requests). Additionally use `AuthMiddleware` to apply server-wide authorization rules and get explicit `AuthorizationError` responses on unauthorized execution attempts. + ## Server-Level Authorization -For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution. +For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth +from fastmcp.server.auth import require_scopes from fastmcp.server.middleware import AuthMiddleware mcp = FastMCP( "Enforced Auth Server", - middleware=[AuthMiddleware(auth=require_auth)] + middleware=[AuthMiddleware(auth=require_scopes("api"))] ) @mcp.tool def any_tool() -> str: - """Requires authentication to see AND call.""" + """Requires 'api' scope to see AND call.""" return "Protected" ``` -### Filtering vs Enforcement +### Component Auth + Middleware -| Behavior | Component-level `auth` | `AuthMiddleware` | -|----------|------------------------|------------------| -| Filters list responses | Yes | Yes | -| Blocks execution | No | Yes (raises `AuthorizationError`) | +Component-level `auth` and `AuthMiddleware` work together as complementary layers. The middleware applies server-wide rules to all components, while component-level auth adds per-component requirements. Both layers are checked—all checks must pass. -Component-level auth is useful for hiding components from unauthorized users while still allowing advanced clients to access them directly. `AuthMiddleware` provides complete enforcement by raising `AuthorizationError` when unauthorized requests attempt execution. +```python +from fastmcp import FastMCP +from fastmcp.server.auth import require_scopes, restrict_tag +from fastmcp.server.middleware import AuthMiddleware + +mcp = FastMCP( + "Layered Auth Server", + middleware=[ + AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) + ] +) + +# Requires "write" scope (component-level) +# Also requires "admin" scope if tagged "admin" (middleware-level) +@mcp.tool(auth=require_scopes("write"), tags={"admin"}) +def admin_write() -> str: + """Requires both 'write' AND 'admin' scopes.""" + return "Admin write" + +# Requires "write" scope (component-level only) +@mcp.tool(auth=require_scopes("write")) +def user_write() -> str: + """Requires 'write' scope.""" + return "User write" +``` ### Tag-Based Global Authorization @@ -338,7 +347,6 @@ from fastmcp.server.auth import ( AccessToken, # Token with .token, .client_id, .scopes, .expires_at, .claims AuthContext, # Context with .token, .component AuthCheck, # Type alias: Callable[[AuthContext], bool] - require_auth, # Built-in: requires any valid token require_scopes, # Built-in: requires specific scopes restrict_tag, # Built-in: tag-based scope requirements run_auth_checks, # Utility: run checks with AND logic diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index d8d221a3d..94e23dca6 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -8,7 +8,6 @@ from .auth import ( from .authorization import ( AuthCheck, AuthContext, - require_auth, require_scopes, restrict_tag, run_auth_checks, @@ -32,7 +31,6 @@ __all__ = [ "RemoteAuthProvider", "StaticTokenVerifier", "TokenVerifier", - "require_auth", "require_scopes", "restrict_tag", "run_auth_checks", diff --git a/src/fastmcp/server/auth/authorization.py b/src/fastmcp/server/auth/authorization.py index ae9e64a5b..dd0e16cc1 100644 --- a/src/fastmcp/server/auth/authorization.py +++ b/src/fastmcp/server/auth/authorization.py @@ -11,17 +11,17 @@ Auth checks can also raise exceptions: Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes + from fastmcp.server.auth import require_scopes mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) def secret_data(): ... - @mcp.prompt(auth=require_auth) + @mcp.prompt(auth=require_scopes("admin")) def admin_prompt(): ... ``` """ @@ -74,20 +74,6 @@ class AuthContext: AuthCheck = Callable[[AuthContext], bool] -def require_auth(ctx: AuthContext) -> bool: - """Require any valid authentication. - - Returns True if the request has a valid token, False otherwise. - - Example: - ```python - @mcp.tool(auth=require_auth) - def protected_tool(): ... - ``` - """ - return ctx.token is not None - - def require_scopes(*scopes: str) -> AuthCheck: """Require specific OAuth scopes. diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py index 46038f6c9..6a50ed656 100644 --- a/src/fastmcp/server/middleware/authorization.py +++ b/src/fastmcp/server/middleware/authorization.py @@ -6,12 +6,12 @@ AuthMiddleware applies auth checks globally to all components on the server. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes, restrict_tag + from fastmcp.server.auth import require_scopes, restrict_tag from fastmcp.server.middleware import AuthMiddleware - # Require auth for all components + # Require specific scope for all components mcp = FastMCP(middleware=[ - AuthMiddleware(auth=require_auth) + AuthMiddleware(auth=require_scopes("api")) ]) # Tag-based: components tagged "admin" require "admin" scope @@ -67,17 +67,14 @@ class AuthMiddleware(Middleware): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes - - # Require any authentication for all components - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + from fastmcp.server.auth import require_scopes # Require specific scope for all components mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) - # Combined checks (AND logic) + # Multiple scopes (AND logic) mcp = FastMCP(middleware=[ - AuthMiddleware(auth=[require_auth, require_scopes("api")]) + AuthMiddleware(auth=require_scopes("read", "api")) ]) ``` """ diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index 6eaaede32..6bab4cecd 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -12,7 +12,6 @@ from fastmcp.client import Client from fastmcp.server.auth import ( AccessToken, AuthContext, - require_auth, require_scopes, restrict_tag, run_auth_checks, @@ -42,21 +41,6 @@ def make_tool() -> Mock: return tool -# ============================================================================= -# Tests for require_auth -# ============================================================================= - - -class TestRequireAuth: - def test_returns_true_with_token(self): - ctx = AuthContext(token=make_token(), component=make_tool()) - assert require_auth(ctx) is True - - def test_returns_false_without_token(self): - ctx = AuthContext(token=None, component=make_tool()) - assert require_auth(ctx) is False - - # ============================================================================= # Tests for require_scopes # ============================================================================= @@ -137,23 +121,23 @@ class TestRestrictTag: class TestRunAuthChecks: def test_single_check_passes(self): - ctx = AuthContext(token=make_token(), component=make_tool()) - assert run_auth_checks(require_auth, ctx) is True + ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool()) + assert run_auth_checks(require_scopes("test"), ctx) is True def test_single_check_fails(self): ctx = AuthContext(token=None, component=make_tool()) - assert run_auth_checks(require_auth, ctx) is False + assert run_auth_checks(require_scopes("test"), ctx) is False def test_multiple_checks_all_pass(self): - token = make_token(scopes=["admin"]) + token = make_token(scopes=["test", "admin"]) ctx = AuthContext(token=token, component=make_tool()) - checks = [require_auth, require_scopes("admin")] + checks = [require_scopes("test"), require_scopes("admin")] assert run_auth_checks(checks, ctx) is True def test_multiple_checks_one_fails(self): token = make_token(scopes=["read"]) ctx = AuthContext(token=token, component=make_tool()) - checks = [require_auth, require_scopes("admin")] + checks = [require_scopes("read"), require_scopes("admin")] assert run_auth_checks(checks, ctx) is False def test_empty_list_passes(self): @@ -244,7 +228,7 @@ class TestToolLevelAuth: async def test_tool_with_auth_hidden_without_token(self): mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" @@ -255,12 +239,12 @@ class TestToolLevelAuth: async def test_tool_with_auth_visible_with_token(self): mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" # Set token in context - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: tools = await mcp.list_tools() @@ -306,7 +290,7 @@ class TestToolLevelAuth: """get_tool() returns None for unauthorized tools (consistent with list filtering).""" mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" @@ -317,11 +301,11 @@ class TestToolLevelAuth: async def test_get_tool_returns_tool_with_auth(self): mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: tool = await mcp.get_tool("protected_tool") @@ -344,7 +328,7 @@ class TestAuthMiddleware: """ async def test_middleware_filters_tools_without_token(self): - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def public_tool() -> str: @@ -355,13 +339,13 @@ class TestAuthMiddleware: assert len(result.tools) == 0 async def test_middleware_allows_tools_with_token(self): - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def public_tool() -> str: return "public" - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) @@ -435,7 +419,7 @@ class TestAuthIntegration: def public_tool() -> str: return "public" - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" @@ -452,12 +436,12 @@ class TestAuthIntegration: def public_tool() -> str: return "public" - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" # Set token before creating client - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: async with Client(mcp) as client: @@ -482,7 +466,7 @@ class TestTransformedToolAuth: mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool(x: int) -> str: return str(x) @@ -507,7 +491,7 @@ class TestTransformedToolAuth: mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool(x: int) -> str: return str(x) @@ -526,7 +510,7 @@ class TestTransformedToolAuth: mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool(x: int) -> str: return str(x) @@ -536,7 +520,7 @@ class TestTransformedToolAuth: ) # With token, transformed tool should be visible - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: tools = await mcp.list_tools() @@ -555,7 +539,7 @@ class TestAuthMiddlewareCallTool: async def test_middleware_blocks_call_without_auth(self): """AuthMiddleware should raise AuthorizationError on unauthorized call.""" - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def my_tool() -> str: @@ -573,14 +557,14 @@ class TestAuthMiddlewareCallTool: async def test_middleware_allows_call_with_auth(self): """AuthMiddleware should allow tool call with valid token.""" - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def my_tool() -> str: return "result" # With token, calling the tool should succeed - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: async with Client(mcp) as client: From d12d46b049762db4114dd266e1b5799981377875 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:19:36 -0500 Subject: [PATCH 09/14] Exclude content-type header from get_http_headers() to prevent HTTP 415 errors (#3104) Fixes #3097 When using FastMCP.from_openapi() with APIs that require specific Content-Type headers (e.g., application/vnd.api+json), the transport connection's content-type: application/json was being injected into downstream API requests, causing HTTP 415 (Unsupported Media Type) errors. This change adds content-type to the exclude_headers set in get_http_headers(), similar to how accept is already excluded. The MCP transport's content type has no relevance to downstream API calls and should not be forwarded. Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/server/dependencies.py | 1 + tests/server/http/test_http_dependencies.py | 40 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index ce964fd31..ffef2561b 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -441,6 +441,7 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: exclude_headers = { "host", "content-length", + "content-type", "connection", "transfer-encoding", "upgrade", diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index e379f5e83..e637af269 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -126,3 +126,43 @@ async def test_http_headers_prompt_sse(sse_server: str): json_result = json.loads(result.messages[0].content.text) assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" + + +async def test_get_http_headers_excludes_content_type(sse_server: str): + """Test that get_http_headers() excludes content-type header (issue #3097). + + This prevents HTTP 415 errors when forwarding headers to downstream APIs + that require specific Content-Type headers (e.g., application/vnd.api+json). + """ + from fastmcp.server.dependencies import get_http_headers + + server = FastMCP() + + @server.tool + def check_excluded_headers() -> dict[str, str]: + """Check that problematic headers are excluded from get_http_headers().""" + return get_http_headers() + + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport( + url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Custom-Header": "should-be-included", + }, + ) + ) as client: + result = await client.call_tool("check_excluded_headers") + headers = result.data + + # These headers should be excluded + assert "content-type" not in headers + assert "accept" not in headers + assert "host" not in headers + assert "content-length" not in headers + + # Custom headers should be included + assert "x-custom-header" in headers + assert headers["x-custom-header"] == "should-be-included" From 3e3ed76a8c1ced5d52e2209840a02dad77643b08 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:27:29 -0500 Subject: [PATCH 10/14] chore: Update SDK documentation (#3089) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/docs.json | 7 +- docs/python-sdk/fastmcp-cli-auth.mdx | 9 + docs/python-sdk/fastmcp-cli-cimd.mdx | 43 ++++ docs/python-sdk/fastmcp-cli-cli.mdx | 12 +- docs/python-sdk/fastmcp-client-auth-oauth.mdx | 6 +- ...mcp-client-sampling-handlers-anthropic.mdx | 2 +- .../fastmcp-client-transports-http.mdx | 6 +- .../fastmcp-client-transports-sse.mdx | 2 +- docs/python-sdk/fastmcp-server-auth-auth.mdx | 68 +++-- .../fastmcp-server-auth-authorization.mdx | 24 +- docs/python-sdk/fastmcp-server-auth-cimd.mdx | 242 ++++++++++++++++++ ...astmcp-server-auth-oauth_proxy-consent.mdx | 2 +- ...fastmcp-server-auth-oauth_proxy-models.mdx | 25 +- .../fastmcp-server-auth-oauth_proxy-proxy.mdx | 27 +- .../fastmcp-server-auth-oauth_proxy-ui.mdx | 4 +- .../fastmcp-server-auth-oidc_proxy.mdx | 4 +- .../fastmcp-server-auth-providers-jwt.mdx | 20 +- ...astmcp-server-auth-redirect_validation.mdx | 18 +- docs/python-sdk/fastmcp-server-auth-ssrf.mdx | 172 +++++++++++++ .../fastmcp-server-dependencies.mdx | 50 ++-- ...astmcp-server-middleware-authorization.mdx | 20 +- ...cp-server-middleware-response_limiting.mdx | 32 +++ ...cp-server-providers-openapi-components.mdx | 12 +- ...tmcp-server-providers-openapi-provider.mdx | 6 +- .../fastmcp-tools-tool_transform.mdx | 6 +- .../fastmcp-utilities-json_schema.mdx | 6 +- .../fastmcp-utilities-openapi-parser.mdx | 2 +- 27 files changed, 681 insertions(+), 146 deletions(-) create mode 100644 docs/python-sdk/fastmcp-cli-auth.mdx create mode 100644 docs/python-sdk/fastmcp-cli-cimd.mdx create mode 100644 docs/python-sdk/fastmcp-server-auth-cimd.mdx create mode 100644 docs/python-sdk/fastmcp-server-auth-ssrf.mdx create mode 100644 docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx diff --git a/docs/docs.json b/docs/docs.json index b9c1bc4dd..2864855fe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -302,6 +302,8 @@ "group": "fastmcp.cli", "pages": [ "python-sdk/fastmcp-cli-__init__", + "python-sdk/fastmcp-cli-auth", + "python-sdk/fastmcp-cli-cimd", "python-sdk/fastmcp-cli-cli", "python-sdk/fastmcp-cli-client", "python-sdk/fastmcp-cli-discovery", @@ -413,6 +415,7 @@ "python-sdk/fastmcp-server-auth-__init__", "python-sdk/fastmcp-server-auth-auth", "python-sdk/fastmcp-server-auth-authorization", + "python-sdk/fastmcp-server-auth-cimd", "python-sdk/fastmcp-server-auth-jwt_issuer", "python-sdk/fastmcp-server-auth-middleware", { @@ -447,7 +450,8 @@ "python-sdk/fastmcp-server-auth-providers-workos" ] }, - "python-sdk/fastmcp-server-auth-redirect_validation" + "python-sdk/fastmcp-server-auth-redirect_validation", + "python-sdk/fastmcp-server-auth-ssrf" ] }, "python-sdk/fastmcp-server-context", @@ -468,6 +472,7 @@ "python-sdk/fastmcp-server-middleware-middleware", "python-sdk/fastmcp-server-middleware-ping", "python-sdk/fastmcp-server-middleware-rate_limiting", + "python-sdk/fastmcp-server-middleware-response_limiting", "python-sdk/fastmcp-server-middleware-timing", "python-sdk/fastmcp-server-middleware-tool_injection" ] diff --git a/docs/python-sdk/fastmcp-cli-auth.mdx b/docs/python-sdk/fastmcp-cli-auth.mdx new file mode 100644 index 000000000..586a53505 --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-auth.mdx @@ -0,0 +1,9 @@ +--- +title: auth +sidebarTitle: auth +--- + +# `fastmcp.cli.auth` + + +Authentication-related CLI commands. diff --git a/docs/python-sdk/fastmcp-cli-cimd.mdx b/docs/python-sdk/fastmcp-cli-cimd.mdx new file mode 100644 index 000000000..8f69aae9d --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-cimd.mdx @@ -0,0 +1,43 @@ +--- +title: cimd +sidebarTitle: cimd +--- + +# `fastmcp.cli.cimd` + + +CIMD (Client ID Metadata Document) CLI commands. + +## Functions + +### `create_command` + +```python +create_command() -> None +``` + + +Generate a CIMD document for hosting. + +Create a Client ID Metadata Document that you can host at an HTTPS URL. +The URL where you host this document becomes your client_id. + +After creating the document, host it at an HTTPS URL with a non-root path, +for example: https://myapp.example.com/oauth/client.json + + +### `validate_command` + +```python +validate_command(url: Annotated[str, cyclopts.Parameter(help='URL of the CIMD document to validate')]) -> None +``` + + +Validate a hosted CIMD document. + +Fetches the document from the given URL and validates: +- URL is valid CIMD URL (HTTPS, non-root path) +- Document is valid JSON +- Document conforms to CIMD schema +- client_id in document matches the URL + diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 26e8f0621..fca179c65 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index 3c83f641e..b9d06beb7 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -73,7 +73,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -82,7 +82,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -91,7 +91,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx index b1c9af0a9..905a25ef6 100644 --- a/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx +++ b/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx @@ -10,7 +10,7 @@ Anthropic sampling handler for FastMCP. ## Classes -### `AnthropicSamplingHandler` +### `AnthropicSamplingHandler` Sampling handler that uses the Anthropic API. diff --git a/docs/python-sdk/fastmcp-client-transports-http.mdx b/docs/python-sdk/fastmcp-client-transports-http.mdx index e5ba1599a..163f02c5e 100644 --- a/docs/python-sdk/fastmcp-client-transports-http.mdx +++ b/docs/python-sdk/fastmcp-client-transports-http.mdx @@ -18,19 +18,19 @@ Transport implementation that connects to an MCP server via Streamable HTTP Requ **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `get_session_id` +#### `get_session_id` ```python get_session_id(self) -> str | None ``` -#### `close` +#### `close` ```python close(self) diff --git a/docs/python-sdk/fastmcp-client-transports-sse.mdx b/docs/python-sdk/fastmcp-client-transports-sse.mdx index 2f3449e2d..c1e1841de 100644 --- a/docs/python-sdk/fastmcp-client-transports-sse.mdx +++ b/docs/python-sdk/fastmcp-client-transports-sse.mdx @@ -18,7 +18,7 @@ Transport implementation that connects to an MCP server via Server-Sent Events. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index 723b5a08f..285c8aa29 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -7,13 +7,13 @@ sidebarTitle: auth ## Classes -### `AccessToken` +### `AccessToken` AccessToken that includes all JWT claims. -### `TokenHandler` +### `TokenHandler` TokenHandler that returns MCP-compliant error responses. @@ -33,7 +33,7 @@ This handler transforms responses to be compliant with both OAuth 2.1 and MCP sp **Methods:** -#### `handle` +#### `handle` ```python handle(self, request: Any) @@ -42,7 +42,37 @@ handle(self, request: Any) Wrap SDK handle() and transform auth error responses. -### `AuthProvider` +### `PrivateKeyJWTClientAuthenticator` + + +Client authenticator with private_key_jwt support for CIMD clients. + +Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt` +authentication method per RFC 7523. This is required for CIMD (Client ID Metadata +Document) clients that use asymmetric keys for authentication. + +The authenticator: +1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none) +2. Adds private_key_jwt handling for CIMD clients +3. Validates JWT assertions against client's JWKS + + +**Methods:** + +#### `authenticate_request` + +```python +authenticate_request(self, request: Request) -> OAuthClientInformationFull +``` + +Authenticate a client from an HTTP request. + +Extends SDK authentication to support private_key_jwt for CIMD clients. +Delegates to SDK for client_secret_basic (Authorization header) and +client_secret_post (form body) authentication. + + +### `AuthProvider` Base class for all FastMCP authentication providers. @@ -55,7 +85,7 @@ custom authentication routes. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -72,7 +102,7 @@ All auth providers must implement token verification. - AccessToken object if valid, None if invalid or expired -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -89,7 +119,7 @@ MCP endpoint path. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -113,7 +143,7 @@ provider does not create the actual MCP endpoint route. - List of all routes for this provider (excluding the MCP endpoint itself) -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] @@ -141,7 +171,7 @@ This is used to construct path-scoped well-known URLs. - List of well-known discovery routes (typically mounted at root level) -#### `get_middleware` +#### `get_middleware` ```python get_middleware(self) -> list @@ -153,7 +183,7 @@ Get HTTP application-level middleware for this auth provider. - List of Starlette Middleware instances to apply to the HTTP app -### `TokenVerifier` +### `TokenVerifier` Base class for token verifiers (Resource Servers). @@ -164,7 +194,7 @@ Token verifiers typically don't provide authentication routes by default. **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] @@ -178,7 +208,7 @@ where tokens contain short-form scopes but clients request full URI scopes). -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -187,7 +217,7 @@ verify_token(self, token: str) -> AccessToken | None Verify a bearer token and return access info if valid. -### `RemoteAuthProvider` +### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. @@ -204,7 +234,7 @@ the authorization servers that issue valid tokens. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -213,7 +243,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token using the configured token verifier. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -224,7 +254,7 @@ Get routes for this provider. Creates protected resource metadata routes (RFC 9728). -### `OAuthProvider` +### `OAuthProvider` OAuth Authorization Server provider. @@ -235,7 +265,7 @@ authorization flows, token issuance, and token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -253,7 +283,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -269,7 +299,7 @@ This method creates the full set of OAuth routes including: - List of OAuth routes -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-authorization.mdx b/docs/python-sdk/fastmcp-server-auth-authorization.mdx index d8e0611a9..6268118d8 100644 --- a/docs/python-sdk/fastmcp-server-auth-authorization.mdx +++ b/docs/python-sdk/fastmcp-server-auth-authorization.mdx @@ -19,36 +19,24 @@ Auth checks can also raise exceptions: Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes + from fastmcp.server.auth import require_scopes mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) def secret_data(): ... - @mcp.prompt(auth=require_auth) + @mcp.prompt(auth=require_scopes("admin")) def admin_prompt(): ... ``` ## Functions -### `require_auth` - -```python -require_auth(ctx: AuthContext) -> bool -``` - - -Require any valid authentication. - -Returns True if the request has a valid token, False otherwise. - - -### `require_scopes` +### `require_scopes` ```python require_scopes(*scopes: str) -> AuthCheck @@ -64,7 +52,7 @@ in the token (AND logic). - `*scopes`: One or more scope strings that must all be present. -### `restrict_tag` +### `restrict_tag` ```python restrict_tag(tag: str) -> AuthCheck @@ -81,7 +69,7 @@ required scopes. If the component doesn't have the tag, access is allowed. - `scopes`: List of scopes required when the tag is present. -### `run_auth_checks` +### `run_auth_checks` ```python run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool diff --git a/docs/python-sdk/fastmcp-server-auth-cimd.mdx b/docs/python-sdk/fastmcp-server-auth-cimd.mdx new file mode 100644 index 000000000..c6d72ea6e --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-cimd.mdx @@ -0,0 +1,242 @@ +--- +title: cimd +sidebarTitle: cimd +--- + +# `fastmcp.server.auth.cimd` + + +CIMD (Client ID Metadata Document) support for FastMCP. + +.. warning:: + **Beta Feature**: CIMD support is currently in beta. The API may change + in future releases. Please report any issues you encounter. + +CIMD is a simpler alternative to Dynamic Client Registration where clients +host a static JSON document at an HTTPS URL, and that URL becomes their +client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document + +This module provides: +- CIMDDocument: Pydantic model for CIMD document validation +- CIMDFetcher: Fetch and validate CIMD documents with SSRF protection +- CIMDClientManager: Manages CIMD client operations + + +## Classes + +### `CIMDDocument` + + +CIMD document per draft-parecki-oauth-client-id-metadata-document. + +The client metadata document is a JSON document containing OAuth client +metadata. The client_id property MUST match the URL where this document +is hosted. + +Key constraint: token_endpoint_auth_method MUST NOT use shared secrets +(client_secret_post, client_secret_basic, client_secret_jwt). + +redirect_uris is required and must contain at least one entry. + + +**Methods:** + +#### `validate_auth_method` + +```python +validate_auth_method(cls, v: str) -> str +``` + +Ensure no shared-secret auth methods are used. + + +#### `validate_redirect_uris` + +```python +validate_redirect_uris(cls, v: list[str]) -> list[str] +``` + +Ensure redirect_uris is non-empty and each entry is a valid URI. + + +### `CIMDValidationError` + + +Raised when CIMD document validation fails. + + +### `CIMDFetchError` + + +Raised when CIMD document fetching fails. + + +### `CIMDFetcher` + + +Fetch and validate CIMD documents with SSRF protection. + +Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS +pinning, IP validation, size limits, and timeout enforcement. Documents are +cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with +a TTL fallback when response headers do not define caching behavior. + + +**Methods:** + +#### `is_cimd_client_id` + +```python +is_cimd_client_id(self, client_id: str) -> bool +``` + +Check if a client_id looks like a CIMD URL. + +CIMD URLs must be HTTPS with a host and non-root path. + + +#### `fetch` + +```python +fetch(self, client_id_url: str) -> CIMDDocument +``` + +Fetch and validate a CIMD document with SSRF protection. + +Uses ssrf_safe_fetch_response for the HTTP layer, which provides: +- HTTPS only, DNS resolution with IP validation +- DNS pinning (connects to validated IP directly) +- Blocks private/loopback/link-local/multicast IPs +- Response size limit and timeout enforcement +- Redirects disabled + +**Args:** +- `client_id_url`: The URL to fetch (also the expected client_id) + +**Returns:** +- Validated CIMDDocument + +**Raises:** +- `CIMDValidationError`: If document is invalid or URL blocked +- `CIMDFetchError`: If document cannot be fetched + + +#### `validate_redirect_uri` + +```python +validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool +``` + +Validate that a redirect_uri is allowed by the CIMD document. + +**Args:** +- `doc`: The CIMD document +- `redirect_uri`: The redirect URI to validate + +**Returns:** +- True if valid, False otherwise + + +### `CIMDAssertionValidator` + + +Validates JWT assertions for private_key_jwt CIMD clients. + +Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client +Authentication and Authorization Grants) for CIMD client authentication. + +JTI replay protection uses TTL-based caching to ensure proper security: +- JTIs are cached with expiration matching the JWT's exp claim +- Expired JTIs are automatically cleaned up +- Maximum assertion lifetime is enforced (5 minutes) + + +**Methods:** + +#### `validate_assertion` + +```python +validate_assertion(self, assertion: str, client_id: str, token_endpoint: str, cimd_doc: CIMDDocument) -> bool +``` + +Validate JWT assertion from client. + +**Args:** +- `assertion`: The JWT assertion string +- `client_id`: Expected client_id (must match iss and sub claims) +- `token_endpoint`: Token endpoint URL (must match aud claim) +- `cimd_doc`: CIMD document containing JWKS for key verification + +**Returns:** +- True if valid + +**Raises:** +- `ValueError`: If validation fails + + +### `CIMDClientManager` + + +Manages all CIMD client operations for OAuth proxy. + +This class encapsulates: +- CIMD client detection +- Document fetching and validation +- Synthetic OAuth client creation +- Private key JWT assertion validation + +This allows the OAuth proxy to delegate all CIMD-specific logic to a +single, focused manager class. + + +**Methods:** + +#### `is_cimd_client_id` + +```python +is_cimd_client_id(self, client_id: str) -> bool +``` + +Check if client_id is a CIMD URL. + +**Args:** +- `client_id`: Client ID to check + +**Returns:** +- True if client_id is an HTTPS URL (CIMD format) + + +#### `get_client` + +```python +get_client(self, client_id_url: str) +``` + +Fetch CIMD document and create synthetic OAuth client. + +**Args:** +- `client_id_url`: HTTPS URL pointing to CIMD document + +**Returns:** +- OAuthProxyClient with CIMD document attached, or None if fetch fails + + +#### `validate_private_key_jwt` + +```python +validate_private_key_jwt(self, assertion: str, client, token_endpoint: str) -> bool +``` + +Validate JWT assertion for private_key_jwt auth. + +**Args:** +- `assertion`: JWT assertion string from client +- `client`: OAuth proxy client (must have cimd_document) +- `token_endpoint`: Token endpoint URL for aud validation + +**Returns:** +- True if assertion is valid + +**Raises:** +- `ValueError`: If client doesn't have CIMD document or validation fails + diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx index 0b9f709f1..6e77a1079 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx @@ -15,7 +15,7 @@ cookie management, and consent page rendering. ## Classes -### `ConsentMixin` +### `ConsentMixin` Mixin class providing consent management functionality for OAuthProxy. diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx index 9de2cf8d2..bca8b088e 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx @@ -13,7 +13,7 @@ This module contains all Pydantic models and constants used by the OAuth proxy. ## Classes -### `OAuthTransaction` +### `OAuthTransaction` OAuth transaction state for consent flow. @@ -22,7 +22,7 @@ Stored server-side to track active authorization flows with client context. Includes CSRF tokens for consent protection per MCP security best practices. -### `ClientCode` +### `ClientCode` Client authorization code with PKCE and upstream tokens. @@ -31,7 +31,7 @@ Stored server-side after upstream IdP callback. Contains the upstream tokens bound to the client's PKCE challenge for secure token exchange. -### `UpstreamTokenSet` +### `UpstreamTokenSet` Stored upstream OAuth tokens from identity provider. @@ -41,7 +41,7 @@ and stored in plaintext within this model. Encryption is handled transparently at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. -### `JTIMapping` +### `JTIMapping` Maps FastMCP token JTI to upstream token ID. @@ -50,7 +50,7 @@ This allows stateless JWT validation while still being able to look up the corresponding upstream token when tools need to access upstream APIs. -### `RefreshTokenMetadata` +### `RefreshTokenMetadata` Metadata for a refresh token, stored keyed by token hash. @@ -59,7 +59,7 @@ We store only metadata (not the token itself) for security - if storage is compromised, attackers get hashes they can't reverse into usable tokens. -### `ProxyDCRClient` +### `ProxyDCRClient` Client for DCR proxy with configurable redirect URI validation. @@ -89,16 +89,17 @@ arise from accepting arbitrary redirect URIs. **Methods:** -#### `validate_redirect_uri` +#### `validate_redirect_uri` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl ``` -Validate redirect URI against allowed patterns. +Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. -Since we're acting as a proxy and clients register dynamically, -we validate their redirect URIs against configurable patterns. -This is essential for cached token scenarios where the client may -reconnect with a different port. +For CIMD clients: validates against BOTH the CIMD document's redirect_uris +AND the proxy's allowed patterns (if configured). Both must pass. + +For DCR clients: validates against proxy patterns first, falling back to +base validation (registered redirect_uris) if patterns don't match. diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx index 26f80c876..b89d05edc 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx @@ -26,7 +26,7 @@ production use with enterprise identity providers. ## Classes -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -140,7 +140,7 @@ Handles provider-specific requirements: **Methods:** -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -157,7 +157,7 @@ this specific MCP endpoint. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `jwt_issuer` +#### `jwt_issuer` ```python jwt_issuer(self) -> JWTIssuer @@ -169,7 +169,7 @@ The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -179,9 +179,10 @@ Get client information by ID. This is generally the random ID provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). +CIMD clients (URL-based client IDs) are looked up and cached automatically. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -195,7 +196,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -213,7 +214,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -225,7 +226,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -243,7 +244,7 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -255,7 +256,7 @@ Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -272,7 +273,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -291,7 +292,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -304,7 +305,7 @@ For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx index efd2338d6..02d1d8a1b 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx @@ -16,7 +16,7 @@ This module contains HTML generation functions for consent and error pages. ### `create_consent_html` ```python -create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None) -> str +create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None, is_cimd_client: bool = False, cimd_domain: str | None = None) -> str ``` @@ -29,7 +29,7 @@ If empty string "", disables CSP entirely (no meta tag is rendered). If a non-empty string, uses that as the CSP policy value. -### `create_error_html` +### `create_error_html` ```python create_error_html(error_title: str, error_message: str, error_details: dict[str, str] | None = None, server_name: str | None = None, server_icon_url: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index a1794bb6f..3c853c4ce 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx index 9dd176f2e..beabae8ec 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx @@ -10,19 +10,19 @@ TokenVerifier implementations for FastMCP. ## Classes -### `JWKData` +### `JWKData` JSON Web Key data structure. -### `JWKSData` +### `JWKSData` JSON Web Key Set data structure. -### `RSAKeyPair` +### `RSAKeyPair` RSA key pair for JWT testing. @@ -30,7 +30,7 @@ RSA key pair for JWT testing. **Methods:** -#### `generate` +#### `generate` ```python generate(cls) -> RSAKeyPair @@ -42,7 +42,7 @@ Generate an RSA key pair for testing. - Generated key pair -#### `create_token` +#### `create_token` ```python create_token(self, subject: str = 'fastmcp-user', issuer: str = 'https://fastmcp.example.com', audience: str | list[str] | None = None, scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, kid: str | None = None) -> str @@ -60,7 +60,7 @@ Generate a test JWT token for testing purposes. - `kid`: Key ID to include in header -### `JWTVerifier` +### `JWTVerifier` JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. @@ -82,7 +82,7 @@ Use this when: **Methods:** -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -97,7 +97,7 @@ Validate a JWT bearer token and return an AccessToken when the token is valid. - AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -115,7 +115,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -### `StaticTokenVerifier` +### `StaticTokenVerifier` Simple static token verifier for testing and development. @@ -136,7 +136,7 @@ WARNING: Never use this in production - tokens are stored in plain text! **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx index c7aa26786..b8ae3c83b 100644 --- a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx +++ b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx @@ -8,23 +8,33 @@ sidebarTitle: redirect_validation Utilities for validating client redirect URIs in OAuth flows. +This module provides secure redirect URI validation with wildcard support, +protecting against userinfo-based bypass attacks like http://localhost@evil.com. + + ## Functions -### `matches_allowed_pattern` +### `matches_allowed_pattern` ```python matches_allowed_pattern(uri: str, pattern: str) -> bool ``` -Check if a URI matches an allowed pattern with wildcard support. +Securely check if a URI matches an allowed pattern with wildcard support. -Patterns support * wildcard matching: +This function parses both the URI and pattern as URLs, comparing each +component separately to prevent bypass attacks like userinfo injection. + +Patterns support wildcards: - http://localhost:* matches any localhost port - http://127.0.0.1:* matches any 127.0.0.1 port - https://*.example.com/* matches any subdomain of example.com - https://app.example.com/auth/* matches any path under /auth/ +Security: Rejects URIs with userinfo (user:pass@host) which could bypass +naive string matching (e.g., http://localhost@evil.com). + **Args:** - `uri`: The redirect URI to validate - `pattern`: The allowed pattern (may contain wildcards) @@ -33,7 +43,7 @@ Patterns support * wildcard matching: - True if the URI matches the pattern -### `validate_redirect_uri` +### `validate_redirect_uri` ```python validate_redirect_uri(redirect_uri: str | AnyUrl | None, allowed_patterns: list[str] | None) -> bool diff --git a/docs/python-sdk/fastmcp-server-auth-ssrf.mdx b/docs/python-sdk/fastmcp-server-auth-ssrf.mdx new file mode 100644 index 000000000..f098ad3f9 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-ssrf.mdx @@ -0,0 +1,172 @@ +--- +title: ssrf +sidebarTitle: ssrf +--- + +# `fastmcp.server.auth.ssrf` + + +SSRF-safe HTTP utilities for FastMCP. + +This module provides SSRF-protected HTTP fetching with: +- DNS resolution and IP validation before requests +- DNS pinning to prevent rebinding TOCTOU attacks +- Support for both CIMD and JWKS fetches + + +## Functions + +### `format_ip_for_url` + +```python +format_ip_for_url(ip_str: str) -> str +``` + + +Format IP address for use in URL (bracket IPv6 addresses). + +IPv6 addresses must be bracketed in URLs to distinguish the address from +the port separator. For example: https://[2001:db8::1]:443/path + +**Args:** +- `ip_str`: IP address string + +**Returns:** +- IP string suitable for URL (IPv6 addresses are bracketed) + + +### `is_ip_allowed` + +```python +is_ip_allowed(ip_str: str) -> bool +``` + + +Check if an IP address is allowed (must be globally routable unicast). + +Uses ip.is_global which catches: +- Private (10.x, 172.16-31.x, 192.168.x) +- Loopback (127.x, ::1) +- Link-local (169.254.x, fe80::) - includes AWS metadata! +- Reserved, unspecified +- RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks + +Additionally blocks multicast addresses (not caught by is_global). + +**Args:** +- `ip_str`: IP address string to check + +**Returns:** +- True if the IP is allowed (public unicast internet), False if blocked + + +### `resolve_hostname` + +```python +resolve_hostname(hostname: str, port: int = 443) -> list[str] +``` + + +Resolve hostname to IP addresses using DNS. + +**Args:** +- `hostname`: Hostname to resolve +- `port`: Port number (used for getaddrinfo) + +**Returns:** +- List of resolved IP addresses + +**Raises:** +- `SSRFError`: If resolution fails + + +### `validate_url` + +```python +validate_url(url: str, require_path: bool = False) -> ValidatedURL +``` + + +Validate URL for SSRF and resolve to IPs. + +**Args:** +- `url`: URL to validate +- `require_path`: If True, require non-root path (for CIMD) + +**Returns:** +- ValidatedURL with resolved IPs + +**Raises:** +- `SSRFError`: If URL is invalid or resolves to blocked IPs + + +### `ssrf_safe_fetch` + +```python +ssrf_safe_fetch(url: str) -> bytes +``` + + +Fetch URL with comprehensive SSRF protection and DNS pinning. + +Security measures: +1. HTTPS only +2. DNS resolution with IP validation +3. Connects to validated IP directly (DNS pinning prevents rebinding) +4. Response size limit +5. Redirects disabled +6. Overall timeout + +**Args:** +- `url`: URL to fetch +- `require_path`: If True, require non-root path +- `max_size`: Maximum response size in bytes (default 5KB) +- `timeout`: Per-operation timeout in seconds +- `overall_timeout`: Overall timeout for entire operation + +**Returns:** +- Response body as bytes + +**Raises:** +- `SSRFError`: If SSRF validation fails +- `SSRFFetchError`: If fetch fails + + +### `ssrf_safe_fetch_response` + +```python +ssrf_safe_fetch_response(url: str) -> SSRFFetchResponse +``` + + +Fetch URL with SSRF protection and return response metadata. + +This is equivalent to :func:`ssrf_safe_fetch` but returns response headers +and status code, and supports conditional request headers. + + +## Classes + +### `SSRFError` + + +Raised when an SSRF protection check fails. + + +### `SSRFFetchError` + + +Raised when SSRF-safe fetch fails. + + +### `ValidatedURL` + + +A URL that has been validated for SSRF with resolved IPs. + + +### `SSRFFetchResponse` + + +Response payload from an SSRF-safe fetch. + diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index d718b2fc3..b066c8c8e 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -169,7 +169,7 @@ By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients. If `include_all` is True, all headers are returned. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -187,7 +187,7 @@ request is available. - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -212,7 +212,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -238,7 +238,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -257,7 +257,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -277,7 +277,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -297,7 +297,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -315,7 +315,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -335,7 +335,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -352,7 +352,7 @@ safe to use in code that might run over any transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -382,7 +382,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -393,7 +393,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -402,7 +402,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -411,7 +411,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -420,7 +420,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -429,7 +429,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -438,7 +438,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -447,7 +447,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -459,25 +459,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -486,7 +486,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -495,7 +495,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -504,7 +504,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. diff --git a/docs/python-sdk/fastmcp-server-middleware-authorization.mdx b/docs/python-sdk/fastmcp-server-middleware-authorization.mdx index a7853b5c6..27d4afefb 100644 --- a/docs/python-sdk/fastmcp-server-middleware-authorization.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-authorization.mdx @@ -14,12 +14,12 @@ AuthMiddleware applies auth checks globally to all components on the server. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes, restrict_tag + from fastmcp.server.auth import require_scopes, restrict_tag from fastmcp.server.middleware import AuthMiddleware - # Require auth for all components + # Require specific scope for all components mcp = FastMCP(middleware=[ - AuthMiddleware(auth=require_auth) + AuthMiddleware(auth=require_scopes("api")) ]) # Tag-based: components tagged "admin" require "admin" scope @@ -52,7 +52,7 @@ All checks must pass for authorization to succeed (AND logic). **Methods:** -#### `on_list_tools` +#### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] @@ -61,7 +61,7 @@ on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: Filter tools/list response based on auth checks. -#### `on_call_tool` +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult @@ -70,7 +70,7 @@ on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_ne Check auth before tool execution. -#### `on_list_resources` +#### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] @@ -79,7 +79,7 @@ on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], cal Filter resources/list response based on auth checks. -#### `on_read_resource` +#### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult]) -> ResourceResult @@ -88,7 +88,7 @@ on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], Check auth before resource read. -#### `on_list_resource_templates` +#### `on_list_resource_templates` ```python on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] @@ -97,7 +97,7 @@ on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTempl Filter resource templates/list response based on auth checks. -#### `on_list_prompts` +#### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] @@ -106,7 +106,7 @@ on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_ne Filter prompts/list response based on auth checks. -#### `on_get_prompt` +#### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult]) -> PromptResult diff --git a/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx b/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx new file mode 100644 index 000000000..de673407b --- /dev/null +++ b/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx @@ -0,0 +1,32 @@ +--- +title: response_limiting +sidebarTitle: response_limiting +--- + +# `fastmcp.server.middleware.response_limiting` + + +Response limiting middleware for controlling tool response sizes. + +## Classes + +### `ResponseLimitingMiddleware` + + +Middleware that limits the response size of tool calls. + +Intercepts tool call responses and enforces size limits. If a response +exceeds the limit, it extracts text content, truncates it, and returns +a single TextContent block. + + +**Methods:** + +#### `on_call_tool` + +```python +on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult +``` + +Intercept tool calls and limit response size. + diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index ed4b9ce41..bc0b40d3c 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx @@ -10,7 +10,7 @@ OpenAPI component classes: Tool, Resource, and ResourceTemplate. ## Classes -### `OpenAPITool` +### `OpenAPITool` Tool implementation for OpenAPI endpoints. @@ -18,7 +18,7 @@ Tool implementation for OpenAPI endpoints. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Execute the HTTP request using RequestDirector. -### `OpenAPIResource` +### `OpenAPIResource` Resource implementation for OpenAPI endpoints. @@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -44,7 +44,7 @@ read(self) -> ResourceResult Fetch the resource data by making an HTTP request. -### `OpenAPIResourceTemplate` +### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. @@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx index 6be6e07e4..6892d1174 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx @@ -10,7 +10,7 @@ OpenAPIProvider for creating MCP components from OpenAPI specifications. ## Classes -### `OpenAPIProvider` +### `OpenAPIProvider` Provider that creates MCP components from an OpenAPI specification. @@ -21,7 +21,7 @@ spec. Each component makes HTTP calls to the described API endpoints. **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -30,7 +30,7 @@ lifespan(self) -> AsyncIterator[None] Manage the lifecycle of the auto-created httpx client. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index dba6cf8a6..48d0d45a9 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -293,7 +293,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -301,7 +301,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index 259ef10e0..aa8c7b2d5 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -60,7 +60,7 @@ the referenced definition while preserving $defs for nested references. ### `compress_schema` ```python -compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = True, prune_titles: bool = False) -> dict[str, Any] +compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False) -> dict[str, Any] ``` @@ -74,6 +74,8 @@ schema size. **Args:** - `schema`: The schema to compress - `prune_params`: List of parameter names to remove from properties -- `prune_additional_properties`: Whether to remove additionalProperties\: false +- `prune_additional_properties`: Whether to remove additionalProperties\: false. +Defaults to False to maintain MCP client compatibility, as some clients +(e.g., Claude) require additionalProperties\: false for strict validation. - `prune_titles`: Whether to remove title fields from the schema diff --git a/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx b/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx index 2464180bd..c7b0bf5fc 100644 --- a/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx +++ b/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx @@ -33,7 +33,7 @@ Unified parser for OpenAPI schemas with generic type parameters to handle both 3 **Methods:** -#### `parse` +#### `parse` ```python parse(self) -> list[HTTPRoute] From 25f3b0878ee369dec93cf468a74fda9a6b121907 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:27:42 -0500 Subject: [PATCH 11/14] Add missing beta2 features to v3 release tracking (#3105) generate-cli, goose integration, response limiting middleware, background task context, require_auth removal --- docs/development/v3-notes/v3-features.mdx | 79 +++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 432b191bd..f982513d9 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -120,6 +120,85 @@ Key details: Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) +### CLI: `fastmcp generate-cli` + +`fastmcp generate-cli` connects to any MCP server, reads its tool schemas, and writes a standalone Python CLI script where every tool becomes a typed subcommand with flags, help text, and tab completion ([#3065](https://github.com/jlowin/fastmcp/pull/3065)). The insight is that MCP tool schemas already contain everything a CLI framework needs — parameter names, types, descriptions, required/optional status — so the generator maps JSON Schema directly into [cyclopts](https://cyclopts.readthedocs.io/) commands. + +```bash +# Generate from any server spec +fastmcp generate-cli weather +fastmcp generate-cli http://localhost:8000/mcp +fastmcp generate-cli server.py my_weather_cli.py + +# Use the generated script +python my_weather_cli.py call-tool get_forecast --city London --days 3 +python my_weather_cli.py list-tools +python my_weather_cli.py read-resource docs://readme +``` + +The generated script embeds the resolved transport (URL or stdio command), so it's self-contained — users don't need to know about MCP or FastMCP to use it. Supports `-f` to overwrite existing files, and name-based resolution via `fastmcp discover`. + +Documentation: [Generate CLI](/clients/generate-cli) + +### CLI: Goose Integration + +New `fastmcp install goose` command that generates a `goose://extension?...` deeplink URL and opens it, prompting Goose to install the server as a STDIO extension ([#3040](https://github.com/jlowin/fastmcp/pull/3040)). Goose requires `uvx` rather than `uv run`, so the command builds the appropriate invocation automatically. + +```bash +fastmcp install goose server.py +fastmcp install goose server.py --with pandas --python 3.11 +``` + +Also adds a full integration guide at [Goose Integration](/integrations/goose). + +### ResponseLimitingMiddleware + +New middleware for controlling tool response sizes, preventing large outputs from overwhelming LLM context windows ([#3072](https://github.com/jlowin/fastmcp/pull/3072)). Text responses are truncated at UTF-8 character boundaries; structured responses (tools with `output_schema`) raise `ToolError` since truncation would corrupt the schema. + +```python +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware + +# Limit all tool responses to 500KB +mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) + +# Limit only specific tools, raise errors instead of truncating +mcp.add_middleware(ResponseLimitingMiddleware( + max_size=100_000, + tools=["search", "fetch_data"], + raise_on_unstructured=True, +)) +``` + +Key features: +- Configurable size limit (default 1MB) +- Tool-specific filtering via `tools` parameter +- Size metadata added to result's `meta` field for monitoring +- Configurable `raise_on_structured` and `raise_on_unstructured` behavior + +Documentation: [Middleware](/servers/middleware) + +### Background Task Context (SEP-1686) + +`Context` now works transparently in background tasks running in Docket workers ([#2905](https://github.com/jlowin/fastmcp/pull/2905)). Previously, tools running as background tasks couldn't use `ctx.elicit()` because there was no active request context. Now, when a tool executes in a Docket worker, `Context` detects this via its `task_id` and routes elicitation through Redis-based coordination: the task sets its status to `input_required`, sends a `notifications/tasks/updated` notification with elicitation metadata, and waits for the client to respond via `tasks/sendInput`. + +```python +@mcp.tool(task=True) +async def interactive_task(ctx: Context) -> str: + # Works transparently in both foreground and background task modes + result = await ctx.elicit("Please provide additional input", str) + + if isinstance(result, AcceptedElicitation): + return f"You provided: {result.data}" + else: + return "Elicitation was declined or cancelled" +``` + +`ctx.is_background_task` and `ctx.task_id` are available for tools that need to branch on execution mode. + +### `require_auth` Removed + +The `require_auth` authorization check introduced in beta1 has been removed in favor of scope-based authorization via `require_scopes` ([#3103](https://github.com/jlowin/fastmcp/pull/3103)). Since configuring an `AuthProvider` already rejects unauthenticated requests at the transport level, `require_auth` was redundant — `require_scopes` provides the same guarantee with better granularity. The beta1 Component Authorization section has been updated to reflect this. + ### MCP Apps (SDK Compatibility) Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. From 1ed97708926a25cb9ed0e7403d739a72b7003ec5 Mon Sep 17 00:00:00 2001 From: SrzStephen Date: Sat, 7 Feb 2026 21:18:39 +0800 Subject: [PATCH 12/14] Updated deprecation URL (#3108) For V3 this should be https://gofastmcp.com/servers/dependency-injection#using-depends For V2 this should be https://gofastmcp.com/v2/servers/context#using-depends current url fails for both v2 and v3 documentation https://gofastmcp.com/servers/dependencies https://gofastmcp.com/v2/servers/dependencies --- src/fastmcp/tools/function_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 38d3116c6..7ef6df398 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -193,7 +193,7 @@ class FunctionTool(Tool): warnings.warn( "The `exclude_args` parameter is deprecated as of FastMCP 2.14. " "Use dependency injection with `Depends()` instead for better lifecycle management. " - "See https://gofastmcp.com/servers/dependencies for examples.", + "See https://gofastmcp.com/servers/dependency-injection#using-depends for examples.", DeprecationWarning, stacklevel=2, ) From 806aa8c57985d5a0cdacfd9b22a2051e1a18a838 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:33:12 -0500 Subject: [PATCH 13/14] Update docs to reference beta 2 (#3112) --- README.md | 2 +- docs/docs.json | 2 +- docs/getting-started/installation.mdx | 8 ++++---- docs/servers/tasks.mdx | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a0afe2231..5892d00b2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ These compose cleanly, so complex patterns don't require complex code. And becau ## Installation > [!Note] -> FastMCP 3.0 is currently in beta. Install with: `pip install fastmcp==3.0.0b1` +> FastMCP 3.0 is currently in beta. Install with: `pip install fastmcp==3.0.0b2` > > For production systems requiring stability, pin to v2: `pip install 'fastmcp<3'` diff --git a/docs/docs.json b/docs/docs.json index 2864855fe..995cb146c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -665,7 +665,7 @@ "icon": "code" } ], - "version": "v3.0.0 (beta 1)" + "version": "v3.0.0 (beta 2)" }, { "dropdowns": [ diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 9eb54bb16..0a566bd68 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -8,17 +8,17 @@ icon: arrow-down-to-line We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP. -FastMCP 3.0 is currently in beta. Package managers won't install beta versions by default—you must explicitly request one (e.g., `>=3.0.0b1`). +FastMCP 3.0 is currently in beta. Package managers won't install beta versions by default—you must explicitly request one (e.g., `>=3.0.0b2`). ```bash -pip install "fastmcp>=3.0.0b1" +pip install "fastmcp>=3.0.0b2" ``` Or with uv: ```bash -uv add "fastmcp>=3.0.0b1" +uv add "fastmcp>=3.0.0b2" ``` ### Optional Dependencies @@ -26,7 +26,7 @@ uv add "fastmcp>=3.0.0b1" FastMCP provides optional extras for specific features. For example, to install the background tasks extra: ```bash -pip install "fastmcp[tasks]==3.0.0b1" +pip install "fastmcp[tasks]==3.0.0b2" ``` See [Background Tasks](/servers/tasks) for details on the task system. diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index 24fdd4d9d..2b38dc3f0 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -43,7 +43,7 @@ MCP background tasks are different: they're **protocol-native**. This means MCP Background tasks require the `tasks` extra: ```bash -pip install "fastmcp[tasks]>=3.0.0b1" +pip install "fastmcp[tasks]>=3.0.0b2" ``` Add `task=True` to any tool, resource, resource template, or prompt decorator. This marks the component as capable of background execution. From f7cdd20a42b11f6443489c9d114fe7328c552be2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:09:28 -0500 Subject: [PATCH 14/14] generate-cli: auto-generate SKILL.md agent skill (#3115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * generate-cli: auto-generate SKILL.md alongside CLI script generate-cli now produces a SKILL.md agent skill file next to the CLI script, documenting every tool's exact invocation syntax, parameter flags, and types. Agents can use the CLI immediately without discovery. * Use uv run --with fastmcp in generated SKILL.md invocations * Fix skill generation issues from review - Escape pipe chars in union type labels so markdown tables render - Boolean params omit placeholder in example invocations - Quote YAML frontmatter values to handle special chars in names - Match cyclopts camelCase→snake_case in flag derivation - Use four-backtick fence for nested code block in docs * Replace --skill/--no-skill with just --no-skill * Escape quotes in YAML frontmatter description * Strip newlines from param descriptions in skill table rows * Detect boolean union types for flag placeholder --- docs/clients/generate-cli.mdx | 38 ++++- src/fastmcp/cli/generate.py | 180 ++++++++++++++++++++- tests/cli/test_generate_cli.py | 280 +++++++++++++++++++++++++++++++++ 3 files changed, 494 insertions(+), 4 deletions(-) diff --git a/docs/clients/generate-cli.mdx b/docs/clients/generate-cli.mdx index 833637423..4d05e0515 100644 --- a/docs/clients/generate-cli.mdx +++ b/docs/clients/generate-cli.mdx @@ -23,7 +23,7 @@ fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py my_weather_cli.py ``` -The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If the file already exists, the command refuses to overwrite unless you pass `-f`: +The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If either the CLI file or its companion `SKILL.md` already exists, the command refuses to overwrite unless you pass `-f`: ```bash fastmcp generate-cli weather -f @@ -85,6 +85,42 @@ Options: Tool names are preserved exactly as the server defines them — underscores stay as underscores, so `call-tool get_forecast` matches what the server expects. +## Agent Skill + +Alongside the CLI script, `generate-cli` also writes a `SKILL.md` file — a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents the generated CLI. The skill includes every tool's exact invocation syntax, parameter flags with types and descriptions, and the utility commands, so an agent can use the CLI immediately without running `--help` or experimenting with flag names. + +The skill is written to the same directory as the CLI script. For a weather server, it looks something like: + +````markdown +--- +name: "weather-cli" +description: "CLI for the weather MCP server. Call tools, list resources, and get prompts." +--- + +# weather CLI + +## Tool Commands + +### get_forecast + +Get the weather forecast for a city. + +```bash +uv run --with fastmcp python cli.py call-tool get_forecast --city --days +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--city` | string | yes | City name | +| `--days` | integer | no | Number of forecast days | +```` + +To skip skill generation, pass `--no-skill`: + +```bash +fastmcp generate-cli weather --no-skill +``` + ## How It Works The generated script is a client, not a server. It doesn't bundle or embed the MCP server — it connects to it on every invocation. For URL-based servers, the server needs to be running. For stdio-based servers, the command specified in `CLIENT_SPEC` must be available on the system's `PATH`. diff --git a/src/fastmcp/cli/generate.py b/src/fastmcp/cli/generate.py index 7fdc6cd8a..b5e652909 100644 --- a/src/fastmcp/cli/generate.py +++ b/src/fastmcp/cli/generate.py @@ -1,4 +1,4 @@ -"""Generate a standalone CLI script from an MCP server's capabilities.""" +"""Generate a standalone CLI script and agent skill from an MCP server.""" import keyword import re @@ -518,6 +518,152 @@ def generate_cli_script( return "\n".join(lines) +# --------------------------------------------------------------------------- +# Skill (SKILL.md) generation +# --------------------------------------------------------------------------- + +_JSON_SCHEMA_TYPE_LABELS: dict[str, str] = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + "null": "null", + "array": "array", + "object": "object", +} + + +def _param_to_cli_flag(prop_name: str) -> str: + """Convert a JSON Schema property name to its CLI flag form. + + Replicates cyclopts' default_name_transform: camelCase → snake_case, + lowercase, underscores → hyphens, strip leading/trailing hyphens. + """ + safe = _to_python_identifier(prop_name) + # camelCase / PascalCase → snake_case + safe = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", safe) + safe = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", safe) + safe = safe.lower().replace("_", "-").strip("-") + return f"--{safe}" if safe else "--arg" + + +def _schema_type_label(prop_schema: dict[str, Any]) -> str: + """Return a human-readable type label for a property schema.""" + schema_type = prop_schema.get("type", "string") + if isinstance(schema_type, list): + labels = [_JSON_SCHEMA_TYPE_LABELS.get(t, t) for t in schema_type] + return " | ".join(labels) + + label = _JSON_SCHEMA_TYPE_LABELS.get(schema_type, schema_type) + + # For arrays, include item type if simple + if schema_type == "array": + items = prop_schema.get("items", {}) + item_type = items.get("type", "") + if isinstance(item_type, str) and item_type in _JSON_SCHEMA_TYPE_LABELS: + return f"array[{item_type}]" + + return label + + +def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str: + """Generate a SKILL.md section for a single tool.""" + schema = tool.inputSchema + properties: dict[str, Any] = schema.get("properties", {}) + required = set(schema.get("required", [])) + + # Build example invocation flags + flag_parts_list: list[str] = [] + for p, p_schema in properties.items(): + flag = _param_to_cli_flag(p) + schema_type = p_schema.get("type") + is_bool = schema_type == "boolean" or ( + isinstance(schema_type, list) and "boolean" in schema_type + ) + if is_bool: + flag_parts_list.append(flag) + else: + flag_parts_list.append(f"{flag} ") + flag_parts = " ".join(flag_parts_list) + invocation = f"uv run --with fastmcp python {cli_filename} call-tool {tool.name}" + if flag_parts: + invocation += f" {flag_parts}" + + # Build parameter table rows + rows: list[str] = [] + for prop_name, prop_schema in properties.items(): + flag = f"`{_param_to_cli_flag(prop_name)}`" + type_label = _schema_type_label(prop_schema).replace("|", "\\|") + is_required = "yes" if prop_name in required else "no" + description = prop_schema.get("description", "") + _, needs_json = _schema_to_python_type(prop_schema) + if needs_json: + description = ( + f"{description} (JSON string)" if description else "JSON string" + ) + description = description.replace("\n", " ").replace("|", "\\|") + rows.append(f"| {flag} | {type_label} | {is_required} | {description} |") + + param_table = "" + if rows: + header = "| Flag | Type | Required | Description |\n|------|------|----------|-------------|" + param_table = f"\n{header}\n" + "\n".join(rows) + "\n" + + lines: list[str] = [f"### {tool.name}"] + if tool.description: + lines.extend(["", tool.description]) + lines.extend(["", "```bash", invocation, "```"]) + if param_table: + lines.extend(["", param_table.strip("\n")]) + return "\n".join(lines) + + +def generate_skill_content( + server_name: str, + cli_filename: str, + tools: list[mcp.types.Tool], +) -> str: + """Generate a SKILL.md file for a generated CLI script.""" + skill_name = ( + server_name.replace(" ", "-").lower().replace("\\", "").replace('"', "") + ) + safe_name = server_name.replace("\\", "").replace('"', "") + description = f"CLI for the {safe_name} MCP server. Call tools, list resources, and get prompts." + + lines = [ + "---", + f'name: "{skill_name}-cli"', + f'description: "{description}"', + "---", + "", + f"# {server_name} CLI", + "", + ] + + if tools: + tool_bodies = "\n\n".join( + _tool_skill_section(tool, cli_filename) for tool in tools + ) + lines.extend(["## Tool Commands", "", tool_bodies, ""]) + + lines.extend( + [ + "## Utility Commands", + "", + "```bash", + f"uv run --with fastmcp python {cli_filename} list-tools", + f"uv run --with fastmcp python {cli_filename} list-resources", + f"uv run --with fastmcp python {cli_filename} read-resource ", + f"uv run --with fastmcp python {cli_filename} list-prompts", + f"uv run --with fastmcp python {cli_filename} get-prompt [key=value ...]", + "```", + "", + ] + ) + + return "\n".join(lines) + + # --------------------------------------------------------------------------- # CLI command # --------------------------------------------------------------------------- @@ -555,22 +701,40 @@ async def generate_cli_command( help="Auth method: 'oauth', a bearer token string, or 'none' to disable", ), ] = None, + no_skill: Annotated[ + bool, + cyclopts.Parameter( + "--no-skill", + help="Skip generating a SKILL.md agent skill alongside the CLI", + ), + ] = False, ) -> None: """Generate a standalone CLI script from an MCP server. Connects to the server, reads its tools/resources/prompts, and writes - a Python script that can invoke them directly. + a Python script that can invoke them directly. Also generates a SKILL.md + agent skill file unless --no-skill is passed. Examples: fastmcp generate-cli weather fastmcp generate-cli weather my_cli.py fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py output.py -f + fastmcp generate-cli weather --no-skill """ output_path = Path(output) + skill_path = output_path.parent / "SKILL.md" + + # Check both files up front before doing any work + existing: list[Path] = [] if output_path.exists() and not force: + existing.append(output_path) + if not no_skill and skill_path.exists() and not force: + existing.append(skill_path) + if existing: + names = ", ".join(f"[cyan]{p}[/cyan]" for p in existing) console.print( - f"[bold red]Error:[/bold red] [cyan]{output_path}[/cyan] already exists. " + f"[bold red]Error:[/bold red] {names} already exist(s). " f"Use [cyan]-f[/cyan] to overwrite." ) sys.exit(1) @@ -612,6 +776,16 @@ async def generate_cli_command( f"[green]✓[/green] Wrote [cyan]{output_path}[/cyan] " f"with {len(tools)} tool command(s)" ) + + if not no_skill: + skill_content = generate_skill_content( + server_name=server_name, + cli_filename=output_path.name, + tools=tools, + ) + skill_path.write_text(skill_content) + console.print(f"[green]✓[/green] Wrote [cyan]{skill_path}[/cyan]") + console.print(f"[dim]Run: python {output_path} --help[/dim]") diff --git a/tests/cli/test_generate_cli.py b/tests/cli/test_generate_cli.py index f513338d7..8f567c846 100644 --- a/tests/cli/test_generate_cli.py +++ b/tests/cli/test_generate_cli.py @@ -13,11 +13,14 @@ from fastmcp.cli import generate as generate_module from fastmcp.cli.client import Client from fastmcp.cli.generate import ( _derive_server_name, + _param_to_cli_flag, _schema_to_python_type, + _schema_type_label, _to_python_identifier, _tool_function_source, generate_cli_command, generate_cli_script, + generate_skill_content, serialize_transport, ) from fastmcp.client.transports.stdio import StdioTransport @@ -636,3 +639,280 @@ class TestGenerateCliCommand: output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) assert output.stat().st_mode & 0o111 + + @pytest.mark.usefixtures("_patch_client") + async def test_writes_skill_file(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + skill_path = tmp_path / "SKILL.md" + assert skill_path.exists() + content = skill_path.read_text() + assert "---" in content + assert "name:" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_contains_tools(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "### greet" in content + assert "### add" in content + assert "--name" in content + assert "call-tool greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_no_skill_flag(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output), no_skill=True) + assert not (tmp_path / "SKILL.md").exists() + + @pytest.mark.usefixtures("_patch_client") + async def test_error_if_skill_exists(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + with pytest.raises(SystemExit): + await generate_cli_command("test-server", str(output)) + + @pytest.mark.usefixtures("_patch_client") + async def test_force_overwrites_skill(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + await generate_cli_command("test-server", str(output), force=True) + content = (tmp_path / "SKILL.md").read_text() + assert content != "existing" + assert "### greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_references_cli_filename(self, tmp_path: Path): + output = tmp_path / "my_weather.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "uv run --with fastmcp python my_weather.py" in content + + +# --------------------------------------------------------------------------- +# _param_to_cli_flag +# --------------------------------------------------------------------------- + + +class TestParamToCliFlag: + def test_simple_name(self): + assert _param_to_cli_flag("city") == "--city" + + def test_underscore_name(self): + assert _param_to_cli_flag("max_days") == "--max-days" + + def test_hyphenated_name(self): + # content-type → _to_python_identifier → content_type → --content-type + assert _param_to_cli_flag("content-type") == "--content-type" + + def test_digit_prefix(self): + # 3d_mode → _3d_mode → --3d-mode (leading underscore stripped) + assert _param_to_cli_flag("3d_mode") == "--3d-mode" + + def test_trailing_underscore(self): + # from → from_ after identifier sanitization; Cyclopts strips trailing "-" + assert _param_to_cli_flag("from") == "--from" + + def test_camel_case(self): + # camelCase → camel-case (cyclopts default_name_transform) + assert _param_to_cli_flag("myParam") == "--my-param" + + def test_pascal_case(self): + assert _param_to_cli_flag("MyParam") == "--my-param" + + +# --------------------------------------------------------------------------- +# _schema_type_label +# --------------------------------------------------------------------------- + + +class TestSchemaTypeLabel: + def test_simple_string(self): + assert _schema_type_label({"type": "string"}) == "string" + + def test_integer(self): + assert _schema_type_label({"type": "integer"}) == "integer" + + def test_array_of_strings(self): + assert ( + _schema_type_label({"type": "array", "items": {"type": "string"}}) + == "array[string]" + ) + + def test_union_types(self): + result = _schema_type_label({"type": ["string", "null"]}) + assert "string" in result + assert "null" in result + + def test_object(self): + assert _schema_type_label({"type": "object"}) == "object" + + def test_missing_type(self): + assert _schema_type_label({}) == "string" + + +# --------------------------------------------------------------------------- +# generate_skill_content +# --------------------------------------------------------------------------- + + +class TestGenerateSkillContent: + def test_frontmatter(self): + content = generate_skill_content("weather", "cli.py", []) + assert content.startswith("---\n") + assert 'name: "weather-cli"' in content + assert "description:" in content + + def test_no_tools(self): + content = generate_skill_content("weather", "cli.py", []) + assert "## Utility Commands" in content + assert "## Tool Commands" not in content + + def test_tool_sections(self): + tools = [ + mcp.types.Tool( + name="greet", + description="Say hello", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Who to greet"} + }, + "required": ["name"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "## Tool Commands" in content + assert "### greet" in content + assert "Say hello" in content + assert "call-tool greet" in content + assert "`--name`" in content + assert "| string |" in content + assert "| yes |" in content + + def test_frontmatter_with_tools_starts_at_column_zero(self): + tools = [ + mcp.types.Tool( + name="greet", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("weather", "cli.py", tools) + assert content.splitlines()[0] == "---" + + def test_optional_param(self): + tools = [ + mcp.types.Tool( + name="search", + description="Search things", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # query is required, limit is not + assert "| `--query` | string | yes |" in content + assert "| `--limit` | integer | no |" in content + + def test_complex_json_param(self): + tools = [ + mcp.types.Tool( + name="create", + description="Create item", + inputSchema={ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": {"x": {"type": "integer"}}, + }, + }, + "required": ["data"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "JSON string" in content + + def test_no_params_tool(self): + tools = [ + mcp.types.Tool( + name="ping", + description="Ping the server", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "### ping" in content + assert "call-tool ping" in content + # No parameter table + assert "| Flag |" not in content + + def test_cli_filename_in_utility_commands(self): + content = generate_skill_content("test", "my_cli.py", []) + assert "uv run --with fastmcp python my_cli.py list-tools" in content + assert "uv run --with fastmcp python my_cli.py list-resources" in content + + def test_pipe_in_description_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "mode": {"type": "string", "description": "a|b|c"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "a\\|b\\|c" in content + + def test_union_type_pipes_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "val": {"type": ["string", "null"]}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # Pipes in type label must be escaped so markdown table renders correctly + assert "string \\| null" in content + + def test_boolean_param_no_value_placeholder(self): + tools = [ + mcp.types.Tool( + name="run", + description="Run something", + inputSchema={ + "type": "object", + "properties": { + "verbose": {"type": "boolean", "description": "Verbose output"}, + "name": {"type": "string"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "--verbose " not in content + assert "--name " in content + + def test_server_name_in_header(self): + content = generate_skill_content("My Weather API", "cli.py", []) + assert "# My Weather API CLI" in content + assert 'name: "my-weather-api-cli"' in content