Merge branch 'main' into claude/issue-3091-20260206-0232

This commit is contained in:
Bill Easton 2026-02-08 17:42:35 -06:00 committed by GitHub
commit b1549b090a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 6932 additions and 399 deletions

View file

@ -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'`

137
docs/clients/auth/cimd.mdx Normal file
View file

@ -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"
<VersionBadge version="3.0.0" />
<Tip>
CIMD authentication is only relevant for HTTP-based transports and requires a server that advertises CIMD support.
</Tip>
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.
<Note>
You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically.
</Note>
## 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:
<Steps>
<Step title="Client Presents Metadata URL">
Your client sends its `client_metadata_url` as the `client_id` in the OAuth authorization request.
</Step>
<Step title="Server Recognizes CIMD URL">
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.
</Step>
<Step title="Server Fetches and Validates">
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).
</Step>
<Step title="Authorization Proceeds">
The standard OAuth flow continues: browser opens for user consent, authorization code exchange, token issuance. The consent screen shows your verified domain.
</Step>
</Steps>
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.

View file

@ -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()
```
<Note>
You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically.
</Note>
#### `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
<Step title="OAuth Server Discovery">
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`.
</Step>
<Step title="Dynamic Client Registration">
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.
<Step title="Client Registration">
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.
</Step>
<Step title="Local Callback Server">
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:<port>/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](
<Note>
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.
</Note>
## CIMD Authentication
<VersionBadge version="3.0.0" />
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.

View file

@ -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 <value> --days <value>
```
| 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`.

View file

@ -73,6 +73,132 @@ 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)
### 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.
@ -731,11 +857,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"))
@ -749,10 +875,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=[
@ -761,7 +887,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

View file

@ -198,6 +198,7 @@
"icon": "key",
"pages": [
"clients/auth/oauth",
"clients/auth/cimd",
"clients/auth/bearer"
]
}
@ -301,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",
@ -412,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",
{
@ -446,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",
@ -467,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"
]
@ -659,7 +665,7 @@
"icon": "code"
}
],
"version": "v3.0.0 (beta 1)"
"version": "v3.0.0 (beta 2)"
},
{
"dropdowns": [

View file

@ -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.
<Note>
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`).
</Note>
```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.

View file

@ -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`
<VersionBadge version="3.0.0" />
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.

View file

@ -0,0 +1,9 @@
---
title: auth
sidebarTitle: auth
---
# `fastmcp.cli.auth`
Authentication-related CLI commands.

View file

@ -0,0 +1,43 @@
---
title: cimd
sidebarTitle: cimd
---
# `fastmcp.cli.cimd`
CIMD (Client ID Metadata Document) CLI commands.
## Functions
### `create_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cimd.py#L32" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cimd.py#L144" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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

View file

@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts.
## Functions
### `with_argv` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L72" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `with_argv` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L73" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L95" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `version` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L96" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
version()
@ -37,7 +37,7 @@ version()
Display version information and platform details.
### `dev` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L137" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `dev` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L138" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L317" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L318" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L619" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `inspect` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L620" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L861" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `prepare` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L862" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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

View file

@ -73,7 +73,7 @@ a browser for user authorization and running a local callback server.
**Methods:**
#### `redirect_handler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L233" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `redirect_handler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `callback_handler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L280" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L293" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `async_auth_flow` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L319" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]

View file

@ -10,7 +10,7 @@ Anthropic sampling handler for FastMCP.
## Classes
### `AnthropicSamplingHandler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/sampling/handlers/anthropic.py#L47" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AnthropicSamplingHandler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/sampling/handlers/anthropic.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Sampling handler that uses the Anthropic API.

View file

@ -18,19 +18,19 @@ Transport implementation that connects to an MCP server via Streamable HTTP Requ
**Methods:**
#### `connect_session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/http.py#L86" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `connect_session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/http.py#L92" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession]
```
#### `get_session_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/http.py#L132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_session_id` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/http.py#L138" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_session_id(self) -> str | None
```
#### `close` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/http.py#L140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `close` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/http.py#L146" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
close(self)

View file

@ -18,7 +18,7 @@ Transport implementation that connects to an MCP server via Server-Sent Events.
**Methods:**
#### `connect_session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/sse.py#L58" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `connect_session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/transports/sse.py#L64" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession]

View file

@ -7,13 +7,13 @@ sidebarTitle: auth
## Classes
### `AccessToken` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L43" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AccessToken` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
AccessToken that includes all JWT claims.
### `TokenHandler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L49" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `TokenHandler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L65" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `handle` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
handle(self, request: Any)
@ -42,7 +42,37 @@ handle(self, request: Any)
Wrap SDK handle() and transform auth error responses.
### `AuthProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L111" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `PrivateKeyJWTClientAuthenticator` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L126" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L156" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L207" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Base class for all FastMCP authentication providers.
@ -55,7 +85,7 @@ custom authentication routes.
**Methods:**
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L236" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L153" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_mcp_path` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L249" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L167" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L263" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L190" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_well_known_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L286" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L222" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_middleware` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L318" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L256" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `TokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L352" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Base class for token verifiers (Resource Servers).
@ -164,7 +194,7 @@ Token verifiers typically don't provide authentication routes by default.
**Methods:**
#### `scopes_supported` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L278" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `scopes_supported` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L374" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
scopes_supported(self) -> list[str]
@ -178,7 +208,7 @@ where tokens contain short-form scopes but clients request full URI
scopes).
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L288" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L384" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L293" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `RemoteAuthProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L389" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L340" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L436" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L344" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L440" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L376" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OAuthProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L472" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
OAuth Authorization Server provider.
@ -235,7 +265,7 @@ authorization flows, token issuance, and token verification.
**Methods:**
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L439" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L535" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L454" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L550" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L533" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_well_known_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/auth.py#L629" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_well_known_routes(self, mcp_path: str | None = None) -> list[Route]

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L77" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
require_auth(ctx: AuthContext) -> bool
```
Require any valid authentication.
Returns True if the request has a valid token, False otherwise.
### `require_scopes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L91" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `require_scopes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L77" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L119" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `restrict_tag` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L105" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L147" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `run_auth_checks` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L133" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L45" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate_auth_method(cls, v: str) -> str
```
Ensure no shared-secret auth methods are used.
#### `validate_redirect_uris` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L137" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate_redirect_uris(cls, v: list[str]) -> list[str]
```
Ensure redirect_uris is non-empty and each entry is a valid URI.
### `CIMDValidationError` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L154" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Raised when CIMD document validation fails.
### `CIMDFetchError` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L158" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Raised when CIMD document fetching fails.
### `CIMDFetcher` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L186" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L270" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L287" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L422" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L452" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L495" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L677" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L711" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L722" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/cimd.py#L771" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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

View file

@ -15,7 +15,7 @@ cookie management, and consent page rendering.
## Classes
### `ConsentMixin` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/consent.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ConsentMixin` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/consent.py#L35" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Mixin class providing consent management functionality for OAuthProxy.

View file

@ -13,7 +13,7 @@ This module contains all Pydantic models and constants used by the OAuth proxy.
## Classes
### `OAuthTransaction` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OAuthTransaction` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L40" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L57" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ClientCode` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L61" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L75" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `UpstreamTokenSet` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L79" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L97" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `JTIMapping` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L101" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L109" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `RefreshTokenMetadata` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L113" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L131" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ProxyDCRClient` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L135" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Client for DCR proxy with configurable redirect URI validation.
@ -89,16 +89,17 @@ arise from accepting arbitrary redirect URIs.
**Methods:**
#### `validate_redirect_uri` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L160" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `validate_redirect_uri` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/models.py#L166" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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.

View file

@ -26,7 +26,7 @@ production use with enterprise identity providers.
## Classes
### `OAuthProxy` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L105" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OAuthProxy` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L113" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L496" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_mcp_path` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L518" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L520" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `jwt_issuer` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L542" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L557" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_client` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L579" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L573" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `register_client` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L623" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L626" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `authorize` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L676" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L745" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_authorization_code` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L795" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L793" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `exchange_authorization_code` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L843" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1039" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_refresh_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1089" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1068" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `exchange_refresh_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1298" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_access_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1348" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1357" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `revoke_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1407" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1390" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1440" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]

View file

@ -16,7 +16,7 @@ This module contains HTML generation functions for consent and error pages.
### `create_consent_html` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/ui.py#L22" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/ui.py#L192" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `create_error_html` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy/ui.py#L224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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

View file

@ -52,7 +52,7 @@ that is OIDC compliant.
**Methods:**
#### `get_oidc_configuration` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oidc_proxy.py#L380" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_oidc_configuration` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oidc_proxy.py#L386" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oidc_proxy.py#L397" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_token_verifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oidc_proxy.py#L403" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_token_verifier(self) -> TokenVerifier

View file

@ -10,19 +10,19 @@ TokenVerifier implementations for FastMCP.
## Classes
### `JWKData` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L24" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `JWKData` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
JSON Web Key data structure.
### `JWKSData` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L37" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `JWKSData` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L39" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
JSON Web Key Set data structure.
### `RSAKeyPair` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L44" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `RSAKeyPair` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
RSA key pair for JWT testing.
@ -30,7 +30,7 @@ RSA key pair for JWT testing.
**Methods:**
#### `generate` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `generate` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L53" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
generate(cls) -> RSAKeyPair
@ -42,7 +42,7 @@ Generate an RSA key pair for testing.
- Generated key pair
#### `create_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L86" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `create_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L88" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L139" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `JWTVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L141" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms.
@ -82,7 +82,7 @@ Use this when:
**Methods:**
#### `load_access_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L324" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_access_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L353" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L442" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L471" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L458" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `StaticTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L487" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L492" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/jwt.py#L521" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
verify_token(self, token: str) -> AccessToken | None

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/redirect_validation.py#L8" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `matches_allowed_pattern` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/redirect_validation.py#L121" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/redirect_validation.py#L28" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `validate_redirect_uri` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/redirect_validation.py#L175" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate_redirect_uri(redirect_uri: str | AnyUrl | None, allowed_patterns: list[str] | None) -> bool

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L98" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L147" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L196" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L239" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L47" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Raised when an SSRF protection check fails.
### `SSRFFetchError` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Raised when SSRF-safe fetch fails.
### `ValidatedURL` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A URL that has been validated for SSRF with resolved IPs.
### `SSRFFetchResponse` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/ssrf.py#L139" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Response payload from an SSRF-safe fetch.

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L474" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `get_access_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L475" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L532" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `without_injected_parameters` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L533" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]
@ -212,7 +212,7 @@ Handles:
- Async wrapper function without injected parameters
### `resolve_dependencies` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L673" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `resolve_dependencies` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L674" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L769" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CurrentContext` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L770" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentContext() -> Context
@ -257,7 +257,7 @@ current MCP operation (tool/resource/prompt call).
- `RuntimeError`: If no active context found (during resolution)
### `CurrentDocket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L812" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CurrentDocket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L813" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentDocket() -> Docket
@ -277,7 +277,7 @@ automatically creates for background task scheduling.
- `ImportError`: If fastmcp[tasks] not installed
### `CurrentWorker` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L857" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CurrentWorker` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L858" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
CurrentWorker() -> Worker
@ -297,7 +297,7 @@ automatically creates for background task processing.
- `ImportError`: If fastmcp[tasks] not installed
### `CurrentFastMCP` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L899" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CurrentFastMCP` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L900" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L934" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CurrentRequest` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L935" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L970" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CurrentHeaders` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L971" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1009" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CurrentAccessToken` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1010" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1038" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ProgressLike` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1039" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Protocol for progress tracking interface.
@ -393,7 +393,7 @@ and Docket's Progress (worker context).
**Methods:**
#### `current` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1046" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `current` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1047" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current(self) -> int | None
@ -402,7 +402,7 @@ current(self) -> int | None
Current progress value.
#### `total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1051" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1052" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
total(self) -> int
@ -411,7 +411,7 @@ total(self) -> int
Total/target progress value.
#### `message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1056" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1057" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
message(self) -> str | None
@ -420,7 +420,7 @@ message(self) -> str | None
Current progress message.
#### `set_total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1060" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1061" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1064" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `increment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1065" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1068" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1069" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1073" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `InMemoryProgress` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1074" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
In-memory progress tracker for immediate tool execution.
@ -459,25 +459,25 @@ progress doesn't need to be observable across processes.
**Methods:**
#### `current` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1093" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `current` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1094" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
current(self) -> int | None
```
#### `total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1097" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1098" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
total(self) -> int
```
#### `message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1101" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1102" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
message(self) -> str | None
```
#### `set_total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1104" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_total` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1105" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `increment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1111" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1119" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1120" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1124" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `Progress` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py#L1125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
FastMCP Progress dependency that works in both server and worker contexts.

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L88" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_list_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L85" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L113" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_call_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L156" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_list_resources` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L153" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_read_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L177" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L223" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_list_resource_templates` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L220" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L249" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_list_prompts` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L246" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L273" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_get_prompt` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/authorization.py#L270" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult]) -> PromptResult

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/response_limiting.py#L20" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/response_limiting.py#L93" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult
```
Intercept tool calls and limit response size.

View file

@ -10,7 +10,7 @@ OpenAPI component classes: Tool, Resource, and ResourceTemplate.
## Classes
### `OpenAPITool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L64" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OpenAPITool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Tool implementation for OpenAPI endpoints.
@ -18,7 +18,7 @@ Tool implementation for OpenAPI endpoints.
**Methods:**
#### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L160" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L168" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OpenAPIResource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L222" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Resource implementation for OpenAPI endpoints.
@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints.
**Methods:**
#### `read` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L198" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `read` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L252" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
read(self) -> ResourceResult
@ -44,7 +44,7 @@ read(self) -> ResourceResult
Fetch the resource data by making an HTTP request.
### `OpenAPIResourceTemplate` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L282" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OpenAPIResourceTemplate` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L336" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Resource template implementation for OpenAPI endpoints.
@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints.
**Methods:**
#### `create_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L312" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `create_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/components.py#L368" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource

View file

@ -10,7 +10,7 @@ OpenAPIProvider for creating MCP components from OpenAPI specifications.
## Classes
### `OpenAPIProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/provider.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OpenAPIProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/provider.py#L52" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/provider.py#L175" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `lifespan` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/provider.py#L176" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
lifespan(self) -> AsyncIterator[None]
@ -30,7 +30,7 @@ lifespan(self) -> AsyncIterator[None]
Manage the lifecycle of the auto-created httpx client.
#### `get_tasks` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/provider.py#L427" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_tasks` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/openapi/provider.py#L430" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_tasks(self) -> Sequence[FastMCPComponent]

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L975" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `apply_transformations_to_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L977" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L921" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ToolTransformConfig` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L923" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Provides a way to transform a tool.
@ -301,7 +301,7 @@ Provides a way to transform a tool.
**Methods:**
#### `apply` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L954" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `apply` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L956" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
apply(self, tool: Tool) -> TransformedTool

View file

@ -60,7 +60,7 @@ the referenced definition while preserving $defs for nested references.
### `compress_schema` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L364" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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

View file

@ -33,7 +33,7 @@ Unified parser for OpenAPI schemas with generic type parameters to handle both 3
**Methods:**
#### `parse` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/openapi/parser.py#L659" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `parse` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/openapi/parser.py#L663" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
parse(self) -> list[HTTPRoute]

View file

@ -416,7 +416,7 @@ sequenceDiagram
Note over Client, Proxy: Token Exchange
Client->>Proxy: 11. POST /token with code<br/>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.
@ -524,6 +526,74 @@ auth = OAuthProxy(
Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use.
## CIMD Support
<VersionBadge version="3.0.0" />
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`.
<Card icon="code" title="CIMD Parameters">
<ParamField body="enable_cimd" type="bool" default="True">
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.
</ParamField>
</Card>
### 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
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(
...,
enable_cimd=False,
)
```
## Security
### Key and Storage Management
@ -560,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:

View file

@ -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
<VersionBadge version="3.0.0" />
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:
<Card icon="code" title="CIMD Parameters">
<ParamField body="enable_cimd" type="bool" default="True">
Whether to accept CIMD URLs as client identifiers.
</ParamField>
</Card>
## Production Configuration
For production deployments, load sensitive credentials from environment variables:

View file

@ -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.
</Note>
<Note>
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.
</Note>
## 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"
```
<Warning>
Component-level `auth` only controls visibility in list operations. It does not block direct access. Use `AuthMiddleware` to enforce authorization on execution.
</Warning>
<Note>
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.
</Note>
## 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

View file

@ -555,6 +555,54 @@ my_tool = Tool.from_function(fn=my_tool_fn, name="my_tool")
mcp.add_middleware(ToolInjectionMiddleware(tools=[my_tool]))
```
### Response Limiting
<VersionBadge version="3.0.0" />
```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.
<Note>
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.
</Note>
```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:

View file

@ -43,7 +43,7 @@ MCP background tasks are different: they're **protocol-native**. This means MCP
<VersionBadge version="3.0.0" /> 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.

View file

@ -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!")

View file

@ -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"

13
src/fastmcp/cli/auth.py Normal file
View file

@ -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)

218
src/fastmcp/cli/cimd.py Normal file
View file

@ -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)

View file

@ -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()

View file

@ -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} <value>")
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 <uri>",
f"uv run --with fastmcp python {cli_filename} list-prompts",
f"uv run --with fastmcp python {cli_filename} get-prompt <name> [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]")

View file

@ -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:

View file

@ -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(

View file

@ -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(

View file

@ -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",

View file

@ -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.

View file

@ -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.

View file

@ -0,0 +1,799 @@
"""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 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
from pydantic import AnyHttpUrl, BaseModel, Field, field_validator
from fastmcp.server.auth.ssrf import (
SSRFError,
SSRFFetchError,
ssrf_safe_fetch_response,
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."""
@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_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)
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, _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.
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_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
"""
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:
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:
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(response.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,
)
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:
"""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
)

View file

@ -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

View file

@ -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,92 @@ 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:
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."
)
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 is not None:
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)

View file

@ -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)

View file

@ -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(
</div>
"""
# 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"""
<div class="cimd-badge">
<span class="cimd-check">&#x2713;</span>
Verified domain: <strong>{cimd_domain_escaped}</strong>
</div>
"""
# 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")}
<h1>Application Access Request</h1>
{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

View file

@ -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:

View file

@ -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

View file

@ -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(

View file

@ -0,0 +1,356 @@
"""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 collections.abc import Mapping
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]
@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.
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
"""
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
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,
)
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 (
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=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 not in expected_statuses:
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 SSRFFetchResponse(
content=b"".join(chunks),
status_code=response.status_code,
headers=dict(response.headers),
)
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")

View file

@ -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",

View file

@ -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"))
])
```
"""

View file

@ -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)

View file

@ -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 []),
)

View file

@ -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:

View file

@ -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,
)

View file

@ -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:

View file

@ -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

View file

@ -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:

208
tests/cli/test_cimd_cli.py Normal file
View file

@ -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

View file

@ -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 <value>" not in content
assert "--name <value>" 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

View file

@ -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

View file

@ -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

View file

@ -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:

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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,72 @@ 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_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(
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)
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."""
@ -240,3 +312,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"))

View file

@ -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,
}
)

View file

@ -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

View file

@ -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."""

View file

@ -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)

View file

@ -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"

View file

@ -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")

View file

@ -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"

View file

@ -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"},

View file

@ -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,
}
)

View file

@ -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",

View file

@ -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.