mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Merge branch 'main' into claude/issue-3091-20260206-0232
This commit is contained in:
commit
b1549b090a
88 changed files with 6932 additions and 399 deletions
|
|
@ -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
137
docs/clients/auth/cimd.mdx
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
9
docs/python-sdk/fastmcp-cli-auth.mdx
Normal file
9
docs/python-sdk/fastmcp-cli-auth.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: auth
|
||||
sidebarTitle: auth
|
||||
---
|
||||
|
||||
# `fastmcp.cli.auth`
|
||||
|
||||
|
||||
Authentication-related CLI commands.
|
||||
43
docs/python-sdk/fastmcp-cli-cimd.mdx
Normal file
43
docs/python-sdk/fastmcp-cli-cimd.mdx
Normal 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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
242
docs/python-sdk/fastmcp-server-auth-cimd.mdx
Normal file
242
docs/python-sdk/fastmcp-server-auth-cimd.mdx
Normal 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
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
172
docs/python-sdk/fastmcp-server-auth-ssrf.mdx
Normal file
172
docs/python-sdk/fastmcp-server-auth-ssrf.mdx
Normal 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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
|
||||
|
|
|
|||
2
loq.toml
2
loq.toml
|
|
@ -76,7 +76,7 @@ max_lines = 1584
|
|||
|
||||
[[rules]]
|
||||
path = "src/fastmcp/server/auth/oauth_proxy/proxy.py"
|
||||
max_lines = 1600
|
||||
max_lines = 1740
|
||||
|
||||
[[rules]]
|
||||
path = "tests/server/test_dependencies.py"
|
||||
|
|
|
|||
13
src/fastmcp/cli/auth.py
Normal file
13
src/fastmcp/cli/auth.py
Normal 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
218
src/fastmcp/cli/cimd.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
799
src/fastmcp/server/auth/cimd.py
Normal file
799
src/fastmcp/server/auth/cimd.py
Normal 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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">✓</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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
356
src/fastmcp/server/auth/ssrf.py
Normal file
356
src/fastmcp/server/auth/ssrf.py
Normal 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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
])
|
||||
```
|
||||
"""
|
||||
|
|
|
|||
125
src/fastmcp/server/middleware/response_limiting.py
Normal file
125
src/fastmcp/server/middleware/response_limiting.py
Normal 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)
|
||||
|
|
@ -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 []),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
208
tests/cli/test_cimd_cli.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
164
tests/client/auth/test_oauth_cimd.py
Normal file
164
tests/client/auth/test_oauth_cimd.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
1209
tests/server/auth/test_cimd.py
Normal file
1209
tests/server/auth/test_cimd.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
447
tests/server/auth/test_ssrf_protection.py
Normal file
447
tests/server/auth/test_ssrf_protection.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
155
tests/server/middleware/test_response_limiting.py
Normal file
155
tests/server/middleware/test_response_limiting.py
Normal 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")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue