From cd085412d1d43f722a5147b89bda70921785ed02 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:35:43 +0000 Subject: [PATCH 01/63] Mock network call in test_version_command_execution Fixes #3049 by mocking check_for_newer_version to prevent real network calls to PyPI during tests, which was causing timeouts on Windows. Co-authored-by: Bill Easton --- tests/cli/test_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index a6ab3b8a7..703771dff 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -39,7 +39,8 @@ class TestMainCLI: class TestVersionCommand: """Test the version command.""" - def test_version_command_execution(self): + @patch("fastmcp.cli.cli.check_for_newer_version", return_value=None) + def test_version_command_execution(self, mock_check): """Test that version command executes properly.""" # The version command should execute without raising SystemExit command, bound, _ = app.parse_args(["version"]) From cec40b378d688a0d5c230ff5e7e1fb1e43f32b90 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:30:05 +0000 Subject: [PATCH 02/63] Use MemoryStore for OAuth proxy tests Updated all OAuthProxy test instantiations to use MemoryStore instead of defaulting to DiskStore, avoiding SQLite timeout issues on Windows and improving test performance. Co-authored-by: Bill Easton --- tests/server/auth/oauth_proxy/conftest.py | 3 +++ tests/server/auth/oauth_proxy/test_authorization.py | 4 ++++ tests/server/auth/oauth_proxy/test_config.py | 9 +++++++++ tests/server/auth/oauth_proxy/test_e2e.py | 4 ++++ tests/server/auth/oauth_proxy/test_oauth_proxy.py | 5 +++++ tests/server/auth/oauth_proxy/test_tokens.py | 8 ++++++++ tests/server/auth/oauth_proxy/test_ui.py | 2 ++ tests/server/auth/test_enhanced_error_responses.py | 6 ++++++ tests/server/auth/test_oauth_mounting.py | 4 ++++ .../server/auth/test_oauth_proxy_redirect_validation.py | 6 ++++++ 10 files changed, 51 insertions(+) diff --git a/tests/server/auth/oauth_proxy/conftest.py b/tests/server/auth/oauth_proxy/conftest.py index 3acfacf7f..802ca2348 100644 --- a/tests/server/auth/oauth_proxy/conftest.py +++ b/tests/server/auth/oauth_proxy/conftest.py @@ -288,6 +288,8 @@ def jwt_verifier(): @pytest.fixture def oauth_proxy(jwt_verifier): """Create a standard OAuthProxy instance for testing.""" + from key_value.aio.stores.memory import MemoryStore + return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", @@ -297,6 +299,7 @@ def oauth_proxy(jwt_verifier): base_url="https://myserver.com", redirect_path="/auth/callback", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) diff --git a/tests/server/auth/oauth_proxy/test_authorization.py b/tests/server/auth/oauth_proxy/test_authorization.py index 2b5aaf4a2..7a8a9b9e7 100644 --- a/tests/server/auth/oauth_proxy/test_authorization.py +++ b/tests/server/auth/oauth_proxy/test_authorization.py @@ -3,6 +3,7 @@ from urllib.parse import parse_qs, urlparse import pytest +from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl @@ -67,6 +68,7 @@ class TestOAuthProxyPKCE: base_url="https://proxy.example.com", forward_pkce=True, jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) @pytest.fixture @@ -82,6 +84,7 @@ class TestOAuthProxyPKCE: base_url="https://proxy.example.com", forward_pkce=False, jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) async def test_pkce_forwarding_enabled(self, proxy_with_pkce): @@ -172,6 +175,7 @@ class TestParameterForwarding: "prompt": "consent", "max_age": "3600", }, + client_storage=MemoryStore(), ) client = OAuthClientInformationFull( diff --git a/tests/server/auth/oauth_proxy/test_config.py b/tests/server/auth/oauth_proxy/test_config.py index ae3abe002..0b88a0ae5 100644 --- a/tests/server/auth/oauth_proxy/test_config.py +++ b/tests/server/auth/oauth_proxy/test_config.py @@ -1,6 +1,7 @@ """Tests for OAuth proxy configuration and validation.""" import pytest +from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.provider import AuthorizationParams, AuthorizeError from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyHttpUrl, AnyUrl @@ -76,6 +77,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Use non-default path to prove fix isn't relying on old hardcoded /mcp proxy.set_mcp_path("/api/v2/mcp") @@ -261,6 +263,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) proxy.set_mcp_path("/mcp") # Simulate server configured with query params for tenant scoping @@ -300,6 +303,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) proxy.set_mcp_path("/mcp") # Simulate server configured with query params for tenant scoping @@ -337,6 +341,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) proxy.set_mcp_path("/mcp") # Simulate server configured with query params for tenant scoping @@ -374,6 +379,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Before set_mcp_path, _jwt_issuer is None @@ -397,6 +403,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) proxy.set_mcp_path(None) @@ -413,6 +420,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) with pytest.raises(RuntimeError) as exc_info: @@ -430,6 +438,7 @@ class TestResourceURLValidation: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Before get_routes, _jwt_issuer is None diff --git a/tests/server/auth/oauth_proxy/test_e2e.py b/tests/server/auth/oauth_proxy/test_e2e.py index 8b500db61..39c4592e8 100644 --- a/tests/server/auth/oauth_proxy/test_e2e.py +++ b/tests/server/auth/oauth_proxy/test_e2e.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from urllib.parse import parse_qs, urlparse import httpx +from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.provider import AuthorizationCode, AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl @@ -30,6 +31,7 @@ class TestOAuthProxyE2E: token_verifier=MockTokenVerifier(), base_url="http://localhost:8000", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Create FastMCP server with proxy @@ -84,6 +86,7 @@ class TestOAuthProxyE2E: token_verifier=MockTokenVerifier(), base_url="http://localhost:8000", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Initialize JWT issuer before token operations @@ -201,6 +204,7 @@ class TestOAuthProxyE2E: base_url="http://localhost:8000", forward_pkce=True, # Enable PKCE forwarding jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) client = OAuthClientInformationFull( diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index a8cfac54d..27c31e198 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -1,5 +1,7 @@ """Tests for OAuth proxy initialization and configuration.""" +from key_value.aio.stores.memory import MemoryStore + from fastmcp.server.auth.oauth_proxy import OAuthProxy @@ -16,6 +18,7 @@ class TestOAuthProxyInitialization: token_verifier=jwt_verifier, base_url="https://api.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert ( @@ -45,6 +48,7 @@ class TestOAuthProxyInitialization: forward_pkce=False, token_endpoint_auth_method="client_secret_post", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert proxy._upstream_revocation_endpoint == "https://auth.example.com/revoke" @@ -65,5 +69,6 @@ class TestOAuthProxyInitialization: base_url="https://api.com", redirect_path="auth/callback", # No leading slash jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert proxy._redirect_path == "/auth/callback" diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 5051a8ebc..b7e16431f 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -4,6 +4,7 @@ import time from unittest.mock import AsyncMock, Mock, patch import pytest +from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.handlers.token import TokenErrorResponse from mcp.server.auth.handlers.token import TokenHandler as SDKTokenHandler from mcp.server.auth.provider import AuthorizationCode @@ -35,6 +36,7 @@ class TestOAuthProxyTokenEndpointAuth: base_url="https://proxy.example.com", token_endpoint_auth_method="client_secret_post", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert proxy_post._token_endpoint_auth_method == "client_secret_post" @@ -48,6 +50,7 @@ class TestOAuthProxyTokenEndpointAuth: base_url="https://proxy.example.com", token_endpoint_auth_method="client_secret_basic", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert proxy_basic._token_endpoint_auth_method == "client_secret_basic" @@ -60,6 +63,7 @@ class TestOAuthProxyTokenEndpointAuth: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert proxy_default._token_endpoint_auth_method is None @@ -74,6 +78,7 @@ class TestOAuthProxyTokenEndpointAuth: base_url="https://proxy.example.com", token_endpoint_auth_method="client_secret_post", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Initialize JWT issuer before token operations @@ -296,6 +301,7 @@ class TestFallbackAccessTokenExpiry: base_url="http://localhost:8000", jwt_signing_key="test-signing-key", fallback_access_token_expiry_seconds=86400, + client_storage=MemoryStore(), ) assert provider._fallback_access_token_expiry_seconds == 86400 @@ -313,6 +319,7 @@ class TestFallbackAccessTokenExpiry: ), base_url="http://localhost:8000", jwt_signing_key="test-signing-key", + client_storage=MemoryStore(), ) assert provider._fallback_access_token_expiry_seconds is None @@ -345,6 +352,7 @@ class TestUpstreamTokenStorageTTL: token_verifier=jwt_verifier, base_url="https://proxy.example.com", jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), ) proxy.set_mcp_path("/mcp") return proxy diff --git a/tests/server/auth/oauth_proxy/test_ui.py b/tests/server/auth/oauth_proxy/test_ui.py index 9967d5a31..4795ec2ff 100644 --- a/tests/server/auth/oauth_proxy/test_ui.py +++ b/tests/server/auth/oauth_proxy/test_ui.py @@ -2,6 +2,7 @@ from unittest.mock import Mock +from key_value.aio.stores.memory import MemoryStore from starlette.requests import Request from starlette.responses import HTMLResponse @@ -76,6 +77,7 @@ class TestErrorPageRendering: ), base_url="http://localhost:8000", jwt_signing_key="test-signing-key", + client_storage=MemoryStore(), ) # Mock a request with an error from the IdP diff --git a/tests/server/auth/test_enhanced_error_responses.py b/tests/server/auth/test_enhanced_error_responses.py index cec425ecc..f7463be36 100644 --- a/tests/server/auth/test_enhanced_error_responses.py +++ b/tests/server/auth/test_enhanced_error_responses.py @@ -29,6 +29,8 @@ class TestEnhancedAuthorizationHandler: @pytest.fixture def oauth_proxy(self, rsa_key_pair): """Create OAuth proxy for testing.""" + from key_value.aio.stores.memory import MemoryStore + return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", @@ -42,6 +44,7 @@ class TestEnhancedAuthorizationHandler: ), base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) def test_unregistered_client_returns_html_for_browser(self, oauth_proxy): @@ -290,6 +293,8 @@ class TestContentNegotiation: @pytest.fixture def oauth_proxy(self): """Create OAuth proxy for testing.""" + from key_value.aio.stores.memory import MemoryStore + return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", @@ -303,6 +308,7 @@ class TestContentNegotiation: ), base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) def test_html_preferred_when_both_accepted(self, oauth_proxy): diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py index 1729c5979..93f4a2308 100644 --- a/tests/server/auth/test_oauth_mounting.py +++ b/tests/server/auth/test_oauth_mounting.py @@ -8,6 +8,7 @@ The fix uses MCP SDK 1.17+ which implements RFC 9728 path-scoped well-known URLs import httpx import pytest +from key_value.aio.stores.memory import MemoryStore from pydantic import AnyHttpUrl from starlette.applications import Starlette from starlette.routing import Mount @@ -220,6 +221,7 @@ class TestOAuthMounting: token_verifier=token_verifier, base_url="https://api.example.com/api", # Includes mount prefix issuer_url="https://api.example.com", # Root level + client_storage=MemoryStore(), ) mcp = FastMCP("test-server", auth=auth_provider) @@ -290,6 +292,7 @@ class TestOAuthMounting: upstream_client_secret="test-client-secret", token_verifier=token_verifier, base_url="https://api.example.com/api", # Has path, no explicit issuer_url + client_storage=MemoryStore(), ) mcp = FastMCP("test-server", auth=auth_provider) @@ -366,6 +369,7 @@ class TestOAuthMounting: token_verifier=token_verifier, base_url="https://api.example.com/api", issuer_url="https://api.example.com", # Explicitly root + client_storage=MemoryStore(), ) well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp") diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index c91560c52..336029197 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -1,6 +1,7 @@ """Tests for OAuth proxy redirect URI validation.""" import pytest +from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import InvalidRedirectUriError from pydantic import AnyUrl @@ -112,6 +113,7 @@ class TestOAuthProxyRedirectValidation: token_verifier=MockTokenVerifier(), base_url="http://localhost:8000", jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # The proxy should store None for default (allow all) @@ -130,6 +132,7 @@ class TestOAuthProxyRedirectValidation: base_url="http://localhost:8000", allowed_client_redirect_uris=custom_patterns, jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert proxy._allowed_client_redirect_uris == custom_patterns @@ -145,6 +148,7 @@ class TestOAuthProxyRedirectValidation: base_url="http://localhost:8000", allowed_client_redirect_uris=[], jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) assert proxy._allowed_client_redirect_uris == [] @@ -162,6 +166,7 @@ class TestOAuthProxyRedirectValidation: base_url="http://localhost:8000", allowed_client_redirect_uris=custom_patterns, jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Register a client @@ -195,6 +200,7 @@ class TestOAuthProxyRedirectValidation: base_url="http://localhost:8000", allowed_client_redirect_uris=custom_patterns, jwt_signing_key="test-secret", + client_storage=MemoryStore(), ) # Get an unregistered client From bd37763e98897c007bca77b05ff9293f3ff0c53a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:30:14 -0500 Subject: [PATCH 03/63] Add `fastmcp list` and `fastmcp call` CLI commands (#3054) --- docs/clients/cli.mdx | 126 ++++ docs/development/v3-notes/v3-features.mdx | 27 + docs/docs.json | 1 + docs/patterns/cli.mdx | 134 ++++ examples/elicitation.py | 47 ++ skills/fastmcp-client-cli/SKILL.md | 93 +++ src/fastmcp/cli/cli.py | 5 + src/fastmcp/cli/client.py | 872 ++++++++++++++++++++++ tests/cli/test_client_commands.py | 557 ++++++++++++++ 9 files changed, 1862 insertions(+) create mode 100644 docs/clients/cli.mdx create mode 100644 examples/elicitation.py create mode 100644 skills/fastmcp-client-cli/SKILL.md create mode 100644 src/fastmcp/cli/client.py create mode 100644 tests/cli/test_client_commands.py diff --git a/docs/clients/cli.mdx b/docs/clients/cli.mdx new file mode 100644 index 000000000..b12996631 --- /dev/null +++ b/docs/clients/cli.mdx @@ -0,0 +1,126 @@ +--- +title: Client CLI +sidebarTitle: CLI +description: Query and invoke MCP server tools directly from the terminal with fastmcp list and fastmcp call. +icon: terminal +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +MCP servers are designed for programmatic consumption by AI assistants and applications. But during development, you often want to poke at a server directly: check what tools it exposes, call one with test arguments, or verify that a deployment is responding correctly. The FastMCP CLI gives you that direct access with two commands, `fastmcp list` and `fastmcp call`, so you can query and invoke any MCP server without writing a single line of Python. + +These commands are also valuable for LLM-based agents that lack native MCP support. An agent that can execute shell commands can use `fastmcp list --json` to discover available tools and `fastmcp call --json` to invoke them, with structured JSON output designed for programmatic consumption. + +## Server Targets + +Both commands need to know which server to talk to. You provide a "server spec" as the first argument, and FastMCP figures out the transport automatically. You can point at an HTTP URL for a running server, a Python file that defines one, a JSON configuration file that describes one, or a JavaScript file. The CLI resolves the right connection mechanism so you can focus on the query. + +```bash +fastmcp list http://localhost:8000/mcp +fastmcp list server.py +fastmcp list mcp-config.json +``` + +Python files are handled with particular care. Rather than requiring your script to call `mcp.run()` at the bottom, the CLI routes it through `fastmcp run` internally, which means any Python file that defines a FastMCP server object works as a target with no boilerplate. + +For servers that communicate over stdio (common with Node.js-based MCP servers), use the `--command` flag instead of a positional server spec. The string is shell-split into a command and arguments. + +```bash +fastmcp list --command 'npx -y @modelcontextprotocol/server-github' +``` + +## Discovering Tools + +`fastmcp list` connects to a server and prints every tool it exposes. The default output is compact: each tool appears as a function signature with its parameter names, types, and a description. + +```bash +fastmcp list http://localhost:8000/mcp +``` + +The output looks like a Python function signature, making it easy to see at a glance what a tool expects and what it returns. Required parameters appear with just their type annotation, while optional ones show their defaults. + +When you need the full JSON Schema for a tool's inputs or outputs -- useful for understanding nested object structures or enum constraints -- opt into them with `--input-schema` or `--output-schema`. These print the raw schema beneath each tool signature. + +### Beyond Tools + +MCP servers can expose resources and prompts alongside tools. By default, `fastmcp list` only shows tools because they are the most common interaction point. Add `--resources` or `--prompts` to include those in the output. + +```bash +fastmcp list server.py --resources --prompts +``` + +Resources appear with their URIs and descriptions. Prompts appear with their argument names so you can see what parameters they accept. + +### Machine-Readable Output + +The `--json` flag switches from human-friendly text to structured JSON. Each tool includes its name, description, and full input schema (and output schema when present). When combined with `--resources` or `--prompts`, those are included as additional top-level keys. + +```bash +fastmcp list server.py --json +``` + +This is the format to use when building automation around MCP servers or feeding tool definitions to an LLM agent that needs to decide which tool to call. + +## Calling Tools + +`fastmcp call` invokes a single tool on a server. You provide the server spec, the tool name, and arguments as `key=value` pairs. The CLI fetches the tool's schema, coerces your string values to the correct types (integers, floats, booleans, arrays, objects), and makes the call. + +```bash +fastmcp call http://localhost:8000/mcp search query=hello limit=5 +``` + +Type coercion is driven by the tool's JSON Schema. If a parameter is declared as an integer, the string `"5"` becomes the integer `5`. Booleans accept `true`/`false`, `yes`/`no`, and `1`/`0`. Array and object parameters are parsed as JSON. + +For tools with complex or deeply nested arguments, the `key=value` syntax gets unwieldy. You can pass a single JSON object as the argument instead, and the CLI treats it as the full input dictionary. + +```bash +fastmcp call server.py create_item '{"name": "Widget", "tags": ["sale", "new"], "metadata": {"color": "blue"}}' +``` + +Alternatively, `--input-json` provides the base argument dictionary. Any `key=value` pairs you add alongside it override keys from the JSON, which is useful for templating a complex call and varying one parameter at a time. + +### Error Handling + +The CLI validates your call before sending it. If you misspell a tool name, it uses fuzzy matching to suggest corrections. If you omit a required argument, it tells you which ones are missing and prints the tool's signature as a reminder. + +When a tool call itself returns an error (the server executed the tool but it failed), the error message is printed and the CLI exits with a non-zero status code, making it straightforward to use in scripts. + +### Structured Output + +Like `fastmcp list`, the `--json` flag on `fastmcp call` emits structured JSON instead of formatted text. The output includes the content blocks, error status, and structured content when the server provides it. Use this when you need to parse tool results programmatically. + +```bash +fastmcp call server.py get_weather city=London --json +``` + +## Authentication + +When the server target is an HTTP URL, the CLI automatically enables OAuth authentication. If the server requires it, you will be guided through the OAuth flow (typically opening a browser for authorization). If the server has no auth requirements, the OAuth setup is a silent no-op. + +To explicitly disable authentication -- for example, when connecting to a local development server where OAuth setup would just slow you down -- pass `--auth none`. + +```bash +fastmcp call http://localhost:8000/mcp my_tool --auth none +``` + +## Transport Override + +FastMCP defaults to Streamable HTTP for URL targets. If you are connecting to a server that only supports Server-Sent Events (SSE), use `--transport sse` to force the older transport. This appends `/sse` to the URL path automatically so the client picks the correct protocol. + +```bash +fastmcp list http://localhost:8000 --transport sse +``` + +## Interactive Elicitation + +Some MCP tools request additional input from the user during execution through a mechanism called elicitation. When a tool sends an elicitation request, the CLI prints the server's question to the terminal and prompts you to respond. Each field in the elicitation schema is presented with its name and expected type, and required fields are clearly marked. + +You can type `decline` to skip a question or `cancel` to abort the tool call entirely. This interactive behavior means the CLI works naturally with tools that have multi-step or conversational workflows. + +## LLM Agent Integration + +For LLM agents that can execute shell commands but lack built-in MCP support, the CLI provides a clean integration path. The agent calls `fastmcp list --json` to get a structured description of every available tool, including full input schemas, and then calls `fastmcp call --json` with the chosen tool and arguments. Both commands return well-formed JSON that is straightforward to parse. + +Because the CLI handles connection management, transport selection, and type coercion internally, the agent does not need to understand MCP protocol details. It just needs to read JSON and construct shell commands. diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 974d34009..45a9e647a 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -6,6 +6,33 @@ This document tracks major features in FastMCP v3.0 for release notes preparatio ## 3.0.0beta2 +### CLI: `fastmcp list` and `fastmcp call` + +New client-side CLI commands for querying and invoking tools on any MCP server — remote URLs, local Python files, MCPConfig JSON, or arbitrary stdio commands. Especially useful for giving LLMs that don't have built-in MCP support access to MCP tools via shell commands. + +```bash +# Discover tools on a server +fastmcp list http://localhost:8000/mcp +fastmcp list server.py +fastmcp list --command 'npx -y @modelcontextprotocol/server-github' + +# Call a tool +fastmcp call server.py greet name=World +fastmcp call http://localhost:8000/mcp search query=hello limit=5 +fastmcp call server.py create_item '{"name": "Widget", "tags": ["a", "b"]}' +``` + +Key features: +- Tool arguments are auto-coerced using the tool's JSON schema (`limit=5` → int) +- Single JSON objects work as positional args alongside `key=value` and `--input-json` +- `--input-schema` / `--output-schema` for full JSON schemas, `--json` for machine-readable output +- `--transport sse` for SSE servers, `--command` for stdio servers +- Auto OAuth for HTTP targets (no-ops if server doesn't require auth) +- Fuzzy tool name matching suggests alternatives on typos +- Interactive terminal elicitation for tools that request user input mid-execution + +Documentation: [Client CLI](/clients/cli) + ### CLI: Expanded Reload File Watching The `--reload` flag now watches a comprehensive set of file types, making it suitable for MCP apps with frontend bundles ([#3028](https://github.com/jlowin/fastmcp/pull/3028)). Previously limited to `.py` files, it now watches JavaScript, TypeScript, HTML, CSS, config files, and media assets. diff --git a/docs/docs.json b/docs/docs.json index 70b6d2173..de6369d10 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -168,6 +168,7 @@ "group": "Clients", "pages": [ "clients/client", + "clients/cli", "clients/transports", { "group": "Core Operations", diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 8662e26e5..72badb870 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -18,6 +18,8 @@ fastmcp --help | Command | Purpose | Dependency Management | | ------- | ------- | --------------------- | +| `list` | List tools on any MCP server | **Supports:** URLs, local files, MCPConfig JSON, stdio commands. **Deps:** N/A (connects to existing servers) | +| `call` | Call a tool on any MCP server | **Supports:** URLs, local files, MCPConfig JSON, stdio commands. **Deps:** N/A (connects to existing servers) | | `run` | Run a FastMCP server directly | **Supports:** Local files, factory functions, URLs, fastmcp.json configs, MCP configs. **Deps:** Uses your local environment directly. With `--python`, `--with`, `--project`, or `--with-requirements`: Runs via `uv run` subprocess. With fastmcp.json: Automatically manages dependencies based on configuration | | `dev` | Run a server with the MCP Inspector for testing | **Supports:** Local files and fastmcp.json configs. **Deps:** Always runs via `uv run` subprocess (never uses your local environment); dependencies must be specified or available in a uv-managed project. With fastmcp.json: Uses configured dependencies | | `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 | @@ -25,6 +27,138 @@ fastmcp --help | `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 | | `version` | Display version information | N/A | +## `fastmcp list` + +List tools available on any MCP server. This works with remote URLs, local Python files, MCPConfig JSON files, and arbitrary stdio commands. Together with `fastmcp call`, these commands are especially useful for giving LLMs that don't have built-in MCP support access to MCP tools via shell commands. + +```bash +fastmcp list http://localhost:8000/mcp +fastmcp list server.py +fastmcp list mcp.json +fastmcp list --command 'npx -y @modelcontextprotocol/server-github' +``` + +By default, the output shows each tool's signature and description. Use `--input-schema` or `--output-schema` to include full JSON schemas, or `--json` for machine-readable output. + +### Options + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| Command | `--command` | Connect to a stdio server command (e.g. `'npx -y @mcp/server'`) | +| Transport | `--transport`, `-t` | Force transport type for URL targets (`http` or `sse`) | +| Resources | `--resources` | Also list resources | +| Prompts | `--prompts` | Also list prompts | +| Input Schema | `--input-schema` | Show full input schemas | +| Output Schema | `--output-schema` | Show full output schemas | +| JSON | `--json` | Output as JSON | +| Timeout | `--timeout` | Connection timeout in seconds | +| Auth | `--auth` | Auth method: `oauth` (default for HTTP), a bearer token, or `none` to disable | + +### Server Targets + +The `` argument accepts: + +1. **URLs** — `http://` or `https://` endpoints. Uses Streamable HTTP by default; pass `--transport sse` for SSE servers. +2. **Python files** — `.py` files are run via `fastmcp run` automatically. +3. **MCPConfig JSON** — `.json` files with an `mcpServers` key are treated as multi-server configs. +4. **Stdio commands** — Use `--command` to connect to any MCP server via stdio (e.g. `npx`, `uvx`). + +### Examples + +```bash +# List tools on a remote server +fastmcp list http://localhost:8000/mcp + +# List tools from a local Python file +fastmcp list server.py + +# Include full input schemas +fastmcp list server.py --input-schema + +# Machine-readable JSON +fastmcp list server.py --json + +# SSE server +fastmcp list http://localhost:8000/mcp --transport sse + +# Stdio command +fastmcp list --command 'npx -y @modelcontextprotocol/server-github' + +# Include resources and prompts +fastmcp list server.py --resources --prompts +``` + +## `fastmcp call` + +Call a tool on any MCP server. Arguments can be passed as `key=value` pairs, a single JSON object, or via `--input-json`. + +```bash +fastmcp call server.py greet name=World +fastmcp call http://localhost:8000/mcp search query=hello limit=5 +fastmcp call server.py create_item '{"name": "x", "tags": ["a", "b"]}' +``` + +Tool arguments are automatically coerced to the correct type based on the tool's input schema — string values like `limit=5` become integers when the schema expects one. + +### Options + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| Command | `--command` | Connect to a stdio server command (e.g. `'npx -y @mcp/server'`) | +| Transport | `--transport`, `-t` | Force transport type for URL targets (`http` or `sse`) | +| Input JSON | `--input-json` | JSON string of tool arguments (merged with key=value args) | +| JSON | `--json` | Output raw JSON result | +| Timeout | `--timeout` | Connection timeout in seconds | +| Auth | `--auth` | Auth method: `oauth` (default for HTTP), a bearer token, or `none` to disable | + +### Argument Passing + +There are three ways to pass arguments: + +**Key=value pairs** are the simplest for flat arguments. Values are coerced using the tool's JSON schema (strings become ints, bools, etc.): + +```bash +fastmcp call server.py search query=hello limit=5 verbose=true +``` + +**A single JSON object** works when you have structured or nested arguments: + +```bash +fastmcp call server.py create_item '{"name": "Widget", "tags": ["new", "sale"]}' +``` + +**`--input-json`** provides a base dict that key=value pairs can override: + +```bash +fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10 +``` + +### Examples + +```bash +# Call a tool with simple args +fastmcp call server.py greet name=World + +# Call with JSON object +fastmcp call server.py create '{"name": "x", "tags": ["a"]}' + +# Get JSON output for scripting +fastmcp call server.py add a=3 b=4 --json + +# Call a tool on a remote server +fastmcp call http://localhost:8000/mcp search query=hello + +# Call via stdio command +fastmcp call --command 'npx -y @mcp/server' tool_name arg=value + +# Disable OAuth for HTTP targets +fastmcp call http://localhost:8000/mcp search query=hello --auth none +``` + + +If you call a tool that doesn't exist, FastMCP will suggest similar tool names. Use `fastmcp list` to see all available tools on a server. + + ## `fastmcp run` Run a FastMCP server directly or proxy a remote server. diff --git a/examples/elicitation.py b/examples/elicitation.py new file mode 100644 index 000000000..368980ea1 --- /dev/null +++ b/examples/elicitation.py @@ -0,0 +1,47 @@ +""" +FastMCP Elicitation Example + +Demonstrates tools that ask users for input during execution. + +Try it with the CLI: + + fastmcp list examples/elicitation.py + fastmcp call examples/elicitation.py greet + fastmcp call examples/elicitation.py survey +""" + +from dataclasses import dataclass + +from fastmcp import Context, FastMCP + +mcp = FastMCP("Elicitation Demo") + + +@mcp.tool +async def greet(ctx: Context) -> str: + """Greet the user by name (asks for their name).""" + result = await ctx.elicit("What is your name?", response_type=str) + + if result.action == "accept": + return f"Hello, {result.data}!" + return "Maybe next time!" + + +@mcp.tool +async def survey(ctx: Context) -> str: + """Run a short survey collecting structured info.""" + + @dataclass + class SurveyResponse: + favorite_color: str + lucky_number: int + + result = await ctx.elicit( + "Quick survey — tell us about yourself:", + response_type=SurveyResponse, + ) + + if result.action == "accept": + resp = result.data + return f"Got it — you like {resp.favorite_color} and your lucky number is {resp.lucky_number}." + return "Survey skipped." diff --git a/skills/fastmcp-client-cli/SKILL.md b/skills/fastmcp-client-cli/SKILL.md new file mode 100644 index 000000000..9742fa5cb --- /dev/null +++ b/skills/fastmcp-client-cli/SKILL.md @@ -0,0 +1,93 @@ +--- +name: fastmcp-client-cli +description: Query and invoke tools on MCP servers using fastmcp list and fastmcp call. Use when you need to discover what tools a server offers, call tools, or integrate MCP servers into workflows. +--- + +# FastMCP CLI: List and Call + +Use `fastmcp list` and `fastmcp call` to interact with any MCP server from the command line. + +## Listing Tools + +```bash +# Remote server +fastmcp list http://localhost:8000/mcp + +# Local Python file (runs via fastmcp run automatically) +fastmcp list server.py + +# MCPConfig with multiple servers +fastmcp list mcp.json + +# Stdio command (npx, uvx, etc.) +fastmcp list --command 'npx -y @modelcontextprotocol/server-github' + +# Include full input/output schemas +fastmcp list server.py --input-schema --output-schema + +# Machine-readable JSON +fastmcp list server.py --json + +# Include resources and prompts +fastmcp list server.py --resources --prompts +``` + +Default output shows tool signatures and descriptions. Use `--input-schema` or `--output-schema` to include full JSON schemas, `--json` for structured output. + +## Calling Tools + +```bash +# Key=value arguments (auto-coerced to correct types) +fastmcp call server.py greet name=World +fastmcp call server.py add a=3 b=4 + +# Single JSON object for complex/nested args +fastmcp call server.py create_item '{"name": "Widget", "tags": ["a", "b"]}' + +# --input-json with key=value overrides +fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10 + +# JSON output for scripting +fastmcp call server.py add a=3 b=4 --json +``` + +Type coercion is automatic: `limit=5` becomes an integer, `verbose=true` becomes a boolean, based on the tool's input schema. + +## Server Targets + +All commands accept the same server targets: + +| Target | Example | +|--------|---------| +| HTTP/HTTPS URL | `http://localhost:8000/mcp` | +| Python file | `server.py` | +| MCPConfig JSON | `mcp.json` (must have `mcpServers` key) | +| Stdio command | `--command 'npx -y @mcp/server'` | + +For SSE servers, pass `--transport sse`: + +```bash +fastmcp list http://localhost:8000/mcp --transport sse +``` + +## Auth + +HTTP targets automatically use OAuth (no-ops if the server doesn't require auth). Disable with `--auth none`: + +```bash +fastmcp call http://server/mcp tool --auth none +``` + +## Workflow Pattern + +Discover tools first, then call them: + +```bash +# 1. See what's available +fastmcp list server.py + +# 2. Call a tool +fastmcp call server.py tool_name arg=value +``` + +If you call a nonexistent tool, FastMCP suggests close matches. diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index c8a358cea..9eb197660 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -19,6 +19,7 @@ from rich.table import Table import fastmcp from fastmcp.cli import run as run_module +from fastmcp.cli.client import call_command, list_command from fastmcp.cli.install import install_app from fastmcp.cli.tasks import tasks_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config @@ -952,6 +953,10 @@ app.command(install_app) # Add tasks subcommand group app.command(tasks_app) +# Add client query commands +app.command(list_command, name="list") +app.command(call_command, name="call") + if __name__ == "__main__": app() diff --git a/src/fastmcp/cli/client.py b/src/fastmcp/cli/client.py new file mode 100644 index 000000000..e517e18e4 --- /dev/null +++ b/src/fastmcp/cli/client.py @@ -0,0 +1,872 @@ +"""Client-side CLI commands for querying and invoking MCP servers.""" + +import difflib +import json +import os +import shlex +import sys +from pathlib import Path +from typing import Annotated, Any, Literal + +import cyclopts +import mcp.types +from rich.console import Console + +from fastmcp.client.client import CallToolResult, Client +from fastmcp.client.elicitation import ElicitResult +from fastmcp.client.transports.stdio import StdioTransport +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli.client") +console = Console() + + +# --------------------------------------------------------------------------- +# Server spec resolution +# --------------------------------------------------------------------------- + +_JSON_SCHEMA_TYPE_MAP: dict[str, str] = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + "array": "list", + "object": "dict", + "null": "None", +} + + +def resolve_server_spec( + server_spec: str | None, + *, + command: str | None = None, + transport: str | None = None, +) -> str | dict[str, Any] | StdioTransport: + """Turn CLI inputs into something ``Client()`` accepts. + + Exactly one of ``server_spec`` or ``command`` should be provided. + + Resolution order for ``server_spec``: + 1. URLs (``http://``, ``https://``) — passed through as-is. + If ``--transport`` is ``sse``, the URL is rewritten to end with ``/sse`` + so ``infer_transport`` picks the right transport. + 2. Existing file paths, or strings ending in ``.py``/``.js``/``.json``. + 3. Anything else — error with guidance. + + When ``command`` is provided, the string is shell-split into a + ``StdioTransport(command, args)``. + """ + + if command is not None and server_spec is not None: + console.print( + "[bold red]Error:[/bold red] Cannot use both a server spec and --command" + ) + sys.exit(1) + + if command is not None: + return _build_stdio_from_command(command) + + if server_spec is None: + console.print( + "[bold red]Error:[/bold red] Provide a server spec or use --command" + ) + sys.exit(1) + + assert isinstance(server_spec, str) + spec: str = server_spec + + # 1. URL + if spec.startswith(("http://", "https://")): + if transport == "sse" and not spec.rstrip("/").endswith("/sse"): + spec = spec.rstrip("/") + "/sse" + return spec + + # 2. File path (must be a file, not a directory) + path = Path(spec) + is_file = path.is_file() or ( + not path.is_dir() and spec.endswith((".py", ".js", ".json")) + ) + + if is_file: + if spec.endswith(".json"): + return _resolve_json_spec(path) + if spec.endswith(".py"): + # Run via `fastmcp run` so scripts don't need mcp.run() + resolved_path = path.resolve() + return StdioTransport( + command="fastmcp", + args=["run", str(resolved_path), "--no-banner"], + log_file=Path(os.devnull), + ) + # .js — pass through for Client's infer_transport + return spec + + # 3. Unrecognised + console.print( + f"[bold red]Error:[/bold red] Could not resolve server spec: [cyan]{spec}[/cyan]\n\n" + "Expected one of:\n" + " • A URL (e.g. http://localhost:8000/mcp)\n" + " • A Python file (e.g. server.py)\n" + " • An MCPConfig (e.g. mcp.json)\n" + " • --command (e.g. --command 'npx -y @mcp/server')\n" + ) + sys.exit(1) + + +def _build_stdio_from_command(command_str: str) -> StdioTransport: + """Shell-split a command string into a ``StdioTransport``.""" + try: + parts = shlex.split(command_str) + except ValueError as exc: + console.print(f"[bold red]Error:[/bold red] Invalid command: {exc}") + sys.exit(1) + + if not parts: + console.print("[bold red]Error:[/bold red] Empty --command") + sys.exit(1) + + return StdioTransport(command=parts[0], args=parts[1:], log_file=Path(os.devnull)) + + +def _resolve_json_spec(path: Path) -> str | dict[str, Any]: + """Disambiguate a ``.json`` server spec.""" + + if not path.exists(): + console.print( + f"[bold red]Error:[/bold red] File not found: [cyan]{path}[/cyan]" + ) + sys.exit(1) + + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + console.print(f"[bold red]Error:[/bold red] Invalid JSON in {path}: {exc}") + sys.exit(1) + + if isinstance(data, dict) and "mcpServers" in data: + return data + + # Likely a fastmcp.json (MCPServerConfig) — not directly usable as a client target. + console.print( + f"[bold red]Error:[/bold red] [cyan]{path}[/cyan] is a FastMCP server config, not an MCPConfig.\n" + f"Start the server first, then query it:\n\n" + f" fastmcp run {path}\n" + f" fastmcp list http://localhost:8000/mcp\n" + ) + sys.exit(1) + + +def _is_http_target(resolved: str | dict[str, Any] | StdioTransport) -> bool: + """Return True if the resolved target will use an HTTP-based transport. + + MCPConfig dicts are excluded because ``MCPConfigTransport`` manages + individual server transports internally and does not support top-level auth. + """ + if isinstance(resolved, str): + return resolved.startswith(("http://", "https://")) + return False + + +async def _terminal_elicitation_handler( + message: str, + response_type: type[Any] | None, + params: Any, + context: Any, +) -> ElicitResult[dict[str, Any]]: + """Prompt the user on the terminal for elicitation responses. + + Prints the server's message and prompts for each field in the schema. + The user can type 'decline' or 'cancel' instead of a value to abort. + """ + from mcp.types import ElicitRequestFormParams + + console.print(f"\n[bold yellow]Server asks:[/bold yellow] {message}") + + if not isinstance(params, ElicitRequestFormParams): + answer = console.input( + "[dim](press Enter to accept, or type 'decline'):[/dim] " + ) + if answer.strip().lower() == "decline": + return ElicitResult(action="decline") + if answer.strip().lower() == "cancel": + return ElicitResult(action="cancel") + return ElicitResult(action="accept", content={}) + + schema = params.requestedSchema + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + + if not properties: + answer = console.input( + "[dim](press Enter to accept, or type 'decline'):[/dim] " + ) + if answer.strip().lower() == "decline": + return ElicitResult(action="decline") + if answer.strip().lower() == "cancel": + return ElicitResult(action="cancel") + return ElicitResult(action="accept", content={}) + + result: dict[str, Any] = {} + for field_name, field_schema in properties.items(): + type_hint = field_schema.get("type", "string") + req_marker = " [red]*[/red]" if field_name in required else "" + prompt_text = f" [cyan]{field_name}[/cyan] ({type_hint}){req_marker}: " + + raw = console.input(prompt_text) + if raw.strip().lower() == "decline": + return ElicitResult(action="decline") + if raw.strip().lower() == "cancel": + return ElicitResult(action="cancel") + + if raw == "" and field_name not in required: + continue + + result[field_name] = coerce_value(raw, field_schema) + + return ElicitResult(action="accept", content=result) + + +def _build_client( + resolved: str | dict[str, Any] | StdioTransport, + *, + timeout: float | None = None, + auth: str | None = None, +) -> Client: + """Build a ``Client`` from a resolved server spec. + + Applies ``auth='oauth'`` automatically for HTTP-based targets unless + the caller explicitly passes ``--auth none`` to disable it. + + ``auth=None`` means "not specified" (use default), ``auth="none"`` + means "explicitly disabled". + """ + if auth == "none": + effective_auth: str | None = None + elif auth is not None: + effective_auth = auth + elif _is_http_target(resolved): + effective_auth = "oauth" + else: + effective_auth = None + + return Client( + resolved, + timeout=timeout, + auth=effective_auth, + elicitation_handler=_terminal_elicitation_handler, + ) + + +# --------------------------------------------------------------------------- +# Argument coercion +# --------------------------------------------------------------------------- + + +def coerce_value(raw: str, schema: dict[str, Any]) -> Any: + """Coerce a string CLI value according to a JSON-Schema type hint.""" + + schema_type = schema.get("type", "string") + + if schema_type == "integer": + try: + return int(raw) + except ValueError: + raise ValueError(f"Expected integer, got {raw!r}") from None + + if schema_type == "number": + try: + return float(raw) + except ValueError: + raise ValueError(f"Expected number, got {raw!r}") from None + + if schema_type == "boolean": + if raw.lower() in ("true", "1", "yes"): + return True + if raw.lower() in ("false", "0", "no"): + return False + raise ValueError(f"Expected boolean, got {raw!r}") + + if schema_type in ("array", "object"): + try: + return json.loads(raw) + except json.JSONDecodeError: + raise ValueError(f"Expected JSON {schema_type}, got {raw!r}") from None + + # Default: treat as string + return raw + + +def parse_tool_arguments( + raw_args: tuple[str, ...], + input_json: str | None, + input_schema: dict[str, Any], +) -> dict[str, Any]: + """Build a tool-call argument dict from CLI inputs. + + A single JSON object argument is treated as the full argument dict. + ``--input-json`` provides the base dict; ``key=value`` pairs override. + Values are coerced using the tool's ``inputSchema``. + """ + + # A single positional arg that looks like JSON → treat as input-json + if len(raw_args) == 1 and raw_args[0].startswith("{") and input_json is None: + input_json = raw_args[0] + raw_args = () + + result: dict[str, Any] = {} + + if input_json is not None: + try: + parsed = json.loads(input_json) + except json.JSONDecodeError as exc: + console.print(f"[bold red]Error:[/bold red] Invalid --input-json: {exc}") + sys.exit(1) + if not isinstance(parsed, dict): + console.print( + "[bold red]Error:[/bold red] --input-json must be a JSON object" + ) + sys.exit(1) + result.update(parsed) + + properties = input_schema.get("properties", {}) + + for arg in raw_args: + if "=" not in arg: + console.print( + f"[bold red]Error:[/bold red] Invalid argument [cyan]{arg}[/cyan] — expected key=value" + ) + sys.exit(1) + key, value = arg.split("=", 1) + prop_schema = properties.get(key, {}) + try: + result[key] = coerce_value(value, prop_schema) + except ValueError as exc: + console.print( + f"[bold red]Error:[/bold red] Argument [cyan]{key}[/cyan]: {exc}" + ) + sys.exit(1) + + return result + + +# --------------------------------------------------------------------------- +# Tool signature formatting +# --------------------------------------------------------------------------- + + +def _json_schema_type_to_str(schema: dict[str, Any]) -> str: + """Produce a short Python-style type string from a JSON-Schema fragment.""" + + if "anyOf" in schema: + parts = [_json_schema_type_to_str(s) for s in schema["anyOf"]] + return " | ".join(parts) + + schema_type = schema.get("type", "any") + if isinstance(schema_type, list): + return " | ".join(_JSON_SCHEMA_TYPE_MAP.get(t, t) for t in schema_type) + + return _JSON_SCHEMA_TYPE_MAP.get(schema_type, schema_type) + + +def format_tool_signature(tool: mcp.types.Tool) -> str: + """Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas.""" + + params: list[str] = [] + schema = tool.inputSchema + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + + for prop_name, prop_schema in properties.items(): + type_str = _json_schema_type_to_str(prop_schema) + if prop_name in required: + params.append(f"{prop_name}: {type_str}") + else: + default = prop_schema.get("default") + default_repr = repr(default) if default is not None else "..." + params.append(f"{prop_name}: {type_str} = {default_repr}") + + sig = f"{tool.name}({', '.join(params)})" + + if tool.outputSchema: + ret = _json_schema_type_to_str(tool.outputSchema) + sig += f" -> {ret}" + + return sig + + +# --------------------------------------------------------------------------- +# Output formatting +# --------------------------------------------------------------------------- + + +def _print_schema(label: str, schema: dict[str, Any]) -> None: + """Print a JSON schema with a label.""" + properties = schema.get("properties", {}) + if not properties: + return + console.print(f" [dim]{label}: {json.dumps(schema)}[/dim]") + + +def _format_call_result_text(result: CallToolResult) -> None: + """Pretty-print a tool call result to the console.""" + + if result.is_error: + for block in result.content: + if isinstance(block, mcp.types.TextContent): + console.print(f"[bold red]Error:[/bold red] {block.text}") + else: + console.print(f"[bold red]Error:[/bold red] {block}") + return + + if result.structured_content is not None: + console.print_json(json.dumps(result.structured_content)) + return + + for block in result.content: + if isinstance(block, mcp.types.TextContent): + console.print(block.text) + elif isinstance(block, mcp.types.ImageContent): + size = len(block.data) * 3 // 4 # rough decoded size + console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") + elif isinstance(block, mcp.types.AudioContent): + size = len(block.data) * 3 // 4 + console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") + else: + console.print(str(block)) + + +def _content_block_to_dict(block: mcp.types.ContentBlock) -> dict[str, Any]: + """Serialize a single content block to a JSON-safe dict.""" + if isinstance(block, mcp.types.TextContent): + return {"type": "text", "text": block.text} + if isinstance(block, mcp.types.ImageContent): + return {"type": "image", "mimeType": block.mimeType, "data": block.data} + if isinstance(block, mcp.types.AudioContent): + return {"type": "audio", "mimeType": block.mimeType, "data": block.data} + return {"type": "unknown", "value": str(block)} + + +def _call_result_to_dict(result: CallToolResult) -> dict[str, Any]: + """Serialize a ``CallToolResult`` to a JSON-safe dict.""" + + content_list = [_content_block_to_dict(block) for block in result.content] + out: dict[str, Any] = {"content": content_list, "is_error": result.is_error} + if result.structured_content is not None: + out["structured_content"] = result.structured_content + return out + + +def _tools_to_json(tools: list[mcp.types.Tool]) -> list[dict[str, Any]]: + """Serialize a list of tools to JSON-safe dicts.""" + + return [ + { + "name": t.name, + "description": t.description, + "inputSchema": t.inputSchema, + **({"outputSchema": t.outputSchema} if t.outputSchema else {}), + } + for t in tools + ] + + +# --------------------------------------------------------------------------- +# Call handlers (tool, resource, prompt) +# --------------------------------------------------------------------------- + + +async def _handle_tool_call( + client: Client, + tool_name: str, + arguments: tuple[str, ...], + input_json: str | None, + json_output: bool, +) -> None: + """Handle a tool call within an open client session.""" + tools = await client.list_tools() + tool_map = {t.name: t for t in tools} + + if tool_name not in tool_map: + close_matches = difflib.get_close_matches( + tool_name, tool_map.keys(), n=3, cutoff=0.5 + ) + msg = f"Tool [cyan]{tool_name}[/cyan] not found." + if close_matches: + suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches) + msg += f" Did you mean: {suggestions}?" + console.print(f"[bold red]Error:[/bold red] {msg}") + sys.exit(1) + + tool = tool_map[tool_name] + parsed_args = parse_tool_arguments(arguments, input_json, tool.inputSchema) + + required = set(tool.inputSchema.get("required", [])) + provided = set(parsed_args.keys()) + missing = required - provided + if missing: + missing_str = ", ".join(f"[cyan]{m}[/cyan]" for m in sorted(missing)) + console.print( + f"[bold red]Error:[/bold red] Missing required arguments: {missing_str}" + ) + console.print() + sig = format_tool_signature(tool) + console.print(f" [dim]{sig}[/dim]") + sys.exit(1) + + result = await client.call_tool(tool_name, parsed_args, raise_on_error=False) + + if json_output: + console.print_json(json.dumps(_call_result_to_dict(result))) + else: + _format_call_result_text(result) + + if result.is_error: + sys.exit(1) + + +async def _handle_resource( + client: Client, + uri: str, + json_output: bool, +) -> None: + """Handle a resource read within an open client session.""" + contents = await client.read_resource(uri) + + if json_output: + data = [] + for block in contents: + if isinstance(block, mcp.types.TextResourceContents): + data.append( + { + "uri": str(block.uri), + "mimeType": block.mimeType, + "text": block.text, + } + ) + elif isinstance(block, mcp.types.BlobResourceContents): + data.append( + { + "uri": str(block.uri), + "mimeType": block.mimeType, + "blob": block.blob, + } + ) + console.print_json(json.dumps(data)) + return + + for block in contents: + if isinstance(block, mcp.types.TextResourceContents): + console.print(block.text) + elif isinstance(block, mcp.types.BlobResourceContents): + size = len(block.blob) * 3 // 4 + console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") + + +async def _handle_prompt( + client: Client, + prompt_name: str, + arguments: tuple[str, ...], + input_json: str | None, + json_output: bool, +) -> None: + """Handle a prompt get within an open client session.""" + # Prompt arguments are always string->string, but we reuse + # parse_tool_arguments for the key=value / --input-json parsing. + # Pass an empty schema so values stay as strings. + parsed_args = parse_tool_arguments(arguments, input_json, {"type": "object"}) + + prompts = await client.list_prompts() + prompt_map = {p.name: p for p in prompts} + + if prompt_name not in prompt_map: + close_matches = difflib.get_close_matches( + prompt_name, prompt_map.keys(), n=3, cutoff=0.5 + ) + msg = f"Prompt [cyan]{prompt_name}[/cyan] not found." + if close_matches: + suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches) + msg += f" Did you mean: {suggestions}?" + console.print(f"[bold red]Error:[/bold red] {msg}") + sys.exit(1) + + result = await client.get_prompt(prompt_name, parsed_args or None) + + if json_output: + data: dict[str, Any] = {} + if result.description: + data["description"] = result.description + data["messages"] = [ + { + "role": msg.role, + "content": _content_block_to_dict(msg.content), + } + for msg in result.messages + ] + console.print_json(json.dumps(data)) + return + + for msg in result.messages: + console.print(f"[bold]{msg.role}:[/bold]") + if isinstance(msg.content, mcp.types.TextContent): + console.print(f" {msg.content.text}") + elif isinstance(msg.content, mcp.types.ImageContent): + size = len(msg.content.data) * 3 // 4 + console.print( + f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]" + ) + else: + console.print(f" {msg.content}") + console.print() + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +async def list_command( + server_spec: Annotated[ + str | None, + cyclopts.Parameter( + help="Server URL, Python file, MCPConfig JSON, or .js file", + ), + ] = None, + *, + command: Annotated[ + str | None, + cyclopts.Parameter( + "--command", + help="Stdio command to connect to (e.g. 'npx -y @mcp/server')", + ), + ] = None, + transport: Annotated[ + Literal["http", "sse"] | None, + cyclopts.Parameter( + name=["--transport", "-t"], + help="Force transport type for URL targets (http or sse)", + ), + ] = None, + resources: Annotated[ + bool, + cyclopts.Parameter("--resources", help="Also list resources"), + ] = False, + prompts: Annotated[ + bool, + cyclopts.Parameter("--prompts", help="Also list prompts"), + ] = False, + input_schema: Annotated[ + bool, + cyclopts.Parameter("--input-schema", help="Show full input schemas"), + ] = False, + output_schema: Annotated[ + bool, + cyclopts.Parameter("--output-schema", help="Show full output schemas"), + ] = False, + json_output: Annotated[ + bool, + cyclopts.Parameter("--json", help="Output as JSON"), + ] = False, + timeout: Annotated[ + float | None, + cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), + ] = None, + auth: Annotated[ + str | None, + cyclopts.Parameter( + "--auth", + help="Auth method: 'oauth', a bearer token string, or 'none' to disable", + ), + ] = None, +) -> None: + """List tools available on an MCP server. + + Examples: + fastmcp list http://localhost:8000/mcp + fastmcp list server.py + fastmcp list mcp.json --json + fastmcp list --command 'npx -y @mcp/server' --resources + fastmcp list http://server/mcp --transport sse + """ + + resolved = resolve_server_spec(server_spec, command=command, transport=transport) + client = _build_client(resolved, timeout=timeout, auth=auth) + + try: + async with client: + tools = await client.list_tools() + + if json_output: + data: dict[str, Any] = {"tools": _tools_to_json(tools)} + if resources: + res = await client.list_resources() + data["resources"] = [ + { + "uri": str(r.uri), + "name": r.name, + "description": r.description, + "mimeType": r.mimeType, + } + for r in res + ] + if prompts: + prm = await client.list_prompts() + data["prompts"] = [ + { + "name": p.name, + "description": p.description, + "arguments": [a.model_dump() for a in (p.arguments or [])], + } + for p in prm + ] + console.print_json(json.dumps(data)) + return + + # Text output + if not tools: + console.print("[dim]No tools found.[/dim]") + else: + console.print(f"[bold]Tools ({len(tools)})[/bold]") + console.print() + for tool in tools: + sig = format_tool_signature(tool) + console.print(f" [cyan]{sig}[/cyan]") + if tool.description: + console.print(f" {tool.description}") + if input_schema: + _print_schema("Input", tool.inputSchema) + if output_schema and tool.outputSchema: + _print_schema("Output", tool.outputSchema) + console.print() + + if resources: + res = await client.list_resources() + console.print(f"[bold]Resources ({len(res)})[/bold]") + console.print() + if not res: + console.print(" [dim]No resources found.[/dim]") + for r in res: + console.print(f" [cyan]{r.uri}[/cyan]") + desc_parts = [r.name or "", r.description or ""] + desc = " — ".join(p for p in desc_parts if p) + if desc: + console.print(f" {desc}") + console.print() + + if prompts: + prm = await client.list_prompts() + console.print(f"[bold]Prompts ({len(prm)})[/bold]") + console.print() + if not prm: + console.print(" [dim]No prompts found.[/dim]") + for p in prm: + args_str = "" + if p.arguments: + parts = [a.name for a in p.arguments] + args_str = f"({', '.join(parts)})" + console.print(f" [cyan]{p.name}{args_str}[/cyan]") + if p.description: + console.print(f" {p.description}") + console.print() + + except Exception as exc: + console.print(f"[bold red]Error:[/bold red] {exc}") + sys.exit(1) + + +async def call_command( + server_spec: Annotated[ + str | None, + cyclopts.Parameter( + help="Server URL, Python file, MCPConfig JSON, or .js file", + ), + ] = None, + target: Annotated[ + str, + cyclopts.Parameter( + help="Tool name, resource URI, or prompt name (with --prompt)", + ), + ] = "", + *arguments: str, + command: Annotated[ + str | None, + cyclopts.Parameter( + "--command", + help="Stdio command to connect to (e.g. 'npx -y @mcp/server')", + ), + ] = None, + transport: Annotated[ + Literal["http", "sse"] | None, + cyclopts.Parameter( + name=["--transport", "-t"], + help="Force transport type for URL targets (http or sse)", + ), + ] = None, + prompt: Annotated[ + bool, + cyclopts.Parameter("--prompt", help="Treat target as a prompt name"), + ] = False, + input_json: Annotated[ + str | None, + cyclopts.Parameter( + "--input-json", + help="JSON string of arguments (merged with key=value args)", + ), + ] = None, + json_output: Annotated[ + bool, + cyclopts.Parameter("--json", help="Output raw JSON result"), + ] = False, + timeout: Annotated[ + float | None, + cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), + ] = None, + auth: Annotated[ + str | None, + cyclopts.Parameter( + "--auth", + help="Auth method: 'oauth', a bearer token string, or 'none' to disable", + ), + ] = None, +) -> None: + """Call a tool, read a resource, or get a prompt on an MCP server. + + By default the target is treated as a tool name. If the target + contains ``://`` it is treated as a resource URI. Pass ``--prompt`` + to treat it as a prompt name. + + Arguments are passed as key=value pairs. Use --input-json for complex + or nested arguments. + + Examples: + fastmcp call server.py greet name=World + fastmcp call server.py resource://docs/readme + fastmcp call server.py analyze --prompt data='[1,2,3]' + fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}' + """ + + if not target: + console.print( + "[bold red]Error:[/bold red] Missing target.\n\n" + "Usage: fastmcp call [key=value ...]\n\n" + " target can be a tool name, a resource URI, or a prompt name (with --prompt).\n\n" + "Use [cyan]fastmcp list [/cyan] to see available tools." + ) + sys.exit(1) + + resolved = resolve_server_spec(server_spec, command=command, transport=transport) + client = _build_client(resolved, timeout=timeout, auth=auth) + + try: + async with client: + if prompt: + await _handle_prompt(client, target, arguments, input_json, json_output) + elif "://" in target: + await _handle_resource(client, target, json_output) + else: + await _handle_tool_call( + client, target, arguments, input_json, json_output + ) + + except Exception as exc: + console.print(f"[bold red]Error:[/bold red] {exc}") + sys.exit(1) diff --git a/tests/cli/test_client_commands.py b/tests/cli/test_client_commands.py new file mode 100644 index 000000000..1add45ba2 --- /dev/null +++ b/tests/cli/test_client_commands.py @@ -0,0 +1,557 @@ +"""Tests for fastmcp list and fastmcp call CLI commands.""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import mcp.types +import pytest + +from fastmcp import FastMCP +from fastmcp.cli import client as client_module +from fastmcp.cli.client import ( + Client, + _build_client, + _build_stdio_from_command, + _format_call_result_text, + _is_http_target, + call_command, + coerce_value, + format_tool_signature, + list_command, + parse_tool_arguments, + resolve_server_spec, +) +from fastmcp.client.client import CallToolResult +from fastmcp.client.transports.stdio import StdioTransport + +# --------------------------------------------------------------------------- +# coerce_value +# --------------------------------------------------------------------------- + + +class TestCoerceValue: + def test_integer(self): + assert coerce_value("42", {"type": "integer"}) == 42 + + def test_integer_negative(self): + assert coerce_value("-7", {"type": "integer"}) == -7 + + def test_integer_invalid(self): + with pytest.raises(ValueError, match="Expected integer"): + coerce_value("abc", {"type": "integer"}) + + def test_number(self): + assert coerce_value("3.14", {"type": "number"}) == 3.14 + + def test_number_integer_value(self): + assert coerce_value("5", {"type": "number"}) == 5.0 + + def test_number_invalid(self): + with pytest.raises(ValueError, match="Expected number"): + coerce_value("xyz", {"type": "number"}) + + def test_boolean_true_variants(self): + for val in ("true", "True", "TRUE", "1", "yes"): + assert coerce_value(val, {"type": "boolean"}) is True + + def test_boolean_false_variants(self): + for val in ("false", "False", "FALSE", "0", "no"): + assert coerce_value(val, {"type": "boolean"}) is False + + def test_boolean_invalid(self): + with pytest.raises(ValueError, match="Expected boolean"): + coerce_value("maybe", {"type": "boolean"}) + + def test_array(self): + assert coerce_value("[1, 2, 3]", {"type": "array"}) == [1, 2, 3] + + def test_array_invalid(self): + with pytest.raises(ValueError, match="Expected JSON array"): + coerce_value("not-json", {"type": "array"}) + + def test_object(self): + assert coerce_value('{"a": 1}', {"type": "object"}) == {"a": 1} + + def test_string(self): + assert coerce_value("hello", {"type": "string"}) == "hello" + + def test_string_default(self): + """Unknown or missing type treats value as string.""" + assert coerce_value("hello", {}) == "hello" + + def test_string_preserves_numeric_looking_values(self): + assert coerce_value("42", {"type": "string"}) == "42" + + +# --------------------------------------------------------------------------- +# parse_tool_arguments +# --------------------------------------------------------------------------- + + +class TestParseToolArguments: + SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + "verbose": {"type": "boolean"}, + }, + "required": ["query"], + } + + def test_basic_key_value(self): + result = parse_tool_arguments(("query=hello", "limit=10"), None, self.SCHEMA) + assert result == {"query": "hello", "limit": 10} + + def test_input_json_only(self): + result = parse_tool_arguments((), '{"query": "hello", "limit": 5}', self.SCHEMA) + assert result == {"query": "hello", "limit": 5} + + def test_key_value_overrides_input_json(self): + result = parse_tool_arguments( + ("limit=20",), '{"query": "hello", "limit": 5}', self.SCHEMA + ) + assert result == {"query": "hello", "limit": 20} + + def test_value_containing_equals(self): + result = parse_tool_arguments(("query=a=b=c",), None, self.SCHEMA) + assert result == {"query": "a=b=c"} + + def test_invalid_arg_format_exits(self): + with pytest.raises(SystemExit): + parse_tool_arguments(("noequalssign",), None, self.SCHEMA) + + def test_invalid_input_json_exits(self): + with pytest.raises(SystemExit): + parse_tool_arguments((), "not-valid-json", self.SCHEMA) + + def test_input_json_non_object_exits(self): + with pytest.raises(SystemExit): + parse_tool_arguments((), "[1,2,3]", self.SCHEMA) + + def test_single_json_object_as_positional(self): + result = parse_tool_arguments( + ('{"query": "hello", "limit": 5}',), None, self.SCHEMA + ) + assert result == {"query": "hello", "limit": 5} + + def test_json_positional_ignored_when_input_json_set(self): + """When --input-json is already provided, a JSON positional arg is not special.""" + with pytest.raises(SystemExit): + parse_tool_arguments(('{"limit": 99}',), '{"query": "hello"}', self.SCHEMA) + + def test_coercion_error_exits(self): + with pytest.raises(SystemExit): + parse_tool_arguments(("limit=abc",), None, self.SCHEMA) + + +# --------------------------------------------------------------------------- +# format_tool_signature +# --------------------------------------------------------------------------- + + +class TestFormatToolSignature: + def _make_tool( + self, + name: str = "my_tool", + properties: dict[str, Any] | None = None, + required: list[str] | None = None, + output_schema: dict[str, Any] | None = None, + description: str | None = None, + ) -> mcp.types.Tool: + input_schema: dict[str, Any] = {"type": "object"} + if properties is not None: + input_schema["properties"] = properties + if required is not None: + input_schema["required"] = required + return mcp.types.Tool( + name=name, + description=description, + inputSchema=input_schema, + outputSchema=output_schema, + ) + + def test_no_params(self): + tool = self._make_tool() + assert format_tool_signature(tool) == "my_tool()" + + def test_required_param(self): + tool = self._make_tool( + properties={"query": {"type": "string"}}, + required=["query"], + ) + assert format_tool_signature(tool) == "my_tool(query: str)" + + def test_optional_param_with_default(self): + tool = self._make_tool( + properties={"limit": {"type": "integer", "default": 10}}, + ) + assert format_tool_signature(tool) == "my_tool(limit: int = 10)" + + def test_optional_param_without_default(self): + tool = self._make_tool( + properties={"limit": {"type": "integer"}}, + ) + assert format_tool_signature(tool) == "my_tool(limit: int = ...)" + + def test_mixed_required_and_optional(self): + tool = self._make_tool( + properties={ + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 10}, + }, + required=["query"], + ) + sig = format_tool_signature(tool) + assert sig == "my_tool(query: str, limit: int = 10)" + + def test_with_output_schema(self): + tool = self._make_tool( + properties={"q": {"type": "string"}}, + required=["q"], + output_schema={"type": "object"}, + ) + assert format_tool_signature(tool) == "my_tool(q: str) -> dict" + + def test_anyof_type(self): + tool = self._make_tool( + properties={"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, + required=["value"], + ) + assert format_tool_signature(tool) == "my_tool(value: str | int)" + + +# --------------------------------------------------------------------------- +# resolve_server_spec +# --------------------------------------------------------------------------- + + +class TestResolveServerSpec: + def test_http_url(self): + assert ( + resolve_server_spec("http://localhost:8000/mcp") + == "http://localhost:8000/mcp" + ) + + def test_https_url(self): + assert ( + resolve_server_spec("https://example.com/mcp") == "https://example.com/mcp" + ) + + def test_python_file_existing(self, tmp_path: Path): + py_file = tmp_path / "server.py" + py_file.write_text("# empty") + result = resolve_server_spec(str(py_file)) + assert isinstance(result, StdioTransport) + assert result.command == "fastmcp" + assert result.args == ["run", str(py_file.resolve()), "--no-banner"] + + def test_json_mcp_config(self, tmp_path: Path): + config_file = tmp_path / "mcp.json" + config = {"mcpServers": {"test": {"url": "http://localhost:8000"}}} + config_file.write_text(json.dumps(config)) + result = resolve_server_spec(str(config_file)) + assert isinstance(result, dict) + assert "mcpServers" in result + + def test_json_fastmcp_config_exits(self, tmp_path: Path): + config_file = tmp_path / "fastmcp.json" + config_file.write_text(json.dumps({"source": {"type": "file"}})) + with pytest.raises(SystemExit): + resolve_server_spec(str(config_file)) + + def test_json_not_found_exits(self, tmp_path: Path): + with pytest.raises(SystemExit): + resolve_server_spec(str(tmp_path / "nonexistent.json")) + + def test_directory_exits(self, tmp_path: Path): + """Directories should not be treated as file paths.""" + with pytest.raises(SystemExit): + resolve_server_spec(str(tmp_path)) + + def test_unrecognised_exits(self): + with pytest.raises(SystemExit): + resolve_server_spec("some_random_thing") + + def test_command_returns_stdio_transport(self): + result = resolve_server_spec(None, command="npx -y @mcp/server") + assert isinstance(result, StdioTransport) + assert result.command == "npx" + assert result.args == ["-y", "@mcp/server"] + + def test_command_single_word(self): + result = resolve_server_spec(None, command="myserver") + assert isinstance(result, StdioTransport) + assert result.command == "myserver" + assert result.args == [] + + def test_server_spec_and_command_exits(self): + with pytest.raises(SystemExit): + resolve_server_spec("http://localhost:8000", command="npx server") + + def test_neither_server_spec_nor_command_exits(self): + with pytest.raises(SystemExit): + resolve_server_spec(None) + + def test_transport_sse_rewrites_url(self): + result = resolve_server_spec("http://localhost:8000/mcp", transport="sse") + assert result == "http://localhost:8000/mcp/sse" + + def test_transport_sse_no_duplicate_suffix(self): + result = resolve_server_spec("http://localhost:8000/sse", transport="sse") + assert result == "http://localhost:8000/sse" + + def test_transport_sse_trailing_slash(self): + result = resolve_server_spec("http://localhost:8000/mcp/", transport="sse") + assert result == "http://localhost:8000/mcp/sse" + + def test_transport_http_leaves_url_unchanged(self): + result = resolve_server_spec("http://localhost:8000/mcp", transport="http") + assert result == "http://localhost:8000/mcp" + + +# --------------------------------------------------------------------------- +# _build_stdio_from_command +# --------------------------------------------------------------------------- + + +class TestBuildStdioFromCommand: + def test_simple_command(self): + transport = _build_stdio_from_command("uvx my-server") + assert transport.command == "uvx" + assert transport.args == ["my-server"] + + def test_quoted_args(self): + transport = _build_stdio_from_command("npx -y '@scope/server'") + assert transport.command == "npx" + assert transport.args == ["-y", "@scope/server"] + + def test_empty_command_exits(self): + with pytest.raises(SystemExit): + _build_stdio_from_command("") + + def test_invalid_shell_syntax_exits(self): + with pytest.raises(SystemExit): + _build_stdio_from_command("npx 'unterminated") + + +# --------------------------------------------------------------------------- +# _is_http_target +# --------------------------------------------------------------------------- + + +class TestIsHttpTarget: + def test_http_url(self): + assert _is_http_target("http://localhost:8000") is True + + def test_https_url(self): + assert _is_http_target("https://example.com/mcp") is True + + def test_file_path(self): + assert _is_http_target("/path/to/server.py") is False + + def test_stdio_transport(self): + assert _is_http_target(StdioTransport(command="npx", args=[])) is False + + def test_mcp_config_dict(self): + """MCPConfig dicts are not HTTP targets — auth is per-server internally.""" + assert _is_http_target({"mcpServers": {}}) is False + + +# --------------------------------------------------------------------------- +# _build_client +# --------------------------------------------------------------------------- + + +class TestBuildClient: + def test_http_target_gets_oauth_by_default(self): + client = _build_client("http://localhost:8000/mcp") + # OAuth is applied during Client init via _set_auth + assert client.transport.auth is not None + + def test_stdio_target_no_auth(self): + transport = StdioTransport(command="npx", args=["-y", "@mcp/server"]) + client = _build_client(transport) + # Stdio transports don't support auth — no auth should be set + assert not hasattr(client.transport, "auth") or client.transport.auth is None + + def test_explicit_auth_none_disables_oauth(self): + client = _build_client("http://localhost:8000/mcp", auth="none") + # "none" explicitly disables auth, even for HTTP targets + assert client.transport.auth is None + + def test_mcp_config_no_auth(self): + """MCPConfig dicts handle auth per-server; no top-level auth applied.""" + client = _build_client({"mcpServers": {"test": {"url": "http://localhost"}}}) + # MCPConfigTransport doesn't support _set_auth — no crash means success + assert client.transport is not None + + +# --------------------------------------------------------------------------- +# Integration tests — invoke actual CLI commands via monkeypatched _build_client +# --------------------------------------------------------------------------- + + +def _build_test_server() -> FastMCP: + """Create a minimal FastMCP server for integration tests.""" + server = FastMCP("TestServer") + + @server.tool + def greet(name: str) -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + @server.tool + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + @server.resource("test://greeting") + def greeting_resource() -> str: + """A static greeting resource.""" + return "Hello from resource!" + + @server.prompt + def ask(topic: str) -> str: + """Ask about a topic.""" + return f"Tell me about {topic}" + + return server + + +@pytest.fixture() +def _patch_client(): + """Patch resolve_server_spec and _build_client so CLI commands use the + in-process test server without needing a real transport.""" + server = _build_test_server() + + def fake_resolve(server_spec: Any, **kwargs: Any) -> str: + return "fake" + + def fake_build_client(resolved: Any, **kwargs: Any) -> Client: + return Client(server) + + with ( + patch.object(client_module, "resolve_server_spec", side_effect=fake_resolve), + patch.object(client_module, "_build_client", side_effect=fake_build_client), + ): + yield + + +class TestListCommandCLI: + @pytest.mark.usefixtures("_patch_client") + async def test_list_tools(self, capsys: pytest.CaptureFixture[str]): + await list_command("fake://server") + captured = capsys.readouterr() + assert "greet" in captured.out + assert "add" in captured.out + + @pytest.mark.usefixtures("_patch_client") + async def test_list_json(self, capsys: pytest.CaptureFixture[str]): + await list_command("fake://server", json_output=True) + captured = capsys.readouterr() + data = json.loads(captured.out) + names = {t["name"] for t in data["tools"]} + assert "greet" in names + assert "add" in names + + @pytest.mark.usefixtures("_patch_client") + async def test_list_resources(self, capsys: pytest.CaptureFixture[str]): + await list_command("fake://server", resources=True) + captured = capsys.readouterr() + assert "test://greeting" in captured.out + + @pytest.mark.usefixtures("_patch_client") + async def test_list_prompts(self, capsys: pytest.CaptureFixture[str]): + await list_command("fake://server", prompts=True) + captured = capsys.readouterr() + assert "ask" in captured.out + + +class TestCallCommandCLI: + @pytest.mark.usefixtures("_patch_client") + async def test_call_tool(self, capsys: pytest.CaptureFixture[str]): + await call_command("fake://server", "greet", "name=World") + captured = capsys.readouterr() + assert "Hello, World!" in captured.out + + @pytest.mark.usefixtures("_patch_client") + async def test_call_tool_json(self, capsys: pytest.CaptureFixture[str]): + await call_command("fake://server", "greet", "name=World", json_output=True) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["is_error"] is False + + @pytest.mark.usefixtures("_patch_client") + async def test_call_tool_not_found(self): + with pytest.raises(SystemExit): + await call_command("fake://server", "nonexistent") + + @pytest.mark.usefixtures("_patch_client") + async def test_call_tool_missing_args(self): + with pytest.raises(SystemExit): + await call_command("fake://server", "greet") + + @pytest.mark.usefixtures("_patch_client") + async def test_call_resource_by_uri(self, capsys: pytest.CaptureFixture[str]): + await call_command("fake://server", "test://greeting") + captured = capsys.readouterr() + assert "Hello from resource!" in captured.out + + @pytest.mark.usefixtures("_patch_client") + async def test_call_resource_json(self, capsys: pytest.CaptureFixture[str]): + await call_command("fake://server", "test://greeting", json_output=True) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert isinstance(data, list) + assert data[0]["text"] == "Hello from resource!" + + @pytest.mark.usefixtures("_patch_client") + async def test_call_prompt(self, capsys: pytest.CaptureFixture[str]): + await call_command("fake://server", "ask", "topic=Python", prompt=True) + captured = capsys.readouterr() + assert "Python" in captured.out + + @pytest.mark.usefixtures("_patch_client") + async def test_call_prompt_json(self, capsys: pytest.CaptureFixture[str]): + await call_command( + "fake://server", "ask", "topic=Python", prompt=True, json_output=True + ) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "messages" in data + + @pytest.mark.usefixtures("_patch_client") + async def test_call_prompt_not_found(self): + with pytest.raises(SystemExit): + await call_command("fake://server", "nonexistent", prompt=True) + + async def test_call_missing_target(self): + with pytest.raises(SystemExit): + await call_command("fake://server", "") + + +# --------------------------------------------------------------------------- +# Structured content serialization +# --------------------------------------------------------------------------- + + +class TestFormatCallResult: + def test_structured_content_uses_dict_not_data( + self, capsys: pytest.CaptureFixture[str] + ): + """structured_content (raw dict) is used for display, not data (which may + be a non-serializable dataclass).""" + result = CallToolResult( + content=[mcp.types.TextContent(type="text", text="ok")], + structured_content={"key": "value"}, + meta=None, + data=object(), # non-serializable on purpose + is_error=False, + ) + # Should not raise — uses structured_content, not data + _format_call_result_text(result) + captured = capsys.readouterr() + assert "value" in captured.out From adf21ac630a540ad3e97c8a72d5d3f5a23366483 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:27:22 -0500 Subject: [PATCH 04/63] Add `fastmcp discover` and name-based server resolution (#3055) --- docs/clients/cli.mdx | 35 ++ docs/development/v3-notes/v3-features.mdx | 22 + pyproject.toml | 1 + skills/fastmcp-client-cli/SKILL.md | 29 +- src/fastmcp/cli/cli.py | 3 +- src/fastmcp/cli/client.py | 122 +++- src/fastmcp/cli/discovery.py | 375 ++++++++++++ tests/cli/test_discovery.py | 668 ++++++++++++++++++++++ uv.lock | 2 + 9 files changed, 1237 insertions(+), 20 deletions(-) create mode 100644 src/fastmcp/cli/discovery.py create mode 100644 tests/cli/test_discovery.py diff --git a/docs/clients/cli.mdx b/docs/clients/cli.mdx index b12996631..801c7632c 100644 --- a/docs/clients/cli.mdx +++ b/docs/clients/cli.mdx @@ -31,6 +31,41 @@ For servers that communicate over stdio (common with Node.js-based MCP servers), fastmcp list --command 'npx -y @modelcontextprotocol/server-github' ``` +### Name-Based Resolution + +If your MCP servers are already configured in an editor or tool, you can refer to them by name instead of spelling out URLs or file paths. The CLI scans config files from Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose, and matches the name you provide. + +```bash +fastmcp list weather +fastmcp call weather get_forecast city=London +``` + +You can also use the `source:name` form to target a specific source directly, which is useful when the same server name appears in multiple configs or when you want to be explicit about which config you mean. + +```bash +fastmcp list claude-code:my-server +fastmcp call cursor:weather get_forecast city=London +``` + +The available source names are `claude-desktop`, `claude-code`, `cursor`, `gemini`, `goose`, and `project` (for `./mcp.json`). Run `fastmcp discover` to see what's available. + +## Discovering Configured Servers + +`fastmcp discover` scans your local editor and project configurations for MCP server definitions. It checks Claude Desktop, Claude Code (`~/.claude.json`), Cursor workspace configs (walking up from the current directory), Gemini CLI (`~/.gemini/settings.json`), Goose (`~/.config/goose/config.yaml`), and `mcp.json` in the current directory. + +```bash +fastmcp discover +``` + +The output groups servers by source, showing each server's name and transport. Use `--source` to filter to specific sources, and `--json` for machine-readable output. + +```bash +fastmcp discover --source claude-code +fastmcp discover --source cursor --source gemini --json +``` + +Any server that appears here can be used by name (or `source:name`) with `fastmcp list` and `fastmcp call`, which means you can go from "I have a server configured in Claude Code" to querying it without copying any URLs or paths. + ## Discovering Tools `fastmcp list` connects to a server and prints every tool it exposes. The default output is compact: each tool appears as a function signature with its parameter names, types, and a description. diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 45a9e647a..2fa032e6e 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -33,6 +33,28 @@ Key features: Documentation: [Client CLI](/clients/cli) +### CLI: `fastmcp discover` and name-based resolution + +`fastmcp discover` scans editor configs (Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose) and project-level `mcp.json` files for MCP server definitions. Discovered servers can be referenced by name — or `source:name` for precision — in `fastmcp list` and `fastmcp call`. + +```bash +# See all configured servers +fastmcp discover + +# Use a server by name +fastmcp list weather +fastmcp call weather get_forecast city=London + +# Target a specific source with source:name +fastmcp list claude-code:my-server +fastmcp call cursor:weather get_forecast city=London + +# Filter discovery to specific sources +fastmcp discover --source claude-code --source cursor +``` + +Documentation: [Client CLI](/clients/cli) + ### CLI: Expanded Reload File Watching The `--reload` flag now watches a comprehensive set of file types, making it suitable for MCP apps with frontend bundles ([#3028](https://github.com/jlowin/fastmcp/pull/3028)). Previously limited to `.py` files, it now watches JavaScript, TypeScript, HTML, CSS, config files, and media assets. diff --git a/pyproject.toml b/pyproject.toml index 3003da059..da38224a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "cyclopts>=4.0.0", "authlib>=1.6.5", "pydantic[email]>=2.11.7", + "pyyaml>=6.0,<7.0", "pyperclip>=1.9.0", "py-key-value-aio[disk,keyring,memory]>=0.3.0,<0.4.0", "uvicorn>=0.35", diff --git a/skills/fastmcp-client-cli/SKILL.md b/skills/fastmcp-client-cli/SKILL.md index 9742fa5cb..ae66a5e31 100644 --- a/skills/fastmcp-client-cli/SKILL.md +++ b/skills/fastmcp-client-cli/SKILL.md @@ -63,6 +63,9 @@ All commands accept the same server targets: | Python file | `server.py` | | MCPConfig JSON | `mcp.json` (must have `mcpServers` key) | | Stdio command | `--command 'npx -y @mcp/server'` | +| Discovered name | `weather` or `source:name` | + +Servers configured in editor configs (Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose) or project-level `mcp.json` can be referenced by name. Use `source:name` (e.g. `claude-code:my-server`, `cursor:weather`) to target a specific source. Run `fastmcp discover` to see available names. For SSE servers, pass `--transport sse`: @@ -78,16 +81,34 @@ HTTP targets automatically use OAuth (no-ops if the server doesn't require auth) fastmcp call http://server/mcp tool --auth none ``` +## Discovering Configured Servers + +```bash +# See all MCP servers in editor/project configs +fastmcp discover + +# Filter by source +fastmcp discover --source claude-code + +# JSON output +fastmcp discover --json +``` + +Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and `./mcp.json`. Sources: `claude-desktop`, `claude-code`, `cursor`, `gemini`, `goose`, `project`. + ## Workflow Pattern Discover tools first, then call them: ```bash -# 1. See what's available -fastmcp list server.py +# 1. See what servers are configured +fastmcp discover -# 2. Call a tool -fastmcp call server.py tool_name arg=value +# 2. See what tools a server has +fastmcp list weather + +# 3. Call a tool +fastmcp call weather get_forecast city=London ``` If you call a nonexistent tool, FastMCP suggests close matches. diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 9eb197660..85b9d9ff5 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -19,7 +19,7 @@ from rich.table import Table import fastmcp from fastmcp.cli import run as run_module -from fastmcp.cli.client import call_command, list_command +from fastmcp.cli.client import call_command, discover_command, list_command from fastmcp.cli.install import install_app from fastmcp.cli.tasks import tasks_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config @@ -956,6 +956,7 @@ app.command(tasks_app) # Add client query commands app.command(list_command, name="list") app.command(call_command, name="call") +app.command(discover_command, name="discover") if __name__ == "__main__": diff --git a/src/fastmcp/cli/client.py b/src/fastmcp/cli/client.py index e517e18e4..d0d070d98 100644 --- a/src/fastmcp/cli/client.py +++ b/src/fastmcp/cli/client.py @@ -12,8 +12,12 @@ import cyclopts import mcp.types from rich.console import Console +from fastmcp.cli.discovery import DiscoveredServer, discover_servers, resolve_name from fastmcp.client.client import CallToolResult, Client from fastmcp.client.elicitation import ElicitResult +from fastmcp.client.transports.base import ClientTransport +from fastmcp.client.transports.http import StreamableHttpTransport +from fastmcp.client.transports.sse import SSETransport from fastmcp.client.transports.stdio import StdioTransport from fastmcp.utilities.logging import get_logger @@ -41,7 +45,7 @@ def resolve_server_spec( *, command: str | None = None, transport: str | None = None, -) -> str | dict[str, Any] | StdioTransport: +) -> str | dict[str, Any] | ClientTransport: """Turn CLI inputs into something ``Client()`` accepts. Exactly one of ``server_spec`` or ``command`` should be provided. @@ -51,7 +55,7 @@ def resolve_server_spec( If ``--transport`` is ``sse``, the URL is rewritten to end with ``/sse`` so ``infer_transport`` picks the right transport. 2. Existing file paths, or strings ending in ``.py``/``.js``/``.json``. - 3. Anything else — error with guidance. + 3. Anything else — name-based resolution via ``resolve_name``. When ``command`` is provided, the string is shell-split into a ``StdioTransport(command, args)``. @@ -101,16 +105,12 @@ def resolve_server_spec( # .js — pass through for Client's infer_transport return spec - # 3. Unrecognised - console.print( - f"[bold red]Error:[/bold red] Could not resolve server spec: [cyan]{spec}[/cyan]\n\n" - "Expected one of:\n" - " • A URL (e.g. http://localhost:8000/mcp)\n" - " • A Python file (e.g. server.py)\n" - " • An MCPConfig (e.g. mcp.json)\n" - " • --command (e.g. --command 'npx -y @mcp/server')\n" - ) - sys.exit(1) + # 3. Name-based resolution (bare name or source:name) + try: + return resolve_name(spec) + except ValueError as exc: + console.print(f"[bold red]Error:[/bold red] {exc}") + sys.exit(1) def _build_stdio_from_command(command_str: str) -> StdioTransport: @@ -156,7 +156,7 @@ def _resolve_json_spec(path: Path) -> str | dict[str, Any]: sys.exit(1) -def _is_http_target(resolved: str | dict[str, Any] | StdioTransport) -> bool: +def _is_http_target(resolved: str | dict[str, Any] | ClientTransport) -> bool: """Return True if the resolved target will use an HTTP-based transport. MCPConfig dicts are excluded because ``MCPConfigTransport`` manages @@ -164,7 +164,7 @@ def _is_http_target(resolved: str | dict[str, Any] | StdioTransport) -> bool: """ if isinstance(resolved, str): return resolved.startswith(("http://", "https://")) - return False + return isinstance(resolved, (StreamableHttpTransport, SSETransport)) async def _terminal_elicitation_handler( @@ -227,7 +227,7 @@ async def _terminal_elicitation_handler( def _build_client( - resolved: str | dict[str, Any] | StdioTransport, + resolved: str | dict[str, Any] | ClientTransport, *, timeout: float | None = None, auth: str | None = None, @@ -870,3 +870,95 @@ async def call_command( except Exception as exc: console.print(f"[bold red]Error:[/bold red] {exc}") sys.exit(1) + + +async def discover_command( + *, + source: Annotated[ + list[str] | None, + cyclopts.Parameter( + "--source", + help="Only show servers from these sources (e.g. claude-code, cursor, gemini)", + ), + ] = None, + json_output: Annotated[ + bool, + cyclopts.Parameter("--json", help="Output as JSON"), + ] = False, +) -> None: + """Discover MCP servers configured in editor and project configs. + + Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and + project-level mcp.json files for MCP server definitions. + + Discovered server names can be used directly with ``fastmcp list`` + and ``fastmcp call`` instead of specifying a URL or file path. + + Examples: + fastmcp discover + fastmcp discover --source claude-code + fastmcp discover --source cursor --source gemini --json + fastmcp list weather + fastmcp call cursor:weather get_forecast city=London + """ + + servers = discover_servers() + + if source: + servers = [s for s in servers if s.source in source] + + if json_output: + data: list[dict[str, Any]] = [ + { + "name": s.name, + "source": s.source, + "qualified_name": s.qualified_name, + "transport_summary": s.transport_summary, + "config_path": str(s.config_path), + } + for s in servers + ] + console.print_json(json.dumps(data)) + return + + if not servers: + console.print("[dim]No MCP servers found.[/dim]") + console.print() + console.print("Searched:") + console.print(" • Claude Desktop config") + console.print(" • ~/.claude.json (Claude Code)") + console.print(" • .cursor/mcp.json (walked up from cwd)") + console.print(" • ~/.gemini/settings.json (Gemini CLI)") + console.print(" • ~/.config/goose/config.yaml (Goose)") + console.print(" • ./mcp.json") + return + + from rich.table import Table + + # Group by source + by_source: dict[str, list[DiscoveredServer]] = {} + for s in servers: + by_source.setdefault(s.source, []).append(s) + + for source_name, group in by_source.items(): + console.print() + console.print(f"[bold]Source:[/bold] {source_name}") + console.print(f"[bold]Config:[/bold] [dim]{group[0].config_path}[/dim]") + console.print() + + table = Table( + show_header=True, + header_style="bold", + show_edge=False, + pad_edge=False, + box=None, + padding=(0, 2), + ) + table.add_column("Server", style="cyan") + table.add_column("Transport", style="dim") + + for s in group: + table.add_row(s.name, s.transport_summary) + + console.print(table) + console.print() diff --git a/src/fastmcp/cli/discovery.py b/src/fastmcp/cli/discovery.py new file mode 100644 index 000000000..5acd42d61 --- /dev/null +++ b/src/fastmcp/cli/discovery.py @@ -0,0 +1,375 @@ +"""Discover MCP servers configured in editor config files. + +Scans filesystem-readable config files from editors like Claude Desktop, +Claude Code, Cursor, Gemini CLI, and Goose, as well as project-level +``mcp.json`` files. Each discovered server can be resolved by name +(or ``source:name``) so the CLI can connect without requiring a URL +or file path. +""" + +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from fastmcp.client.transports.base import ClientTransport +from fastmcp.mcp_config import ( + MCPConfig, + MCPServerTypes, + RemoteMCPServer, + StdioMCPServer, +) +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli.discovery") + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DiscoveredServer: + """A single MCP server found in an editor or project config.""" + + name: str + source: str + config: MCPServerTypes + config_path: Path + + @property + def qualified_name(self) -> str: + """Fully qualified ``source:name`` identifier.""" + return f"{self.source}:{self.name}" + + @property + def transport_summary(self) -> str: + """Human-readable one-liner describing the transport.""" + cfg = self.config + if isinstance(cfg, StdioMCPServer): + parts = [cfg.command, *cfg.args] + return f"stdio: {' '.join(parts)}" + if isinstance(cfg, RemoteMCPServer): + transport = cfg.transport or "http" + return f"{transport}: {cfg.url}" + return str(type(cfg).__name__) + + +# --------------------------------------------------------------------------- +# Scanners — one per config source +# --------------------------------------------------------------------------- + + +def _normalize_server_entry(entry: dict[str, Any]) -> dict[str, Any]: + """Normalize editor-specific server config fields to MCPConfig format. + + Handles two known differences: + - Claude Code uses ``type`` where MCPConfig uses ``transport`` for + remote servers. + - Gemini CLI uses ``httpUrl`` where MCPConfig uses ``url``. + """ + # Gemini: httpUrl → url + if "httpUrl" in entry and "url" not in entry: + entry = {**entry, "url": entry["httpUrl"]} + del entry["httpUrl"] + + # Claude Code / others: type → transport (for url-based entries only) + if "url" in entry and "type" in entry and "transport" not in entry: + transport = entry["type"] + entry = {k: v for k, v in entry.items() if k != "type"} + entry["transport"] = transport + + return entry + + +def _parse_mcp_servers( + servers_dict: dict[str, Any], + *, + source: str, + config_path: Path, +) -> list[DiscoveredServer]: + """Parse an ``mcpServers``-style dict into discovered servers.""" + if not servers_dict: + return [] + + normalized = { + name: _normalize_server_entry(entry) + for name, entry in servers_dict.items() + if isinstance(entry, dict) + } + + try: + config = MCPConfig.from_dict({"mcpServers": normalized}) + except Exception as exc: + logger.warning("Could not parse MCP servers from %s: %s", config_path, exc) + return [] + + return [ + DiscoveredServer( + name=name, source=source, config=server, config_path=config_path + ) + for name, server in config.mcpServers.items() + ] + + +def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]: + """Parse an mcpServers-style JSON file into discovered servers.""" + try: + text = path.read_text() + except OSError as exc: + logger.debug("Could not read %s: %s", path, exc) + return [] + + try: + data: dict[str, Any] = json.loads(text) + except json.JSONDecodeError as exc: + logger.warning("Invalid JSON in %s: %s", path, exc) + return [] + + if not isinstance(data, dict) or "mcpServers" not in data: + return [] + + return _parse_mcp_servers(data["mcpServers"], source=source, config_path=path) + + +def _scan_claude_desktop() -> list[DiscoveredServer]: + """Scan the Claude Desktop config file.""" + if sys.platform == "win32": + config_dir = Path(Path.home(), "AppData", "Roaming", "Claude") + elif sys.platform == "darwin": + config_dir = Path(Path.home(), "Library", "Application Support", "Claude") + elif sys.platform.startswith("linux"): + config_dir = Path( + os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" + ) + else: + return [] + + path = config_dir / "claude_desktop_config.json" + return _parse_mcp_config(path, "claude-desktop") + + +def _scan_claude_code(start_dir: Path) -> list[DiscoveredServer]: + """Scan ``~/.claude.json`` for global and project-scoped MCP servers.""" + path = Path.home() / ".claude.json" + try: + text = path.read_text() + except OSError: + return [] + + try: + data: dict[str, Any] = json.loads(text) + except json.JSONDecodeError as exc: + logger.warning("Invalid JSON in %s: %s", path, exc) + return [] + + if not isinstance(data, dict): + return [] + + results: list[DiscoveredServer] = [] + + # Global servers + if global_servers := data.get("mcpServers"): + if isinstance(global_servers, dict): + results.extend( + _parse_mcp_servers( + global_servers, source="claude-code", config_path=path + ) + ) + + # Project-scoped servers matching start_dir + resolved_dir = str(start_dir.resolve()) + projects = data.get("projects", {}) + if isinstance(projects, dict): + project_data = projects.get(resolved_dir, {}) + if isinstance(project_data, dict): + if project_servers := project_data.get("mcpServers"): + if isinstance(project_servers, dict): + results.extend( + _parse_mcp_servers( + project_servers, + source="claude-code", + config_path=path, + ) + ) + + return results + + +def _scan_cursor_workspace(start_dir: Path) -> list[DiscoveredServer]: + """Walk up from *start_dir* looking for ``.cursor/mcp.json``.""" + current = start_dir.resolve() + home = Path.home().resolve() + + while True: + candidate = current / ".cursor" / "mcp.json" + if candidate.is_file(): + return _parse_mcp_config(candidate, "cursor") + + parent = current.parent + # Stop at filesystem root or home directory + if parent == current or current == home: + break + current = parent + + return [] + + +def _scan_project_mcp_json(start_dir: Path) -> list[DiscoveredServer]: + """Check for ``mcp.json`` in *start_dir*.""" + candidate = start_dir.resolve() / "mcp.json" + if candidate.is_file(): + return _parse_mcp_config(candidate, "project") + return [] + + +def _scan_gemini(start_dir: Path) -> list[DiscoveredServer]: + """Scan Gemini CLI settings for MCP servers. + + Checks both user-level ``~/.gemini/settings.json`` and project-level + ``.gemini/settings.json``. + """ + results: list[DiscoveredServer] = [] + + # User-level + user_path = Path.home() / ".gemini" / "settings.json" + results.extend(_parse_mcp_config(user_path, "gemini")) + + # Project-level + project_path = start_dir.resolve() / ".gemini" / "settings.json" + if project_path != user_path: + results.extend(_parse_mcp_config(project_path, "gemini")) + + return results + + +def _scan_goose() -> list[DiscoveredServer]: + """Scan Goose config for MCP server extensions. + + Goose uses YAML (``~/.config/goose/config.yaml``) with a different + schema — MCP servers are defined as ``extensions`` with ``type: stdio``. + """ + if sys.platform == "win32": + config_dir = Path( + os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"), + "Block", + "goose", + "config", + ) + else: + config_dir = Path( + os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), + "goose", + ) + + path = config_dir / "config.yaml" + try: + text = path.read_text() + except OSError: + return [] + + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + logger.warning("Invalid YAML in %s: %s", path, exc) + return [] + + if not isinstance(data, dict): + return [] + + extensions = data.get("extensions", {}) + if not isinstance(extensions, dict): + return [] + + # Convert Goose extensions to mcpServers format + servers: dict[str, Any] = {} + for name, ext in extensions.items(): + if not isinstance(ext, dict): + continue + if not ext.get("enabled", True): + continue + ext_type = ext.get("type", "") + if ext_type == "stdio" and "cmd" in ext: + servers[name] = { + "command": ext["cmd"], + "args": ext.get("args", []), + "env": ext.get("envs", {}), + } + elif ext_type == "sse" and "uri" in ext: + servers[name] = {"url": ext["uri"], "transport": "sse"} + + return _parse_mcp_servers(servers, source="goose", config_path=path) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def discover_servers(start_dir: Path | None = None) -> list[DiscoveredServer]: + """Run all scanners and return the combined results. + + Duplicate names across sources are preserved — callers can + use :pyattr:`DiscoveredServer.qualified_name` to disambiguate. + """ + cwd = start_dir or Path.cwd() + results: list[DiscoveredServer] = [] + results.extend(_scan_claude_desktop()) + results.extend(_scan_claude_code(cwd)) + results.extend(_scan_cursor_workspace(cwd)) + results.extend(_scan_gemini(cwd)) + results.extend(_scan_goose()) + results.extend(_scan_project_mcp_json(cwd)) + return results + + +def resolve_name(name: str, start_dir: Path | None = None) -> ClientTransport: + """Resolve a server name (or ``source:name``) to a transport. + + Raises :class:`ValueError` when the name is not found or is ambiguous. + """ + servers = discover_servers(start_dir) + + # Qualified form: "cursor:weather" + if ":" in name: + source, server_name = name.split(":", 1) + matches = [s for s in servers if s.source == source and s.name == server_name] + if not matches: + raise ValueError( + f"No server named '{server_name}' found in source '{source}'." + ) + return matches[0].config.to_transport() + + # Bare name: "weather" + matches = [s for s in servers if s.name == name] + + if not matches: + if servers: + available = ", ".join(sorted({s.name for s in servers})) + raise ValueError(f"No server named '{name}' found. Available: {available}") + locations = [ + "Claude Desktop config", + "~/.claude.json (Claude Code)", + ".cursor/mcp.json (walked up from cwd)", + "~/.gemini/settings.json (Gemini CLI)", + "~/.config/goose/config.yaml (Goose)", + "./mcp.json", + ] + raise ValueError( + f"No server named '{name}' found. Searched: {', '.join(locations)}" + ) + + if len(matches) == 1: + return matches[0].config.to_transport() + + # Ambiguous — list qualified alternatives + alternatives = ", ".join(f"'{m.qualified_name}'" for m in matches) + raise ValueError( + f"Ambiguous server name '{name}' — found in multiple sources. " + f"Use a qualified name: {alternatives}" + ) diff --git a/tests/cli/test_discovery.py b/tests/cli/test_discovery.py new file mode 100644 index 000000000..716694353 --- /dev/null +++ b/tests/cli/test_discovery.py @@ -0,0 +1,668 @@ +"""Tests for MCP server discovery and name-based resolution.""" + +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from fastmcp.cli.client import _is_http_target, resolve_server_spec +from fastmcp.cli.discovery import ( + DiscoveredServer, + _normalize_server_entry, + _parse_mcp_config, + _scan_claude_code, + _scan_claude_desktop, + _scan_cursor_workspace, + _scan_gemini, + _scan_goose, + _scan_project_mcp_json, + discover_servers, + resolve_name, +) +from fastmcp.client.transports.http import StreamableHttpTransport +from fastmcp.client.transports.sse import SSETransport +from fastmcp.client.transports.stdio import StdioTransport +from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_STDIO_CONFIG: dict[str, Any] = { + "mcpServers": { + "weather": { + "command": "npx", + "args": ["-y", "@mcp/weather"], + }, + "github": { + "command": "npx", + "args": ["-y", "@mcp/github"], + "env": {"GITHUB_TOKEN": "xxx"}, + }, + } +} + +_REMOTE_CONFIG: dict[str, Any] = { + "mcpServers": { + "api": { + "url": "http://localhost:8000/mcp", + }, + } +} + + +def _write_config(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data)) + + +# --------------------------------------------------------------------------- +# DiscoveredServer properties +# --------------------------------------------------------------------------- + + +class TestDiscoveredServer: + def test_qualified_name(self): + server = DiscoveredServer( + name="weather", + source="claude-desktop", + config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]), + config_path=Path("/fake/config.json"), + ) + assert server.qualified_name == "claude-desktop:weather" + + def test_transport_summary_stdio(self): + server = DiscoveredServer( + name="weather", + source="cursor", + config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]), + config_path=Path("/fake/config.json"), + ) + assert server.transport_summary == "stdio: npx -y @mcp/weather" + + def test_transport_summary_remote(self): + server = DiscoveredServer( + name="api", + source="project", + config=RemoteMCPServer(url="http://localhost:8000/mcp"), + config_path=Path("/fake/config.json"), + ) + assert server.transport_summary == "http: http://localhost:8000/mcp" + + def test_transport_summary_remote_sse(self): + server = DiscoveredServer( + name="api", + source="project", + config=RemoteMCPServer(url="http://localhost:8000/sse", transport="sse"), + config_path=Path("/fake/config.json"), + ) + assert server.transport_summary == "sse: http://localhost:8000/sse" + + +# --------------------------------------------------------------------------- +# _parse_mcp_config +# --------------------------------------------------------------------------- + + +class TestParseMcpConfig: + def test_valid_config(self, tmp_path: Path): + path = tmp_path / "config.json" + _write_config(path, _STDIO_CONFIG) + servers = _parse_mcp_config(path, "test-source") + assert len(servers) == 2 + names = {s.name for s in servers} + assert names == {"weather", "github"} + assert all(s.source == "test-source" for s in servers) + assert all(s.config_path == path for s in servers) + + def test_missing_file(self, tmp_path: Path): + path = tmp_path / "nonexistent.json" + servers = _parse_mcp_config(path, "test") + assert servers == [] + + def test_invalid_json(self, tmp_path: Path): + path = tmp_path / "bad.json" + path.write_text("{not json") + servers = _parse_mcp_config(path, "test") + assert servers == [] + + def test_no_mcp_servers_key(self, tmp_path: Path): + path = tmp_path / "config.json" + _write_config(path, {"something": "else"}) + servers = _parse_mcp_config(path, "test") + assert servers == [] + + def test_empty_mcp_servers(self, tmp_path: Path): + path = tmp_path / "config.json" + _write_config(path, {"mcpServers": {}}) + servers = _parse_mcp_config(path, "test") + assert servers == [] + + def test_remote_server(self, tmp_path: Path): + path = tmp_path / "config.json" + _write_config(path, _REMOTE_CONFIG) + servers = _parse_mcp_config(path, "test") + assert len(servers) == 1 + assert isinstance(servers[0].config, RemoteMCPServer) + assert servers[0].config.url == "http://localhost:8000/mcp" + + +# --------------------------------------------------------------------------- +# Scanner: Claude Desktop +# --------------------------------------------------------------------------- + + +class TestScanClaudeDesktop: + def test_finds_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + config_dir = tmp_path / "Claude" + config_path = config_dir / "claude_desktop_config.json" + _write_config(config_path, _STDIO_CONFIG) + + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + # Force darwin for deterministic path + monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin") + + # We need to override the path construction. On macOS it's + # ~/Library/Application Support/Claude — create that. + mac_dir = tmp_path / "Library" / "Application Support" / "Claude" + mac_path = mac_dir / "claude_desktop_config.json" + _write_config(mac_path, _STDIO_CONFIG) + + servers = _scan_claude_desktop() + assert len(servers) == 2 + assert all(s.source == "claude-desktop" for s in servers) + + def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin") + servers = _scan_claude_desktop() + assert servers == [] + + +# --------------------------------------------------------------------------- +# Normalize server entry +# --------------------------------------------------------------------------- + + +class TestNormalizeServerEntry: + def test_remote_type_becomes_transport(self): + entry = {"url": "http://localhost:8000/sse", "type": "sse"} + result = _normalize_server_entry(entry) + assert result["transport"] == "sse" + assert "type" not in result + + def test_remote_with_transport_unchanged(self): + entry = {"url": "http://localhost:8000/mcp", "transport": "http"} + result = _normalize_server_entry(entry) + assert result["transport"] == "http" + + def test_stdio_type_unchanged(self): + """Stdio entries have ``type`` as a proper field — leave it alone.""" + entry = {"command": "npx", "args": [], "type": "stdio"} + result = _normalize_server_entry(entry) + assert result["type"] == "stdio" + + def test_gemini_http_url_becomes_url(self): + entry = {"httpUrl": "https://api.example.com/mcp/"} + result = _normalize_server_entry(entry) + assert result["url"] == "https://api.example.com/mcp/" + assert "httpUrl" not in result + + def test_gemini_http_url_does_not_override_url(self): + entry = {"url": "http://real.com", "httpUrl": "http://other.com"} + result = _normalize_server_entry(entry) + assert result["url"] == "http://real.com" + + +# --------------------------------------------------------------------------- +# Scanner: Claude Code +# --------------------------------------------------------------------------- + + +def _claude_code_config( + *, + global_servers: dict[str, Any] | None = None, + project_path: str | None = None, + project_servers: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a minimal ~/.claude.json structure.""" + data: dict[str, Any] = {} + if global_servers is not None: + data["mcpServers"] = global_servers + if project_path and project_servers is not None: + data["projects"] = {project_path: {"mcpServers": project_servers}} + return data + + +class TestScanClaudeCode: + def test_global_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + config_path = tmp_path / ".claude.json" + _write_config( + config_path, + _claude_code_config(global_servers=_STDIO_CONFIG["mcpServers"]), + ) + servers = _scan_claude_code(tmp_path) + assert len(servers) == 2 + assert all(s.source == "claude-code" for s in servers) + + def test_project_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + project_dir = tmp_path / "my-project" + project_dir.mkdir() + config_path = tmp_path / ".claude.json" + _write_config( + config_path, + _claude_code_config( + project_path=str(project_dir), + project_servers={"api": {"url": "http://localhost:8000/mcp"}}, + ), + ) + servers = _scan_claude_code(project_dir) + assert len(servers) == 1 + assert servers[0].name == "api" + + def test_global_and_project_combined( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + project_dir = tmp_path / "proj" + project_dir.mkdir() + config_path = tmp_path / ".claude.json" + _write_config( + config_path, + _claude_code_config( + global_servers={"global-tool": {"command": "echo", "args": ["hi"]}}, + project_path=str(project_dir), + project_servers={"local-tool": {"command": "cat", "args": []}}, + ), + ) + servers = _scan_claude_code(project_dir) + names = {s.name for s in servers} + assert names == {"global-tool", "local-tool"} + + def test_type_normalized_to_transport( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Claude Code uses ``type: sse`` — verify it becomes ``transport``.""" + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + config_path = tmp_path / ".claude.json" + _write_config( + config_path, + _claude_code_config( + global_servers={ + "sse-server": { + "type": "sse", + "url": "http://localhost:8000/sse", + } + } + ), + ) + servers = _scan_claude_code(tmp_path) + assert len(servers) == 1 + assert isinstance(servers[0].config, RemoteMCPServer) + assert servers[0].config.transport == "sse" + + def test_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + servers = _scan_claude_code(tmp_path) + assert servers == [] + + def test_no_matching_project(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + config_path = tmp_path / ".claude.json" + _write_config( + config_path, + _claude_code_config( + project_path="/some/other/project", + project_servers={"tool": {"command": "echo", "args": []}}, + ), + ) + servers = _scan_claude_code(tmp_path) + assert servers == [] + + +# --------------------------------------------------------------------------- +# Scanner: Cursor workspace +# --------------------------------------------------------------------------- + + +class TestScanCursorWorkspace: + def test_finds_config_in_cwd(self, tmp_path: Path): + cursor_path = tmp_path / ".cursor" / "mcp.json" + _write_config(cursor_path, _STDIO_CONFIG) + servers = _scan_cursor_workspace(tmp_path) + assert len(servers) == 2 + assert all(s.source == "cursor" for s in servers) + + def test_finds_config_in_parent(self, tmp_path: Path): + cursor_path = tmp_path / ".cursor" / "mcp.json" + _write_config(cursor_path, _STDIO_CONFIG) + child = tmp_path / "src" / "deep" + child.mkdir(parents=True) + servers = _scan_cursor_workspace(child) + assert len(servers) == 2 + + def test_stops_at_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # Place config above home — should not be found + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + above_home = tmp_path.parent / ".cursor" / "mcp.json" + _write_config(above_home, _STDIO_CONFIG) + child = tmp_path / "project" + child.mkdir() + servers = _scan_cursor_workspace(child) + assert servers == [] + + def test_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # Confine walk to tmp_path so it doesn't find sibling test dirs + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + servers = _scan_cursor_workspace(tmp_path) + assert servers == [] + + +# --------------------------------------------------------------------------- +# Scanner: project mcp.json +# --------------------------------------------------------------------------- + + +class TestScanProjectMcpJson: + def test_finds_config(self, tmp_path: Path): + config_path = tmp_path / "mcp.json" + _write_config(config_path, _STDIO_CONFIG) + servers = _scan_project_mcp_json(tmp_path) + assert len(servers) == 2 + assert all(s.source == "project" for s in servers) + + def test_no_config(self, tmp_path: Path): + servers = _scan_project_mcp_json(tmp_path) + assert servers == [] + + +# --------------------------------------------------------------------------- +# Scanner: Gemini CLI +# --------------------------------------------------------------------------- + + +class TestScanGemini: + def test_user_level_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + config_path = tmp_path / ".gemini" / "settings.json" + _write_config(config_path, _STDIO_CONFIG) + servers = _scan_gemini(tmp_path) + assert len(servers) == 2 + assert all(s.source == "gemini" for s in servers) + + def test_project_level_config( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + project_dir = tmp_path / "my-project" + project_dir.mkdir() + config_path = project_dir / ".gemini" / "settings.json" + _write_config(config_path, _STDIO_CONFIG) + servers = _scan_gemini(project_dir) + assert len(servers) == 2 + + def test_http_url_normalized(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Gemini uses ``httpUrl`` — verify it becomes ``url``.""" + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + config_path = tmp_path / ".gemini" / "settings.json" + _write_config( + config_path, + { + "mcpServers": { + "api": {"httpUrl": "https://api.example.com/mcp/"}, + } + }, + ) + servers = _scan_gemini(tmp_path) + assert len(servers) == 1 + assert isinstance(servers[0].config, RemoteMCPServer) + assert servers[0].config.url == "https://api.example.com/mcp/" + + def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + servers = _scan_gemini(tmp_path) + assert servers == [] + + +# --------------------------------------------------------------------------- +# Scanner: Goose +# --------------------------------------------------------------------------- + +_GOOSE_CONFIG = { + "extensions": { + "developer": { + "enabled": True, + "name": "developer", + "type": "builtin", + }, + "tavily": { + "cmd": "npx", + "args": ["-y", "mcp-tavily-search"], + "enabled": True, + "envs": {"TAVILY_API_KEY": "xxx"}, + "type": "stdio", + }, + "disabled-tool": { + "cmd": "echo", + "args": ["hi"], + "enabled": False, + "type": "stdio", + }, + } +} + + +class TestScanGoose: + def test_finds_stdio_extensions( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + config_dir = tmp_path / ".config" / "goose" + config_path = config_dir / "config.yaml" + config_path.parent.mkdir(parents=True) + config_path.write_text(yaml.dump(_GOOSE_CONFIG)) + # Force non-windows platform for path logic + monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux") + servers = _scan_goose() + assert len(servers) == 1 + assert servers[0].name == "tavily" + assert servers[0].source == "goose" + assert isinstance(servers[0].config, StdioMCPServer) + assert servers[0].config.command == "npx" + + def test_skips_builtin_and_disabled( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + config_dir = tmp_path / ".config" / "goose" + config_path = config_dir / "config.yaml" + config_path.parent.mkdir(parents=True) + config_path.write_text(yaml.dump(_GOOSE_CONFIG)) + monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux") + servers = _scan_goose() + names = {s.name for s in servers} + assert "developer" not in names + assert "disabled-tool" not in names + + def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux") + servers = _scan_goose() + assert servers == [] + + +# --------------------------------------------------------------------------- +# discover_servers +# --------------------------------------------------------------------------- + + +def _suppress_user_scanners(monkeypatch: pytest.MonkeyPatch) -> None: + """Suppress all scanners that read real user config files.""" + monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_desktop", lambda: []) + monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_code", lambda start_dir: []) + monkeypatch.setattr("fastmcp.cli.discovery._scan_gemini", lambda start_dir: []) + monkeypatch.setattr("fastmcp.cli.discovery._scan_goose", lambda: []) + + +class TestDiscoverServers: + def test_combines_sources(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # Set up project mcp.json + project_config = tmp_path / "mcp.json" + _write_config(project_config, _STDIO_CONFIG) + + # Set up cursor config + cursor_config = tmp_path / ".cursor" / "mcp.json" + _write_config(cursor_config, _REMOTE_CONFIG) + + _suppress_user_scanners(monkeypatch) + + servers = discover_servers(start_dir=tmp_path) + sources = {s.source for s in servers} + assert "project" in sources + assert "cursor" in sources + assert len(servers) == 3 # 2 from project + 1 from cursor + + def test_preserves_duplicates( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Same server name in multiple sources should appear multiple times.""" + project_config = tmp_path / "mcp.json" + _write_config(project_config, _STDIO_CONFIG) + + cursor_config = tmp_path / ".cursor" / "mcp.json" + _write_config(cursor_config, _STDIO_CONFIG) + + _suppress_user_scanners(monkeypatch) + + servers = discover_servers(start_dir=tmp_path) + weather_servers = [s for s in servers if s.name == "weather"] + assert len(weather_servers) == 2 + assert {s.source for s in weather_servers} == {"cursor", "project"} + + +# --------------------------------------------------------------------------- +# resolve_name +# --------------------------------------------------------------------------- + + +class TestResolveName: + @pytest.fixture(autouse=True) + def _isolate_scanners(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Suppress scanners that read real user configs and confine walks to tmp_path.""" + _suppress_user_scanners(monkeypatch) + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + + def test_unique_match(self, tmp_path: Path): + config_path = tmp_path / "mcp.json" + _write_config(config_path, _STDIO_CONFIG) + transport = resolve_name("weather", start_dir=tmp_path) + assert isinstance(transport, StdioTransport) + + def test_qualified_match(self, tmp_path: Path): + config_path = tmp_path / "mcp.json" + _write_config(config_path, _STDIO_CONFIG) + transport = resolve_name("project:weather", start_dir=tmp_path) + assert isinstance(transport, StdioTransport) + + def test_not_found_with_servers(self, tmp_path: Path): + config_path = tmp_path / "mcp.json" + _write_config(config_path, _STDIO_CONFIG) + with pytest.raises(ValueError, match="No server named 'nope'.*Available"): + resolve_name("nope", start_dir=tmp_path) + + def test_not_found_no_servers(self, tmp_path: Path): + with pytest.raises(ValueError, match="No server named 'nope'.*Searched"): + resolve_name("nope", start_dir=tmp_path) + + def test_ambiguous_name(self, tmp_path: Path): + project_config = tmp_path / "mcp.json" + _write_config(project_config, _STDIO_CONFIG) + cursor_config = tmp_path / ".cursor" / "mcp.json" + _write_config(cursor_config, _STDIO_CONFIG) + with pytest.raises(ValueError, match="Ambiguous server name 'weather'"): + resolve_name("weather", start_dir=tmp_path) + + def test_ambiguous_resolved_by_qualified(self, tmp_path: Path): + project_config = tmp_path / "mcp.json" + _write_config(project_config, _STDIO_CONFIG) + cursor_config = tmp_path / ".cursor" / "mcp.json" + _write_config(cursor_config, _STDIO_CONFIG) + transport = resolve_name("cursor:weather", start_dir=tmp_path) + assert isinstance(transport, StdioTransport) + + def test_qualified_not_found(self, tmp_path: Path): + config_path = tmp_path / "mcp.json" + _write_config(config_path, _STDIO_CONFIG) + with pytest.raises( + ValueError, match="No server named 'nope' found in source 'project'" + ): + resolve_name("project:nope", start_dir=tmp_path) + + def test_remote_server_resolves_to_http_transport(self, tmp_path: Path): + config_path = tmp_path / "mcp.json" + _write_config(config_path, _REMOTE_CONFIG) + transport = resolve_name("api", start_dir=tmp_path) + assert isinstance(transport, StreamableHttpTransport) + + +# --------------------------------------------------------------------------- +# Integration: resolve_server_spec falls through to name resolution +# --------------------------------------------------------------------------- + + +class TestResolveServerSpecNameFallback: + def test_bare_name_resolves(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + config_path = tmp_path / "mcp.json" + _write_config(config_path, _STDIO_CONFIG) + _suppress_user_scanners(monkeypatch) + monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) + + # Monkeypatch resolve_name in client module to use our tmp_path + original_resolve = resolve_name + + def patched_resolve(name: str, start_dir: Path | None = None) -> Any: + return original_resolve(name, start_dir=tmp_path) + + monkeypatch.setattr("fastmcp.cli.client.resolve_name", patched_resolve) + + result = resolve_server_spec("weather") + assert isinstance(result, StdioTransport) + + def test_url_takes_priority_over_name(self): + """URLs should be resolved before name lookup.""" + result = resolve_server_spec("http://localhost:8000/mcp") + assert result == "http://localhost:8000/mcp" + + +# --------------------------------------------------------------------------- +# Integration: _is_http_target detects transport objects +# --------------------------------------------------------------------------- + + +class TestIsHttpTargetTransports: + def test_streamable_http_transport(self): + transport = StreamableHttpTransport("http://localhost:8000/mcp") + assert _is_http_target(transport) is True + + def test_sse_transport(self): + transport = SSETransport("http://localhost:8000/sse") + assert _is_http_target(transport) is True + + def test_stdio_transport(self): + transport = StdioTransport(command="echo", args=["hello"]) + assert _is_http_target(transport) is False + + def test_string_url(self): + assert _is_http_target("http://localhost:8000") is True + + def test_string_non_url(self): + assert _is_http_target("server.py") is False + + def test_dict_config(self): + assert _is_http_target({"mcpServers": {}}) is False diff --git a/uv.lock b/uv.lock index d70b4e42c..32c27dc99 100644 --- a/uv.lock +++ b/uv.lock @@ -696,6 +696,7 @@ dependencies = [ { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "uvicorn" }, { name = "watchfiles" }, @@ -763,6 +764,7 @@ requires-dist = [ { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.17.2" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, { name = "rich", specifier = ">=13.9.4" }, { name = "uvicorn", specifier = ">=0.35" }, { name = "watchfiles", specifier = ">=1.0.0" }, From b2f5551d229ba85dc6c134e187daa24f21fa7c93 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Sun, 1 Feb 2026 20:28:44 -0600 Subject: [PATCH 05/63] Fix Field() handling in prompts (#3050) Co-authored-by: Bill Easton Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- src/fastmcp/prompts/function_prompt.py | 18 ++++- tests/prompts/test_prompt.py | 82 +++++++++++++++++++++++ tests/resources/test_resource_template.py | 80 ++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index 4648c269f..c2ba6f44a 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -297,13 +297,27 @@ class FunctionPrompt(Prompt): # Convert string arguments to expected types BEFORE validation kwargs = self._convert_string_arguments(kwargs) + # Filter out arguments that aren't in the function signature + # This is important for security: dependencies should not be overridable + # from external callers. self.fn is wrapped by without_injected_parameters, + # so we only accept arguments that are in the wrapped function's signature. + sig = inspect.signature(self.fn) + valid_params = set(sig.parameters.keys()) + kwargs = {k: v for k, v in kwargs.items() if k in valid_params} + + # Use type adapter to validate arguments and handle Field() defaults + # This matches the behavior of tools in function_tool + type_adapter = get_cached_typeadapter(self.fn) + # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally if inspect.iscoroutinefunction(self.fn): - result = await self.fn(**kwargs) + result = await type_adapter.validate_python(kwargs) else: # Run sync functions in threadpool to avoid blocking the event loop - result = await call_sync_fn_in_threadpool(self.fn, **kwargs) + result = await call_sync_fn_in_threadpool( + type_adapter.validate_python, kwargs + ) # Handle sync wrappers that return awaitables (e.g., partial(async_fn)) if inspect.isawaitable(result): result = await result diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index 30c8336e7..05d7c0f17 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -552,6 +552,88 @@ class TestPromptResult: assert mcp_result.meta == {"key": "value"} +class TestPromptFieldDefaults: + """Test prompts with Field() defaults.""" + + async def test_field_with_default(self): + """Test that Field(default=...) correctly provides default values.""" + + from pydantic import Field + + def prompt_with_defaults( + required: str = Field(description="Required parameter"), + optional: str = Field( + default="default_value", description="Optional parameter" + ), + ) -> str: + return f"required={required}, optional={optional}" + + prompt = Prompt.from_function(prompt_with_defaults) + result = await prompt.render(arguments={"required": "test"}) + assert result.messages == [Message("required=test, optional=default_value")] + + async def test_annotated_field_with_default_in_signature(self): + """Test that Annotated[type, Field(...)] with default in signature works.""" + from typing import Annotated + + from pydantic import Field + + def prompt_with_annotated( + required: Annotated[str, Field(description="Required parameter")], + optional: Annotated[ + str, Field(description="Optional parameter") + ] = "default_value", + ) -> str: + return f"required={required}, optional={optional}" + + prompt = Prompt.from_function(prompt_with_annotated) + result = await prompt.render(arguments={"required": "test"}) + assert result.messages == [Message("required=test, optional=default_value")] + + async def test_multiple_field_defaults(self): + """Test multiple parameters with Field() defaults.""" + from pydantic import Field + + def prompt_with_multiple_defaults( + name: str = Field(description="Name"), + greeting: str = Field(default="Hello", description="Greeting"), + punctuation: str = Field(default="!", description="Punctuation"), + ) -> str: + return f"{greeting}, {name}{punctuation}" + + prompt = Prompt.from_function(prompt_with_multiple_defaults) + + # Test with only required parameter + result1 = await prompt.render(arguments={"name": "World"}) + assert result1.messages == [Message("Hello, World!")] + + # Test overriding one default + result2 = await prompt.render(arguments={"name": "World", "greeting": "Hi"}) + assert result2.messages == [Message("Hi, World!")] + + # Test overriding all defaults + result3 = await prompt.render( + arguments={"name": "World", "greeting": "Greetings", "punctuation": "."} + ) + assert result3.messages == [Message("Greetings, World.")] + + async def test_field_defaults_with_type_conversion(self): + """Test Field() defaults work with type conversion for non-string types.""" + from pydantic import Field + + def prompt_with_typed_defaults( + count: int = Field(description="Count"), + multiplier: int = Field(default=2, description="Multiplier"), + ) -> str: + return f"result={count * multiplier}" + + prompt = Prompt.from_function(prompt_with_typed_defaults) + + # Pass count as string (MCP requirement), should use default for multiplier + result = await prompt.render(arguments={"count": "5"}) + assert result.messages == [Message("result=10")] + + class TestPromptCallableAndConcurrency: """Test prompts with callable objects and concurrent execution.""" diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 2cb1d1d4a..ed0b37376 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -1007,3 +1007,83 @@ class TestQueryParameterWithWildcards: assert result["path"] == "src/test/data.txt" assert result["encoding"] == "utf-8" # default assert result["lines"] == 50 # provided + + +class TestResourceTemplateFieldDefaults: + """Test resource templates with Field() defaults.""" + + async def test_field_with_default(self): + """Test that Field(default=...) correctly provides default values in resource templates.""" + from pydantic import Field + + def get_data( + id: str = Field(description="Resource ID"), + format: str = Field(default="json", description="Output format"), + ) -> str: + return f"id={id}, format={format}" + + template = ResourceTemplate.from_function( + fn=get_data, + uri_template="data://{id}{?format}", + name="test", + ) + + # Test with only required parameter + resource = await template.create_resource("data://123", {"id": "123"}) + result = await resource.read() + assert result == "id=123, format=json" + + # Test with override + resource = await template.create_resource( + "data://123?format=xml", {"id": "123", "format": "xml"} + ) + result = await resource.read() + assert result == "id=123, format=xml" + + async def test_multiple_field_defaults(self): + """Test multiple query parameters with Field() defaults.""" + from typing import Any + + from pydantic import Field + + def fetch_data( + resource_id: str = Field(description="Resource ID"), + limit: int = Field(default=10, description="Result limit"), + offset: int = Field(default=0, description="Result offset"), + format: str = Field(default="json", description="Output format"), + ) -> dict[str, Any]: + return { + "resource_id": resource_id, + "limit": limit, + "offset": offset, + "format": format, + } + + template = ResourceTemplate.from_function( + fn=fetch_data, + uri_template="api://{resource_id}{?limit,offset,format}", + name="test", + ) + + # Test with only required parameter - all defaults should apply + resource1 = await template.create_resource( + "api://user123", {"resource_id": "user123"} + ) + result1 = await resource1.read() + assert isinstance(result1, dict) + assert result1["resource_id"] == "user123" + assert result1["limit"] == 10 + assert result1["offset"] == 0 + assert result1["format"] == "json" + + # Test with some overrides + resource2 = await template.create_resource( + "api://user123?limit=50&format=xml", + {"resource_id": "user123", "limit": "50", "format": "xml"}, + ) + result2 = await resource2.read() + assert isinstance(result2, dict) + assert result2["resource_id"] == "user123" + assert result2["limit"] == 50 # overridden + assert result2["offset"] == 0 # default + assert result2["format"] == "xml" # overridden From 6fa90fa7924608362199bf5cd8b3319bf69ec5ac Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Sun, 1 Feb 2026 20:30:48 -0600 Subject: [PATCH 06/63] fix: use SkipJsonSchema to exclude callable fields from JSON schema generation (#3048) Co-authored-by: Bill Easton Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- src/fastmcp/prompts/function_prompt.py | 3 +- src/fastmcp/prompts/prompt.py | 3 +- src/fastmcp/resources/function_resource.py | 3 +- src/fastmcp/resources/resource.py | 3 +- src/fastmcp/resources/template.py | 5 +- src/fastmcp/tools/function_tool.py | 3 +- src/fastmcp/tools/tool.py | 5 +- src/fastmcp/tools/tool_transform.py | 9 +- tests/test_json_schema_generation.py | 231 +++++++++++++++++++++ 9 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 tests/test_json_schema_generation.py diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index c2ba6f44a..916838ede 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -19,6 +19,7 @@ from typing import ( import pydantic_core from mcp.types import Icon +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config @@ -73,7 +74,7 @@ class PromptMeta: class FunctionPrompt(Prompt): """A prompt that is a function.""" - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] @classmethod def from_function( diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 57bbecd56..8cf2d3265 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -25,6 +25,7 @@ from mcp.types import ( from mcp.types import Prompt as SDKPrompt from mcp.types import PromptArgument as SDKPromptArgument from pydantic import Field +from pydantic.json_schema import SkipJsonSchema from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.tools.tool import AuthCheckCallable @@ -194,7 +195,7 @@ class Prompt(FastMCPComponent): arguments: list[PromptArgument] | None = Field( default=None, description="Arguments that can be passed to the prompt" ) - auth: AuthCheckCallable | list[AuthCheckCallable] | None = Field( + auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field( default=None, description="Authorization checks for this prompt", exclude=True ) diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index c6a881dab..76c6f974c 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_check from mcp.types import Annotations, Icon from pydantic import AnyUrl +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config @@ -73,7 +74,7 @@ class FunctionResource(Resource): - other types will be converted to JSON """ - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] @classmethod def from_function( diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 2aaca1863..1f36fd5e2 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -26,6 +26,7 @@ from pydantic import ( field_validator, model_validator, ) +from pydantic.json_schema import SkipJsonSchema from typing_extensions import Self from fastmcp.server.tasks.config import TaskConfig, TaskMeta @@ -226,7 +227,7 @@ class Resource(FastMCPComponent): Field(description="Optional annotations about the resource's behavior"), ] = None auth: Annotated[ - AuthCheckCallable | list[AuthCheckCallable] | None, + SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None], Field(description="Authorization checks for this resource", exclude=True), ] = None diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 02836e398..8ce650073 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -10,6 +10,7 @@ from urllib.parse import parse_qs, unquote import mcp.types from mcp.types import Annotations, Icon +from pydantic.json_schema import SkipJsonSchema if TYPE_CHECKING: from docket import Docket @@ -116,7 +117,7 @@ class ResourceTemplate(FastMCPComponent): annotations: Annotations | None = Field( default=None, description="Optional annotations about the resource's behavior" ) - auth: AuthCheckCallable | list[AuthCheckCallable] | None = Field( + auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field( default=None, description="Authorization checks for this resource template", exclude=True, @@ -327,7 +328,7 @@ class ResourceTemplate(FastMCPComponent): class FunctionResourceTemplate(ResourceTemplate): """A template for dynamically creating resources.""" - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] @overload async def _read( diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 2c22fb8f2..38d3116c6 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -20,6 +20,7 @@ import anyio import mcp.types from mcp.shared.exceptions import McpError from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config @@ -81,7 +82,7 @@ class ToolMeta: class FunctionTool(Tool): - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] def to_mcp_tool( self, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 36b491343..e13cda280 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -24,6 +24,7 @@ from mcp.types import ( ) from mcp.types import Tool as MCPTool from pydantic import BaseModel, Field, model_validator +from pydantic.json_schema import SkipJsonSchema from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent @@ -140,13 +141,13 @@ class Tool(FastMCPComponent): Field(description="Task execution configuration (SEP-1686)"), ] = None serializer: Annotated[ - ToolResultSerializerType | None, + SkipJsonSchema[ToolResultSerializerType | None], Field( description="Deprecated. Return ToolResult from your tools for full control over serialization." ), ] = None auth: Annotated[ - AuthCheckCallable | list[AuthCheckCallable] | None, + SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None], Field(description="Authorization checks for this tool", exclude=True), ] = None timeout: Annotated[ diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 17f1a256c..85ea9e02e 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -13,6 +13,7 @@ from mcp.types import ToolAnnotations from pydantic import ConfigDict from pydantic.fields import Field from pydantic.functional_validators import BeforeValidator +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.tools.function_parsing import ParsedFunction @@ -253,9 +254,11 @@ class TransformedTool(Tool): model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) - parent_tool: Tool - fn: Callable[..., Any] - forwarding_fn: Callable[..., Any] # Always present, handles arg transformation + parent_tool: SkipJsonSchema[Tool] + fn: SkipJsonSchema[Callable[..., Any]] + forwarding_fn: SkipJsonSchema[ + Callable[..., Any] + ] # Always present, handles arg transformation transform_args: dict[str, ArgTransform] async def run(self, arguments: dict[str, Any]) -> ToolResult: diff --git a/tests/test_json_schema_generation.py b/tests/test_json_schema_generation.py new file mode 100644 index 000000000..9cf10a0a5 --- /dev/null +++ b/tests/test_json_schema_generation.py @@ -0,0 +1,231 @@ +"""Tests for JSON schema generation from FastMCP BaseModel classes. + +Validates that callable fields are properly excluded from generated schemas +using SkipJsonSchema annotations. +""" + +from fastmcp.prompts.function_prompt import FunctionPrompt +from fastmcp.resources.function_resource import FunctionResource +from fastmcp.resources.template import FunctionResourceTemplate +from fastmcp.tools.function_tool import FunctionTool +from fastmcp.tools.tool import Tool +from fastmcp.tools.tool_transform import TransformedTool + + +class TestToolJsonSchema: + """Test JSON schema generation for Tool classes.""" + + def test_tool_json_schema_generation(self): + """Verify Tool.model_json_schema() works without errors.""" + # This should not raise an error + schema = Tool.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable fields are excluded from schema + assert "serializer" not in schema["properties"] + # auth already uses exclude=True, so it shouldn't be in schema + assert "auth" not in schema["properties"] + + def test_function_tool_json_schema_generation(self): + """Verify FunctionTool.model_json_schema() works without errors.""" + + def sample_tool(x: int, y: int) -> int: + """Add two numbers.""" + return x + y + + tool = FunctionTool.from_function(sample_tool) + + # This should not raise an error + schema = tool.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + + def test_transformed_tool_json_schema_generation(self): + """Verify TransformedTool.model_json_schema() works without errors.""" + + def parent_fn(x: int) -> int: + return x * 2 + + parent_tool = FunctionTool.from_function(parent_fn) + transformed_tool = TransformedTool.from_tool(parent_tool, name="doubled") + + # This should not raise an error + schema = transformed_tool.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable fields are excluded from schema + assert "fn" not in schema["properties"] + assert "forwarding_fn" not in schema["properties"] + assert "parent_tool" not in schema["properties"] + + +class TestResourceJsonSchema: + """Test JSON schema generation for Resource classes.""" + + def test_function_resource_json_schema_generation(self): + """Verify FunctionResource.model_json_schema() works without errors.""" + + def sample_resource() -> str: + """Return sample data.""" + return "Hello, world!" + + resource = FunctionResource.from_function( + sample_resource, uri="test://resource" + ) + + # This should not raise an error + schema = resource.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + # auth already uses exclude=True + assert "auth" not in schema["properties"] + + def test_function_resource_template_json_schema_generation(self): + """Verify FunctionResourceTemplate.model_json_schema() works without errors.""" + + def sample_template(name: str) -> str: + """Return greeting for name.""" + return f"Hello, {name}!" + + template = FunctionResourceTemplate.from_function( + sample_template, uri_template="greeting://{name}" + ) + + # This should not raise an error + schema = template.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + + +class TestPromptJsonSchema: + """Test JSON schema generation for Prompt classes.""" + + def test_function_prompt_json_schema_generation(self): + """Verify FunctionPrompt.model_json_schema() works without errors.""" + + def sample_prompt(topic: str) -> str: + """Generate prompt about topic.""" + return f"Tell me about {topic}" + + prompt = FunctionPrompt.from_function(sample_prompt) + + # This should not raise an error + schema = prompt.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + # auth already uses exclude=True + assert "auth" not in schema["properties"] + + +class TestJsonSchemaIntegration: + """Integration tests for JSON schema generation across all classes.""" + + def test_all_classes_generate_valid_schemas(self): + """Verify all affected classes can generate valid JSON schemas.""" + + # Create instances of all affected classes + def tool_fn(x: int) -> int: + return x + + def resource_fn() -> str: + return "data" + + def template_fn(id: str) -> str: + return f"data-{id}" + + def prompt_fn(input: str) -> str: + return f"Prompt: {input}" + + tool = FunctionTool.from_function(tool_fn) + transformed_tool = TransformedTool.from_tool(tool) + resource = FunctionResource.from_function(resource_fn, uri="test://resource") + template = FunctionResourceTemplate.from_function( + template_fn, uri_template="test://{id}" + ) + prompt = FunctionPrompt.from_function(prompt_fn) + + # All of these should succeed without errors + schemas = [ + Tool.model_json_schema(), + tool.model_json_schema(), + transformed_tool.model_json_schema(), + resource.model_json_schema(), + template.model_json_schema(), + prompt.model_json_schema(), + ] + + # Verify all schemas are valid + for schema in schemas: + assert isinstance(schema, dict) + assert schema["type"] == "object" + assert "properties" in schema + + def test_callable_fields_not_in_any_schema(self): + """Verify no callable fields appear in any generated schema.""" + + # Define test functions + def tool_fn(x: int) -> int: + return x + + def resource_fn() -> str: + return "data" + + def template_fn(id: str) -> str: + return f"data-{id}" + + def prompt_fn(input: str) -> str: + return f"Prompt: {input}" + + # Create instances + tool = FunctionTool.from_function(tool_fn) + transformed_tool = TransformedTool.from_tool(tool) + resource = FunctionResource.from_function(resource_fn, uri="test://resource") + template = FunctionResourceTemplate.from_function( + template_fn, uri_template="test://{id}" + ) + prompt = FunctionPrompt.from_function(prompt_fn) + + # List of (instance, callable_field_names) tuples + test_cases = [ + (tool, ["fn", "serializer"]), + (transformed_tool, ["fn", "forwarding_fn", "parent_tool", "serializer"]), + (resource, ["fn"]), + (template, ["fn"]), + (prompt, ["fn"]), + ] + + for instance, callable_fields in test_cases: + schema = instance.model_json_schema() + properties = schema.get("properties", {}) + + # Verify none of the callable fields are in the schema + for field in callable_fields: + assert field not in properties, ( + f"Callable field '{field}' found in schema for {type(instance).__name__}" + ) From c8e2c621ef9e568f698e99085ed5d9e5d96678c6 Mon Sep 17 00:00:00 2001 From: Neelay Shah Date: Mon, 2 Feb 2026 16:26:24 +0100 Subject: [PATCH 07/63] fix: Preserve metadata in FastMCPProvider component wrappers (#3057) --- src/fastmcp/server/providers/fastmcp_provider.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 8cc40506c..82476ffb9 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -86,6 +86,9 @@ class FastMCPProviderTool(Tool): tags=tool.tags, annotations=tool.annotations, task_config=tool.task_config, + meta=tool.meta, + title=tool.title, + icons=tool.icons, ) @overload @@ -183,6 +186,9 @@ class FastMCPProviderResource(Resource): tags=resource.tags, annotations=resource.annotations, task_config=resource.task_config, + meta=resource.meta, + title=resource.title, + icons=resource.icons, ) @overload @@ -249,6 +255,9 @@ class FastMCPProviderPrompt(Prompt): arguments=prompt.arguments, tags=prompt.tags, task_config=prompt.task_config, + meta=prompt.meta, + title=prompt.title, + icons=prompt.icons, ) @overload @@ -350,6 +359,9 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): tags=template.tags, annotations=template.annotations, task_config=template.task_config, + meta=template.meta, + title=template.title, + icons=template.icons, ) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: From 08974e50d94b6b856aefccfec30d4a1f82bd8715 Mon Sep 17 00:00:00 2001 From: Guillaume FORTAINE Date: Tue, 3 Feb 2026 01:28:42 +0100 Subject: [PATCH 08/63] feat(context): Add background task support for Context (SEP-1686) (#2905) --- src/fastmcp/server/context.py | 133 ++- src/fastmcp/server/dependencies.py | 140 ++- src/fastmcp/server/tasks/__init__.py | 3 + src/fastmcp/server/tasks/config.py | 18 +- src/fastmcp/server/tasks/elicitation.py | 229 +++++ src/fastmcp/server/tasks/handlers.py | 8 + .../tasks/test_context_background_task.py | 862 ++++++++++++++++++ 7 files changed, 1362 insertions(+), 31 deletions(-) create mode 100644 src/fastmcp/server/tasks/elicitation.py create mode 100644 tests/server/tasks/test_context_background_task.py diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 9a5eb3087..a9245fa2b 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -182,10 +182,45 @@ class Context: # Default TTL for session state: 1 day in seconds _STATE_TTL_SECONDS: int = 86400 - def __init__(self, fastmcp: FastMCP, session: ServerSession | None = None): + def __init__( + self, + fastmcp: FastMCP, + session: ServerSession | None = None, + *, + task_id: str | None = None, + ): self._fastmcp: weakref.ref[FastMCP] = weakref.ref(fastmcp) self._session: ServerSession | None = session # For state ops during init self._tokens: list[Token] = [] + # Background task support (SEP-1686) + self._task_id: str | None = task_id + + @property + def is_background_task(self) -> bool: + """True when this context is running in a background task (Docket worker). + + When True, certain operations like elicit() and sample() will use + task-aware implementations that can pause the task and wait for + client input. + + Example: + ```python + @server.tool(task=True) + async def my_task(ctx: Context) -> str: + # Works transparently in both foreground and background task modes + result = await ctx.elicit("Need input", str) + return str(result) + ``` + """ + return self._task_id is not None + + @property + def task_id(self) -> str | None: + """Get the background task ID if running in a background task. + + Returns None if not running in a background task context. + """ + return self._task_id @property def fastmcp(self) -> FastMCP: @@ -566,14 +601,27 @@ class Context: def session(self) -> ServerSession: """Access to the underlying session for advanced usage. - Raises RuntimeError if MCP request context is not available. + In request mode: Returns the session from the active request context. + In background task mode: Returns the session stored at Context creation. + + Raises RuntimeError if no session is available. """ - if self.request_context is None: - raise RuntimeError( - "session is not available because the MCP session has not been established yet. " - "Check `context.request_context` for None before accessing this attribute." - ) - return self.request_context.session + # Background task mode: use the stored session + if self.is_background_task and self._session is not None: + return self._session + + # Request mode: use request context + if self.request_context is not None: + return self.request_context.session + + # Fallback to stored session (e.g., during on_initialize) + if self._session is not None: + return self._session + + raise RuntimeError( + "session is not available because the MCP session has not been established yet. " + "Check `context.request_context` for None before accessing this attribute." + ) # Convenience methods for common log levels async def debug( @@ -841,7 +889,13 @@ class Context: - .text: The text representation (raw text or JSON for structured) - .result: The typed result (str for text, parsed object for structured) - .history: All messages exchanged during sampling + + Note: + Background task support for sampling is planned for a future release. + Currently, sampling in background tasks requires using the low-level + session.create_message() API directly. """ + # TODO: Add background task support similar to elicit() when is_background_task return await sample_impl( self, messages=messages, @@ -960,14 +1014,27 @@ class Context: response_type: The type of the response, which should be a primitive type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. + + Note: + This method works transparently in both request and background task + contexts. In background task mode (SEP-1686), it will set the task + status to "input_required" and wait for the client to provide input. """ config = parse_elicit_response_type(response_type) - result = await self.session.elicit( - message=message, - requestedSchema=config.schema, - related_request_id=self.request_id, - ) + if self.is_background_task: + # Background task mode: use task-aware elicitation + result = await self._elicit_for_task( + message=message, + schema=config.schema, + ) + else: + # Standard request mode: use session.elicit directly + result = await self.session.elicit( + message=message, + requestedSchema=config.schema, + related_request_id=self.request_id, + ) if result.action == "accept": return handle_elicit_accept(config, result.content) @@ -978,6 +1045,46 @@ class Context: else: raise ValueError(f"Unexpected elicitation action: {result.action}") + async def _elicit_for_task( + self, + message: str, + schema: dict[str, Any], + ) -> mcp.types.ElicitResult: + """Send an elicitation request from a background task (SEP-1686). + + This method handles elicitation when running in a Docket worker context, + where there's no active MCP request. It: + 1. Sets the task status to "input_required" + 2. Sends the elicitation request with task metadata + 3. Waits for the client to provide input via tasks/sendInput + 4. Returns the result and resumes task execution + + Args: + message: The message to display to the user + schema: The JSON schema for the expected response + + Returns: + ElicitResult with the user's response + + Raises: + RuntimeError: If not running in a background task context + """ + if not self.is_background_task: + raise RuntimeError( + "_elicit_for_task called but not in a background task context" + ) + + # Import here to avoid circular imports and optional dependency issues + from fastmcp.server.tasks.elicitation import elicit_for_task + + return await elicit_for_task( + task_id=self._task_id, # type: ignore[arg-type] + session=self.session, + message=message, + schema=schema, + fastmcp=self.fastmcp, + ) + def _make_state_key(self, key: str) -> str: """Create session-prefixed key for state storage.""" return f"{self.session_id}:{key}" diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 1f2032918..ce964fd31 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -13,6 +13,7 @@ import weakref from collections.abc import AsyncGenerator, Callable from contextlib import AsyncExitStack, asynccontextmanager from contextvars import ContextVar +from dataclasses import dataclass from functools import lru_cache from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable @@ -35,6 +36,7 @@ from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type if TYPE_CHECKING: from docket import Docket from docket.worker import Worker + from mcp.server.session import ServerSession from fastmcp.server.context import Context from fastmcp.server.server import FastMCP @@ -50,12 +52,16 @@ __all__ = [ "CurrentRequest", "CurrentWorker", "Progress", + "TaskContextInfo", "get_access_token", "get_context", "get_http_headers", "get_http_request", "get_server", + "get_task_context", + "get_task_session", "is_docket_available", + "register_task_session", "require_docket", "resolve_dependencies", "transform_context_annotations", @@ -63,6 +69,95 @@ __all__ = [ ] +# --- TaskContextInfo and get_task_context --- + + +@dataclass(frozen=True, slots=True) +class TaskContextInfo: + """Information about the current background task context. + + Returned by ``get_task_context()`` when running inside a Docket worker. + Contains identifiers needed to communicate with the MCP session. + """ + + task_id: str + """The MCP task ID (server-generated UUID).""" + + session_id: str + """The session ID that submitted this task.""" + + +def get_task_context() -> TaskContextInfo | None: + """Get the current task context if running inside a background task worker. + + This function extracts task information from the Docket execution context. + Returns None if not running in a task context (e.g., foreground execution). + + Returns: + TaskContextInfo with task_id and session_id, or None if not in a task. + """ + if not is_docket_available(): + return None + + from docket.dependencies import Dependency as DocketDependency + + try: + execution = DocketDependency.execution.get() + # Parse the task key: {session_id}:{task_id}:{task_type}:{component} + from fastmcp.server.tasks.keys import parse_task_key + + key_parts = parse_task_key(execution.key) + return TaskContextInfo( + task_id=key_parts["client_task_id"], + session_id=key_parts["session_id"], + ) + except LookupError: + # Not in worker context + return None + except (ValueError, KeyError): + # Invalid task key format + return None + + +# --- Session registry for background task Context --- + + +_task_sessions: dict[str, weakref.ref[ServerSession]] = {} + + +def register_task_session(session_id: str, session: ServerSession) -> None: + """Register a session for Context access in background tasks. + + Called automatically when a task is submitted to Docket. The session is + stored as a weakref so it doesn't prevent garbage collection when the + client disconnects. + + Args: + session_id: The session identifier + session: The ServerSession instance + """ + _task_sessions[session_id] = weakref.ref(session) + + +def get_task_session(session_id: str) -> ServerSession | None: + """Get a registered session by ID if still alive. + + Args: + session_id: The session identifier + + Returns: + The ServerSession if found and alive, None otherwise + """ + ref = _task_sessions.get(session_id) + if ref is None: + return None + session = ref() + if session is None: + # Session was garbage collected, clean up entry + _task_sessions.pop(session_id, None) + return session + + # --- ContextVars --- _current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( @@ -623,13 +718,52 @@ async def resolve_dependencies( class _CurrentContext(Dependency): # type: ignore[misc] - """Async context manager for Context dependency.""" + """Async context manager for Context dependency. + + In foreground (request) mode: returns the active context from _current_context. + In background (Docket worker) mode: creates a task-aware Context with task_id. + """ + + _context: Context | None = None async def __aenter__(self) -> Context: - return get_context() + from fastmcp.server.context import Context, _current_context + + # Try foreground context first (normal MCP request) + context = _current_context.get() + if context is not None: + return context + + # Check if we're in a Docket worker context + task_info = get_task_context() + if task_info is not None: + # Get session from registry (registered when task was submitted) + session = get_task_session(task_info.session_id) + # Get server from ContextVar + server = get_server() + # Create task-aware Context + self._context = Context( + fastmcp=server, + session=session, + task_id=task_info.task_id, + ) + # Enter the context to set up ContextVars + await self._context.__aenter__() + return self._context + + # Neither foreground nor background context available + raise RuntimeError( + "No active context found. This can happen if:\n" + " - Called outside an MCP request handler\n" + " - Called in a background task before session was registered\n" + "Check `context.request_context` for None before accessing." + ) async def __aexit__(self, *args: object) -> None: - pass + # Clean up if we created a context for background task + if self._context is not None: + await self._context.__aexit__(*args) + self._context = None def CurrentContext() -> Context: diff --git a/src/fastmcp/server/tasks/__init__.py b/src/fastmcp/server/tasks/__init__.py index b3b4a72d4..13ba9e80c 100644 --- a/src/fastmcp/server/tasks/__init__.py +++ b/src/fastmcp/server/tasks/__init__.py @@ -5,6 +5,7 @@ This module implements protocol-level background task execution for MCP servers. from fastmcp.server.tasks.capabilities import get_task_capabilities from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode +from fastmcp.server.tasks.elicitation import elicit_for_task, handle_task_input from fastmcp.server.tasks.keys import ( build_task_key, get_client_task_id_from_key, @@ -16,7 +17,9 @@ __all__ = [ "TaskMeta", "TaskMode", "build_task_key", + "elicit_for_task", "get_client_task_id_from_key", "get_task_capabilities", + "handle_task_input", "parse_task_key", ] diff --git a/src/fastmcp/server/tasks/config.py b/src/fastmcp/server/tasks/config.py index 1bf0b8ce3..4956a7667 100644 --- a/src/fastmcp/server/tasks/config.py +++ b/src/fastmcp/server/tasks/config.py @@ -7,7 +7,6 @@ handle task-augmented execution as specified in SEP-1686. from __future__ import annotations import inspect -import warnings from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -136,17 +135,6 @@ class TaskConfig: "Background tasks require async functions." ) - # Warn if function uses Context - it won't be available in workers - from fastmcp.server.context import Context - from fastmcp.utilities.types import find_kwarg_by_type - - context_kwarg = find_kwarg_by_type(fn_to_check, Context) - if context_kwarg: - warnings.warn( - f"'{name}' uses Context but has task execution enabled. " - "Context is not available in background task workers because " - "there is no active MCP session. Consider using Docket dependencies " - "like Progress() instead for worker-compatible functionality.", - UserWarning, - stacklevel=4, - ) + # Note: Context IS now available in background task workers (SEP-1686) + # The wiring in _CurrentContext creates a task-aware Context with task_id + # and session from the registry. No warning needed. diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py new file mode 100644 index 000000000..2fc0bef5f --- /dev/null +++ b/src/fastmcp/server/tasks/elicitation.py @@ -0,0 +1,229 @@ +"""Background task elicitation support (SEP-1686). + +This module provides elicitation capabilities for background tasks running +in Docket workers. Unlike regular MCP requests, background tasks don't have +an active request context, so elicitation requires special handling: + +1. Set task status to "input_required" via Redis +2. Send notifications/tasks/updated with elicitation metadata +3. Wait for client to send input via tasks/sendInput +4. Resume task execution with the provided input + +This uses the public MCP SDK APIs where possible, with minimal use of +internal APIs for background task coordination. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from typing import TYPE_CHECKING, Any + +import mcp.types +from mcp import ServerSession + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from fastmcp.server.server import FastMCP + + +# Redis key patterns for task elicitation state +ELICIT_REQUEST_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:request" +ELICIT_RESPONSE_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:response" +ELICIT_STATUS_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:status" + +# TTL for elicitation state (1 hour) +ELICIT_TTL_SECONDS = 3600 + + +async def elicit_for_task( + task_id: str, + session: ServerSession, + message: str, + schema: dict[str, Any], + fastmcp: FastMCP, +) -> mcp.types.ElicitResult: + """Send an elicitation request from a background task. + + This function handles the complexity of eliciting user input when running + in a Docket worker context where there's no active MCP request. + + Args: + task_id: The background task ID + session: The MCP ServerSession for this task + message: The message to display to the user + schema: The JSON schema for the expected response + fastmcp: The FastMCP server instance + + Returns: + ElicitResult containing the user's response + + Raises: + RuntimeError: If Docket is not available + McpError: If the elicitation request fails + """ + docket = fastmcp._docket + if docket is None: + raise RuntimeError( + "Background task elicitation requires Docket. " + "Ensure 'fastmcp[tasks]' is installed and the server has task-enabled components." + ) + + # Generate a unique request ID for this elicitation + request_id = str(uuid.uuid4()) + + # Get session ID for Redis key construction + session_id = getattr(session, "_fastmcp_state_prefix", None) + if session_id is None: + # Generate a session ID if not already set + session_id = str(uuid.uuid4()) + session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] + + # Store elicitation request in Redis + request_key = ELICIT_REQUEST_KEY.format(session_id=session_id, task_id=task_id) + response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) + status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) + + elicit_request = { + "request_id": request_id, + "message": message, + "schema": schema, + } + + async with docket.redis() as redis: + # Store the elicitation request + await redis.set( + docket.key(request_key), + json.dumps(elicit_request), + ex=ELICIT_TTL_SECONDS, + ) + # Set status to "waiting" + await redis.set( + docket.key(status_key), + "waiting", + ex=ELICIT_TTL_SECONDS, + ) + + # Send task status update notification with input_required status + # This follows SEP-1686 for background task status updates + notification = mcp.types.JSONRPCNotification( + jsonrpc="2.0", + method="notifications/tasks/updated", + params={}, + _meta={ # type: ignore[call-arg] + "modelcontextprotocol.io/related-task": { + "taskId": task_id, + "status": "input_required", + "statusMessage": message, + "elicitation": { + "requestId": request_id, + "message": message, + "requestedSchema": schema, + }, + } + }, + ) + + # Send notification (best effort - task status is stored in Redis) + # Log failures for debugging but don't fail the elicitation + try: + await session.send_notification(notification) # type: ignore[arg-type] + except Exception as e: + logger.warning( + "Failed to send input_required notification for task %s: %s", + task_id, + e, + ) + + # Wait for response (poll Redis) + # In a production implementation, this could use Redis pub/sub for lower latency + max_wait_seconds = ELICIT_TTL_SECONDS + poll_interval = 0.5 # seconds + + for _ in range(int(max_wait_seconds / poll_interval)): + async with docket.redis() as redis: + response_data = await redis.get(docket.key(response_key)) + if response_data: + response = json.loads(response_data) + # Clean up Redis keys + await redis.delete( + docket.key(request_key), + docket.key(response_key), + docket.key(status_key), + ) + # Convert to ElicitResult + return mcp.types.ElicitResult( + action=response.get("action", "accept"), + content=response.get("content"), + ) + + await asyncio.sleep(poll_interval) + + # Timeout - treat as cancellation + async with docket.redis() as redis: + await redis.delete( + docket.key(request_key), + docket.key(response_key), + docket.key(status_key), + ) + + return mcp.types.ElicitResult(action="cancel", content=None) + + +async def handle_task_input( + task_id: str, + session_id: str, + action: str, + content: dict[str, Any] | None, + fastmcp: FastMCP, +) -> bool: + """Handle input sent to a background task via tasks/sendInput. + + This is called when a client sends input in response to an elicitation + request from a background task. + + Args: + task_id: The background task ID + session_id: The MCP session ID + action: The elicitation action ("accept", "decline", "cancel") + content: The response content (for "accept" action) + fastmcp: The FastMCP server instance + + Returns: + True if the input was successfully stored, False otherwise + """ + docket = fastmcp._docket + if docket is None: + return False + + response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) + status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) + + response = { + "action": action, + "content": content, + } + + async with docket.redis() as redis: + # Check if there's a pending elicitation + status = await redis.get(docket.key(status_key)) + if status is None or status.decode("utf-8") != "waiting": + return False + + # Store the response + await redis.set( + docket.key(response_key), + json.dumps(response), + ex=ELICIT_TTL_SECONDS, + ) + # Update status to "responded" + await redis.set( + docket.key(status_key), + "responded", + ex=ELICIT_TTL_SECONDS, + ) + + return True diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index f03dcc1be..02da22148 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -101,6 +101,14 @@ async def submit_to_docket( await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds) await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds) + # Register session for Context access in background workers (SEP-1686) + # This enables elicitation/sampling from background tasks via weakref + # Skip for "internal" sessions (programmatic calls without MCP session) + if session_id != "internal": + from fastmcp.server.dependencies import register_task_session + + register_task_session(session_id, ctx.session) + # Send notifications/tasks/created per SEP-1686 (mandatory) # Send BEFORE queuing to avoid race where task completes before notification notification = mcp.types.JSONRPCNotification( diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py new file mode 100644 index 000000000..b778a63b2 --- /dev/null +++ b/tests/server/tasks/test_context_background_task.py @@ -0,0 +1,862 @@ +"""Tests for Context background task support (SEP-1686).""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.server.context import Context +from fastmcp.server.elicitation import AcceptedElicitation +from fastmcp.server.tasks.elicitation import elicit_for_task, handle_task_input + + +class TestContextBackgroundTaskSupport: + """Tests for Context.is_background_task and related functionality.""" + + def test_context_not_background_task_by_default(self): + """Context should not be a background task by default.""" + mcp = FastMCP("test") + ctx = Context(mcp) + assert ctx.is_background_task is False + assert ctx.task_id is None + + def test_context_is_background_task_when_task_id_provided(self): + """Context should be a background task when task_id is provided.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task-123") + assert ctx.is_background_task is True + assert ctx.task_id == "test-task-123" + + def test_context_task_id_is_readonly(self): + """task_id should be a read-only property.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task-123") + with pytest.raises(AttributeError): + ctx.task_id = "new-id" # type: ignore[misc] + + +class TestContextSessionProperty: + """Tests for Context.session property in different modes.""" + + def test_session_raises_when_no_session_available(self): + """session should raise RuntimeError when no session is available.""" + mcp = FastMCP("test") + ctx = Context(mcp) # No session, not a background task + + with pytest.raises(RuntimeError, match="session is not available"): + _ = ctx.session + + def test_session_uses_stored_session_in_background_task(self): + """session should use _session in background task mode.""" + mcp = FastMCP("test") + + class MockSession: + _fastmcp_state_prefix = "test-session" + + mock_session = MockSession() + ctx = Context(mcp, session=mock_session, task_id="test-task-123") # type: ignore[arg-type] + + # In background task mode, should return the stored session + assert ctx.session is mock_session + + def test_session_uses_stored_session_during_on_initialize(self): + """session should use _session during on_initialize (no request context).""" + mcp = FastMCP("test") + + class MockSession: + _fastmcp_state_prefix = "test-session" + + mock_session = MockSession() + # Simulating on_initialize: has session but not a background task + ctx = Context(mcp, session=mock_session) # type: ignore[arg-type] + + # Should return the stored session as fallback + assert ctx.session is mock_session + + +class TestContextElicitBackgroundTask: + """Tests for Context.elicit() in background task mode.""" + + @pytest.mark.asyncio + async def test_elicit_raises_when_background_task_but_no_docket(self): + """elicit() should raise when in background task mode but Docket unavailable.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task-123") + + # Set up minimal session mock + class MockSession: + _fastmcp_state_prefix = "test-session" + + ctx._session = MockSession() # type: ignore[assignment] + + with pytest.raises(RuntimeError, match="Docket"): + await ctx.elicit("Need input", str) + + +class TestContextDocumentation: + """Tests to verify Context documentation and API surface.""" + + def test_is_background_task_has_docstring(self): + """is_background_task property should have documentation.""" + assert Context.is_background_task.__doc__ is not None + assert "background task" in Context.is_background_task.__doc__.lower() + + def test_task_id_has_docstring(self): + """task_id property should have documentation.""" + assert Context.task_id.fget.__doc__ is not None + assert "task ID" in Context.task_id.fget.__doc__ + + def test_session_has_docstring(self): + """session property should document background task support.""" + assert Context.session.fget.__doc__ is not None + assert "background task" in Context.session.fget.__doc__.lower() + + +class TestBackgroundTaskElicitationE2E: + """End-to-end tests for background task elicitation (SEP-1686). + + These tests demonstrate the full flow: + 1. Client calls a tool with task=True (background execution) + 2. Tool uses ctx.elicit() to request user input + 3. Task status changes to "input_required" + 4. Client sends input via handle_task_input() + 5. Task resumes and completes with the elicited value + + This simulates what a client would see when interacting with + a background task that needs user input. + """ + + async def test_elicit_for_task_stores_request_in_redis(self): + """Test that elicit_for_task stores the elicitation request in Redis. + + This tests the Redis coordination layer that enables client interaction. + When a background task calls elicit(), the request is stored in Redis + so clients can retrieve it and respond. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + from fastmcp.server.tasks.elicitation import ( + elicit_for_task, + ) + + # Create mocks + mock_redis = AsyncMock() + mock_redis.set = AsyncMock() + mock_redis.get = AsyncMock(return_value=None) # No response yet + mock_redis.delete = AsyncMock() + + mock_docket = MagicMock() + mock_docket.redis = MagicMock(return_value=AsyncMock()) + mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) + mock_docket.redis.return_value.__aexit__ = AsyncMock() + mock_docket.key = lambda k: k + + mock_fastmcp = MagicMock() + mock_fastmcp._docket = mock_docket + + mock_session = MagicMock() + mock_session._fastmcp_state_prefix = "test-session-id" + mock_session.send_notification = AsyncMock() + + # Call elicit_for_task with a short timeout to avoid blocking + with patch("fastmcp.server.tasks.elicitation.ELICIT_TTL_SECONDS", 1): + with patch("fastmcp.server.tasks.elicitation.asyncio.sleep", AsyncMock()): + # Make it return after first poll + mock_redis.get = AsyncMock( + return_value=b'{"action": "accept", "content": {"value": 42}}' + ) + + result = await elicit_for_task( + task_id="test-task-123", + session=mock_session, + message="Please provide a number", + schema={ + "type": "object", + "properties": {"value": {"type": "integer"}}, + }, + fastmcp=mock_fastmcp, + ) + + # Verify the result + assert result.action == "accept" + assert result.content == {"value": 42} + + # Verify Redis operations were called + assert mock_redis.set.call_count >= 2 # request + status + + async def test_handle_task_input_stores_response(self): + """Test that handle_task_input stores the response in Redis. + + This tests the client-side flow: when a client sends input via + tasks/sendInput, the response is stored in Redis for the waiting task. + """ + from unittest.mock import AsyncMock, MagicMock + + # Create mocks + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value=b"waiting") # Status is waiting + mock_redis.set = AsyncMock() + + mock_docket = MagicMock() + mock_docket.redis = MagicMock(return_value=AsyncMock()) + mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) + mock_docket.redis.return_value.__aexit__ = AsyncMock() + mock_docket.key = lambda k: k + + mock_fastmcp = MagicMock() + mock_fastmcp._docket = mock_docket + + # Call handle_task_input + success = await handle_task_input( + task_id="test-task-123", + session_id="test-session-id", + action="accept", + content={"value": 42}, + fastmcp=mock_fastmcp, + ) + + # Verify success + assert success is True + + # Verify Redis operations + assert mock_redis.set.call_count == 2 # response + status update + + async def test_handle_task_input_rejects_when_not_waiting(self): + """Test that handle_task_input rejects input when task isn't waiting. + + This verifies proper state management - clients can only send input + when a task is actually waiting for it. + """ + from unittest.mock import AsyncMock, MagicMock + + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value=None) # No waiting status + + mock_docket = MagicMock() + mock_docket.redis = MagicMock(return_value=AsyncMock()) + mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) + mock_docket.redis.return_value.__aexit__ = AsyncMock() + mock_docket.key = lambda k: k + + mock_fastmcp = MagicMock() + mock_fastmcp._docket = mock_docket + + success = await handle_task_input( + task_id="test-task-123", + session_id="test-session-id", + action="accept", + content={"value": 42}, + fastmcp=mock_fastmcp, + ) + + # Should fail because no task is waiting + assert success is False + + async def test_elicit_for_task_sends_notification(self): + """Test that elicit_for_task sends input_required notification. + + Per SEP-1686, the server should send notifications/tasks/updated + with status="input_required" when a task needs input. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + mock_redis = AsyncMock() + mock_redis.set = AsyncMock() + mock_redis.get = AsyncMock( + return_value=b'{"action": "accept", "content": {"value": 1}}' + ) + mock_redis.delete = AsyncMock() + + mock_docket = MagicMock() + mock_docket.redis = MagicMock(return_value=AsyncMock()) + mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) + mock_docket.redis.return_value.__aexit__ = AsyncMock() + mock_docket.key = lambda k: k + + mock_fastmcp = MagicMock() + mock_fastmcp._docket = mock_docket + + mock_session = MagicMock() + mock_session._fastmcp_state_prefix = "test-session" + mock_session.send_notification = AsyncMock() + + with patch("fastmcp.server.tasks.elicitation.asyncio.sleep", AsyncMock()): + await elicit_for_task( + task_id="my-task-id", + session=mock_session, + message="Enter value", + schema={"type": "object"}, + fastmcp=mock_fastmcp, + ) + + # Verify notification was sent + mock_session.send_notification.assert_called_once() + notification = mock_session.send_notification.call_args[0][0] + assert notification.method == "notifications/tasks/updated" + + async def test_elicit_for_task_timeout_returns_cancel(self): + """Test that elicit_for_task returns cancel on timeout. + + If no response is received within the TTL, the elicitation + should be treated as cancelled. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + mock_redis = AsyncMock() + mock_redis.set = AsyncMock() + mock_redis.get = AsyncMock(return_value=None) # Never responds + mock_redis.delete = AsyncMock() + + mock_docket = MagicMock() + mock_docket.redis = MagicMock(return_value=AsyncMock()) + mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) + mock_docket.redis.return_value.__aexit__ = AsyncMock() + mock_docket.key = lambda k: k + + mock_fastmcp = MagicMock() + mock_fastmcp._docket = mock_docket + + mock_session = MagicMock() + mock_session._fastmcp_state_prefix = "test-session" + mock_session.send_notification = AsyncMock() + + # Use very short TTL for test + with patch("fastmcp.server.tasks.elicitation.ELICIT_TTL_SECONDS", 0.1): + with patch( + "fastmcp.server.tasks.elicitation.asyncio.sleep", + AsyncMock(), + ): + result = await elicit_for_task( + task_id="timeout-task", + session=mock_session, + message="This will timeout", + schema={"type": "object"}, + fastmcp=mock_fastmcp, + ) + + # Should return cancel on timeout + assert result.action == "cancel" + assert result.content is None + + async def test_elicit_notification_includes_full_schema(self): + """Test that the notification includes the full JSON schema for complex types. + + This test demonstrates what the client sees when eliciting a Pydantic model. + The client receives a full JSON Schema that describes the expected input, + which they can use to: + - Render a dynamic form + - Validate user input before sending + - Show field descriptions to the user + + Example notification metadata for a UserInfo model: + ```json + { + "modelcontextprotocol.io/related-task": { + "taskId": "test-task", + "status": "input_required", + "statusMessage": "Please provide user info", + "elicitation": { + "requestId": "...", + "message": "Please provide user info", + "requestedSchema": { + "type": "object", + "properties": { + "name": {"type": "string", "title": "Name"}, + "age": {"type": "integer", "title": "Age"} + }, + "required": ["name", "age"], + "title": "UserInfo" + } + } + } + } + ``` + """ + from unittest.mock import AsyncMock, MagicMock, patch + + from pydantic import BaseModel + + class UserInfo(BaseModel): + """User information for registration.""" + + name: str + age: int + + mock_redis = AsyncMock() + mock_redis.set = AsyncMock() + mock_redis.get = AsyncMock( + return_value=b'{"action": "accept", "content": {"name": "Alice", "age": 30}}' + ) + mock_redis.delete = AsyncMock() + + mock_docket = MagicMock() + mock_docket.redis = MagicMock(return_value=AsyncMock()) + mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) + mock_docket.redis.return_value.__aexit__ = AsyncMock() + mock_docket.key = lambda k: k + + mock_fastmcp = MagicMock() + mock_fastmcp._docket = mock_docket + + mock_session = MagicMock() + mock_session._fastmcp_state_prefix = "test-session" + mock_session.send_notification = AsyncMock() + + # Create task-aware context + ctx = Context( + mock_fastmcp, + session=mock_session, + task_id="schema-test-task", + ) + + # Call elicit with a Pydantic model type + with patch("fastmcp.server.tasks.elicitation.asyncio.sleep", AsyncMock()): + result = await ctx.elicit("Please provide user info", UserInfo) + + # Verify the notification includes the full schema + mock_session.send_notification.assert_called_once() + notification = mock_session.send_notification.call_args[0][0] + meta = notification._meta + related_task = meta["modelcontextprotocol.io/related-task"] + schema = related_task["elicitation"]["requestedSchema"] + + # Verify schema structure matches UserInfo + assert schema["type"] == "object" + assert "properties" in schema + assert "name" in schema["properties"] + assert "age" in schema["properties"] + assert schema["properties"]["name"]["type"] == "string" + assert schema["properties"]["age"]["type"] == "integer" + assert "required" in schema + assert set(schema["required"]) == {"name", "age"} + + # Verify the result is properly parsed into the Pydantic model + assert result.action == "accept" + assert isinstance(result, AcceptedElicitation) # Type narrowing + assert isinstance(result.data, UserInfo) + assert result.data.name == "Alice" + assert result.data.age == 30 + + +class TestBackgroundTaskContextWiring: + """Integration tests for Context wiring in Docket workers. + + These tests verify that when a background task runs in a Docket worker, + the Context dependency is properly created with task_id and session, + allowing ctx.elicit() to work transparently. + + Per Chris Guidry's review request: "Could we get at least one test showing + the end-to-end of it working, with a background task that's eliciting input? + This will help with what the client-side sees when this happens." + + The key test is `test_context_elicit_full_flow_with_mocked_redis` which shows: + + CLIENT RECEIVES: + notifications/tasks/updated with: + - taskId: the background task ID + - status: "input_required" + - statusMessage: the elicit prompt + - elicitation.requestedSchema: JSON schema for expected input + + CLIENT RESPONDS: + handle_task_input(task_id, session_id, action="accept", content={...}) + + TOOL RECEIVES: + AcceptedElicitation(action="accept", data=) + """ + + async def test_context_is_created_with_task_id_in_worker(self): + """Test that Context is created with task_id when running in Docket worker. + + This verifies the wiring from _CurrentContext that creates a task-aware + Context when get_task_context() returns TaskContextInfo. + """ + from unittest.mock import MagicMock, patch + + from fastmcp.server.dependencies import ( + TaskContextInfo, + _current_server, + _CurrentContext, + _task_sessions, + ) + + # Set up mock server + mock_server = MagicMock() + mock_server._docket = MagicMock() + server_token = _current_server.set(MagicMock(return_value=mock_server)) + + # Set up mock session in registry + mock_session = MagicMock() + mock_session._fastmcp_state_prefix = "test-session-id" + _task_sessions["test-session-id"] = MagicMock(return_value=mock_session) + + try: + # Mock get_task_context to return TaskContextInfo + task_info = TaskContextInfo( + task_id="test-task-123", + session_id="test-session-id", + ) + with patch( + "fastmcp.server.dependencies.get_task_context", + return_value=task_info, + ): + # Create the dependency and enter it + dep = _CurrentContext() + ctx = await dep.__aenter__() + + # Verify context is task-aware + assert ctx.is_background_task is True + assert ctx.task_id == "test-task-123" + assert ctx.session is mock_session + + # Clean up + await dep.__aexit__(None, None, None) + finally: + _current_server.reset(server_token) + _task_sessions.pop("test-session-id", None) + + async def test_context_falls_back_to_foreground_mode(self): + """Test that Context uses foreground mode when not in worker context. + + When _current_context has a value (normal request handling), + _CurrentContext should return that context instead of creating a new one. + """ + from unittest.mock import MagicMock + + from fastmcp.server.context import Context, _current_context + from fastmcp.server.dependencies import _CurrentContext + + mcp = MagicMock() + foreground_ctx = Context(mcp) + + # Set the foreground context + token = _current_context.set(foreground_ctx) + try: + dep = _CurrentContext() + ctx = await dep.__aenter__() + + # Should return the foreground context + assert ctx is foreground_ctx + assert ctx.is_background_task is False + + await dep.__aexit__(None, None, None) + finally: + _current_context.reset(token) + + async def test_session_registered_when_task_submitted(self): + """Test that session is registered when a task is submitted to Docket. + + This verifies that submit_to_docket calls register_task_session, + which enables the Context wiring in background workers. + """ + import asyncio + + from fastmcp import FastMCP + from fastmcp.client import Client + from fastmcp.server.dependencies import get_task_session + + mcp = FastMCP("test-server") + + task_started = asyncio.Event() + session_id_captured = None + + @mcp.tool(task=True) + async def capture_session_tool(ctx: Context) -> str: + """Tool that captures the session ID for verification.""" + nonlocal session_id_captured + task_started.set() + # Access session to verify it works + session_id_captured = ctx.session_id + return "done" + + async with Client(mcp) as client: + # Start the task + task = await client.call_tool("capture_session_tool", {}, task=True) + assert task is not None + + # Wait for the task to start + await asyncio.wait_for(task_started.wait(), timeout=5.0) + + # Verify the session was registered + assert session_id_captured is not None + # The session should be retrievable via get_task_session + # (it was registered when the task was submitted) + # Session may be available or None if cleaned up - key is registration happened + _ = get_task_session(session_id_captured) + + # Wait for task to complete + await task.wait(timeout=5.0) + result = await task.result() + assert result.data == "done" + + async def test_context_elicit_works_in_background_task(self): + """E2E test: verify Context is properly wired in background tasks. + + This test demonstrates that: + 1. Context.task_id is set correctly in background tasks + 2. Context.is_background_task returns True + 3. Context.session_id is available + + The wiring is what enables ctx.elicit() to work in background tasks. + """ + import asyncio + + from fastmcp import FastMCP + from fastmcp.client import Client + from fastmcp.server.context import Context + + mcp = FastMCP("context-wiring-test") + + # Track what happens in the background task + task_completed = asyncio.Event() + captured_task_id: str | None = None + captured_session_id: str | None = None + captured_is_background: bool | None = None + + @mcp.tool(task=True) + async def verify_context_tool(ctx: Context) -> str: + """Tool that verifies Context is wired correctly for background tasks.""" + nonlocal captured_task_id, captured_session_id, captured_is_background + + # Capture context properties - this is the key verification + captured_task_id = ctx.task_id + captured_session_id = ctx.session_id + captured_is_background = ctx.is_background_task + + task_completed.set() + return f"task_id={ctx.task_id}, is_background={ctx.is_background_task}" + + async with Client(mcp) as client: + # Start the background task + task = await client.call_tool("verify_context_tool", {}, task=True) + assert task is not None + assert task.task_id is not None + + # Wait for the task to complete + await asyncio.wait_for(task_completed.wait(), timeout=10.0) + + # Verify Context was properly wired in the background task + assert captured_task_id is not None, "Context.task_id should be set" + assert captured_session_id is not None, "Context.session_id should be set" + assert captured_is_background is True, ( + "Context.is_background_task should be True" + ) + + # Wait for task result + await task.wait(timeout=10.0) + result = await task.result() + assert "is_background=True" in result.data + + async def test_context_elicit_full_flow_with_mocked_redis(self): + """E2E test with mocked Redis to show complete elicitation flow. + + This test demonstrates what the client sees during background task + elicitation, with a mocked Redis layer to avoid requiring real Redis. + + Flow: + 1. Tool calls ctx.elicit() in background task + 2. Elicitation stores request in Redis, sends input_required notification + 3. Simulated client sends response via handle_task_input() + 4. Tool receives response and completes + + This is the key test that fulfills Chris Guidry's request for an + "end-to-end test showing a background task that's eliciting input" + and demonstrates "what the client-side sees when this happens." + """ + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from fastmcp.server.context import Context + from fastmcp.server.tasks.elicitation import handle_task_input + + # Shared Redis storage that both elicit and handle_task_input will use + redis_storage: dict[str, bytes] = {} + + # Create a mock Redis that uses our shared storage + class MockRedis: + async def set( + self, key: str, value: str | bytes, ex: int | None = None + ) -> None: + redis_storage[key] = value.encode() if isinstance(value, str) else value + + async def get(self, key: str) -> bytes | None: + return redis_storage.get(key) + + async def delete(self, *keys: str) -> None: + for key in keys: + redis_storage.pop(key, None) + + mock_redis = MockRedis() + + # Create mock context manager for redis() + class MockRedisContext: + async def __aenter__(self): + return mock_redis + + async def __aexit__(self, *args): + pass + + mock_docket = MagicMock() + mock_docket.redis = lambda: MockRedisContext() + mock_docket.key = lambda k: k + + mock_fastmcp = MagicMock() + mock_fastmcp._docket = mock_docket + + mock_session = MagicMock() + mock_session._fastmcp_state_prefix = "test-session-123" + mock_session.send_notification = AsyncMock() + + # Create task-aware context (as would be created in background worker) + ctx = Context( + mock_fastmcp, + session=mock_session, + task_id="test-task-456", + ) + + # Verify context is properly configured for background task + assert ctx.is_background_task is True + assert ctx.task_id == "test-task-456" + + # Start elicit in a background task (simulating the Docket worker) + async def run_elicit(): + return await ctx.elicit("What is your name?", str) + + elicit_task = asyncio.create_task(run_elicit()) + + # Wait for elicit to store request and start polling + # The elicit_for_task function stores the request and sends notification + await asyncio.sleep(0.2) + + # ═══════════════════════════════════════════════════════════════════════ + # CLIENT PERSPECTIVE: What does the client see? + # ═══════════════════════════════════════════════════════════════════════ + + # 1. CLIENT RECEIVES: notifications/tasks/updated notification + mock_session.send_notification.assert_called() + notification = mock_session.send_notification.call_args[0][0] + assert notification.method == "notifications/tasks/updated" + + # 2. CLIENT INSPECTS: The notification metadata tells the client: + # - Which task needs input (taskId) + # - What status the task is in (input_required) + # - What message to display (statusMessage) + # - The schema for the expected response (elicitation.requestedSchema) + meta = notification._meta + related_task = meta["modelcontextprotocol.io/related-task"] + + assert related_task["taskId"] == "test-task-456" + assert related_task["status"] == "input_required" + assert related_task["statusMessage"] == "What is your name?" + assert "elicitation" in related_task + assert related_task["elicitation"]["message"] == "What is your name?" + assert "requestedSchema" in related_task["elicitation"] + + # 3. CLIENT RESPONDS: Send input via handle_task_input + # This is what a real client would do when it receives input_required + success = await handle_task_input( + task_id="test-task-456", + session_id="test-session-123", + action="accept", + content={"value": "Alice"}, + fastmcp=mock_fastmcp, + ) + assert success is True, "Client should successfully send input" + + # ═══════════════════════════════════════════════════════════════════════ + # TOOL PERSPECTIVE: What does the tool receive? + # ═══════════════════════════════════════════════════════════════════════ + + # Wait for elicit to receive the response and return + result = await asyncio.wait_for(elicit_task, timeout=5.0) + + # Verify the result contains what the client sent + # AcceptedElicitation has 'action' and 'data' attributes + assert result.action == "accept" + assert result.data == "Alice" # The value from content["value"] + + async def test_context_elicit_with_real_docket_memory_backend(self): + """E2E test using Docket's real memory:// backend. + + This test uses the real Docket memory backend instead of mocking Redis, + as suggested by Chris Guidry during code review. The memory:// backend + provides a fully functional in-memory Redis-like store that Docket uses + automatically when running tests. + + Flow: + 1. Create FastMCP server with task-enabled tool that calls ctx.elicit() + 2. Start the task via Client (which initializes Docket with memory://) + 3. Background task blocks waiting for client input + 4. Simulate client sending input via handle_task_input() + 5. Task resumes and completes with the elicited value + + This demonstrates the complete elicitation flow with real infrastructure. + """ + import asyncio + + from fastmcp import FastMCP + from fastmcp.client import Client + from fastmcp.server.context import Context + from fastmcp.server.tasks.elicitation import handle_task_input + + mcp = FastMCP("elicit-memory-test") + + # Track task state using mutable container (avoids nonlocal) + elicit_started = asyncio.Event() + captured: dict[str, str | None] = {"task_id": None, "session_id": None} + + @mcp.tool(task=True) + async def ask_for_name(ctx: Context) -> str: + """Tool that elicits user's name via background task.""" + # Capture IDs for handle_task_input call + captured["task_id"] = ctx.task_id + captured["session_id"] = ctx.session_id + elicit_started.set() + + # This will block until client sends input + result = await ctx.elicit("What is your name?", str) + + if isinstance(result, AcceptedElicitation): + return f"Hello, {result.data}!" + else: + return "Elicitation was declined or cancelled" + + async with Client(mcp) as client: + # Start the background task + task = await client.call_tool("ask_for_name", {}, task=True) + assert task is not None + assert task.task_id is not None + + # Wait for task to reach elicit() call + await asyncio.wait_for(elicit_started.wait(), timeout=5.0) + + # Poll until handle_task_input succeeds + # We need to wait for elicit_for_task to store the "waiting" status in Redis + # before we can send input. Using fixed-interval polling (not exponential + # backoff) because we're waiting for state, not recovering from errors. + assert captured["task_id"] is not None + assert captured["session_id"] is not None + + max_attempts = 40 + poll_interval_seconds = 0.05 # 50ms - fast for tests, 2s max total + success = False + for _ in range(max_attempts): + success = await handle_task_input( + task_id=captured["task_id"], + session_id=captured["session_id"], + action="accept", + content={"value": "Bob"}, + fastmcp=mcp, + ) + if success: + break + await asyncio.sleep(poll_interval_seconds) + + assert success is True, ( + f"handle_task_input should succeed within {max_attempts * poll_interval_seconds}s" + ) + + # Wait for task to complete + await task.wait(timeout=10.0) + result = await task.result() + + # Verify the tool received the elicited value and returned correctly + assert result.data == "Hello, Bob!" From b076b2154c6f8da19ea7dd2d63e30a9998e0561c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:59:13 -0500 Subject: [PATCH 09/63] Add AzureJWTVerifier for Managed Identity token verification (#3058) --- docs/integrations/azure.mdx | 44 ++++++ docs/servers/auth/remote-oauth.mdx | 15 ++ src/fastmcp/server/auth/auth.py | 23 ++- src/fastmcp/server/auth/providers/azure.py | 100 ++++++++++++ tests/server/auth/providers/test_azure.py | 143 +++++++++++++++++- tests/server/auth/test_jwt_provider.py | 17 +++ .../server/auth/test_remote_auth_provider.py | 57 +++++++ 7 files changed, 396 insertions(+), 3 deletions(-) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 836b5a4ae..a5b316d88 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -282,3 +282,47 @@ Parameters (`jwt_signing_key` and `client_storage`) work together to ensure toke For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). + +## Token Verification Only (Managed Identity) + + + +For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full `AzureProvider`. + +This pattern is ideal when: +- Your infrastructure handles authentication (e.g., Managed Identity) +- You don't need the OAuth proxy flow (no `client_secret` required) +- You just need to verify that incoming Azure AD tokens are valid + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth import RemoteAuthProvider +from fastmcp.server.auth.providers.azure import AzureJWTVerifier +from pydantic import AnyHttpUrl + +tenant_id = "your-tenant-id" +client_id = "your-client-id" + +# AzureJWTVerifier auto-configures JWKS, issuer, and audience +verifier = AzureJWTVerifier( + client_id=client_id, + tenant_id=tenant_id, + required_scopes=["access_as_user"], # Scope names from Azure Portal +) + +auth = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[ + AnyHttpUrl(f"https://login.microsoftonline.com/{tenant_id}/v2.0") + ], + base_url="https://your-container-app.azurecontainerapps.io", +) + +mcp = FastMCP(name="Azure MI App", auth=auth) +``` + +`AzureJWTVerifier` handles Azure's scope format automatically. You write scope names exactly as they appear in Azure Portal under **Expose an API** (e.g., `access_as_user`). The verifier validates tokens using the short-form scopes that Azure puts in the `scp` claim, while advertising the full URI scopes (e.g., `api://your-client-id/access_as_user`) in OAuth metadata so MCP clients know what to request. + + +For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`. + diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index 5b894a7ea..7c94a937f 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -127,6 +127,21 @@ This configuration creates a server that accepts tokens issued by `auth.yourcomp The `authorization_servers` list tells MCP clients which identity providers you trust. The `base_url` identifies your server in OAuth metadata, enabling proper token audience validation. **Important**: The `base_url` should point to your server base URL - for example, if your MCP server is accessible at `https://api.yourcompany.com/mcp`, use `https://api.yourcompany.com` as the base URL. +### Overriding Advertised Scopes + +Some identity providers use different scope formats for authorization requests versus token claims. For example, Azure AD requires clients to request full URI scopes like `api://client-id/read`, but the token's `scp` claim contains just `read`. The `scopes_supported` parameter lets you advertise the full-form scopes in metadata while validating against the short form: + +```python +auth = RemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://api.example.com", + scopes_supported=["api://my-api/read", "api://my-api/write"], +) +``` + +When not set, `scopes_supported` defaults to the token verifier's `required_scopes`. For Azure AD specifically, see the [AzureJWTVerifier](/integrations/azure#token-verification-only-managed-identity) which handles this automatically. + ### Custom Endpoints You can extend `RemoteAuthProvider` to add additional endpoints beyond the standard OAuth protected resource metadata. These don't have to be OAuth-specific - you can add any endpoints your authentication integration requires. diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index 95f1ad3a0..9a804f05d 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -274,6 +274,17 @@ class TokenVerifier(AuthProvider): """ super().__init__(base_url=base_url, required_scopes=required_scopes) + @property + def scopes_supported(self) -> list[str]: + """Scopes to advertise in OAuth metadata. + + Defaults to required_scopes. Override in subclasses when the + advertised scopes differ from the validation scopes (e.g., Azure AD + where tokens contain short-form scopes but clients request full URI + scopes). + """ + return self.required_scopes or [] + async def verify_token(self, token: str) -> AccessToken | None: """Verify a bearer token and return access info if valid.""" raise NotImplementedError("Subclasses must implement verify_token") @@ -299,6 +310,7 @@ class RemoteAuthProvider(AuthProvider): token_verifier: TokenVerifier, authorization_servers: list[AnyHttpUrl], base_url: AnyHttpUrl | str, + scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, ): @@ -308,6 +320,10 @@ class RemoteAuthProvider(AuthProvider): token_verifier: TokenVerifier instance for token validation authorization_servers: List of authorization servers that issue valid tokens base_url: The base URL of this server + scopes_supported: Scopes to advertise in OAuth metadata. If None, + uses the token verifier's scopes_supported property. Use this + when the scopes clients request differ from the scopes that + appear in tokens (e.g., Azure AD full URI scopes vs short-form). resource_name: Optional name for the protected resource resource_documentation: Optional documentation URL for the protected resource """ @@ -317,6 +333,7 @@ class RemoteAuthProvider(AuthProvider): ) self.token_verifier = token_verifier self.authorization_servers = authorization_servers + self._scopes_supported = scopes_supported self.resource_name = resource_name self.resource_documentation = resource_documentation @@ -343,7 +360,11 @@ class RemoteAuthProvider(AuthProvider): create_protected_resource_routes( resource_url=resource_url, authorization_servers=self.authorization_servers, - scopes_supported=self.token_verifier.required_scopes, + scopes_supported=( + self._scopes_supported + if self._scopes_supported is not None + else self.token_verifier.scopes_supported + ), resource_name=self.resource_name, resource_documentation=self.resource_documentation, ) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 36fb8975a..cc0d2544e 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -452,3 +452,103 @@ class AzureProvider(OAuthProxy): except Exception as e: logger.debug("Failed to extract Azure claims: %s", e) return None + + +class AzureJWTVerifier(JWTVerifier): + """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. + + Auto-configures JWKS URI, issuer, audience, and scope handling from your + Azure app registration details. Designed for Managed Identity and other + token-verification-only scenarios where AzureProvider's full OAuth proxy + isn't needed. + + Handles Azure's scope format automatically: + - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims) + - Advertises full-URI scopes in OAuth metadata (what clients need to request) + + Example:: + + from fastmcp.server.auth import RemoteAuthProvider + from fastmcp.server.auth.providers.azure import AzureJWTVerifier + from pydantic import AnyHttpUrl + + verifier = AzureJWTVerifier( + client_id="your-client-id", + tenant_id="your-tenant-id", + required_scopes=["access_as_user"], + ) + + auth = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[ + AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0") + ], + base_url="https://my-server.com", + ) + """ + + def __init__( + self, + *, + client_id: str, + tenant_id: str, + required_scopes: list[str] | None = None, + identifier_uri: str | None = None, + base_authority: str = "login.microsoftonline.com", + ): + """Initialize Azure JWT verifier. + + Args: + client_id: Azure application (client) ID from your App registration + tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers"). + For multi-tenant apps ("organizations" or "consumers"), issuer validation + is skipped since Azure tokens carry the actual tenant GUID as issuer. + required_scopes: Scope names as they appear in Azure Portal under "Expose an API" + (e.g., ["access_as_user", "read"]). These are validated against + the short-form scopes in token ``scp`` claims, and automatically + prefixed with identifier_uri for OAuth metadata. + identifier_uri: Application ID URI (defaults to ``api://{client_id}``). + Used to prefix scopes in OAuth metadata so clients know the full + scope URIs to request from Azure. + base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). + For Azure Government, use "login.microsoftonline.us". + """ + self._identifier_uri = identifier_uri or f"api://{client_id}" + + # For multi-tenant apps, Azure tokens carry the actual tenant GUID as + # issuer, not the literal "organizations" or "consumers" string. Skip + # issuer validation for these — audience still protects against wrong-app tokens. + multi_tenant_values = {"organizations", "consumers", "common"} + issuer: str | None = ( + None + if tenant_id in multi_tenant_values + else f"https://{base_authority}/{tenant_id}/v2.0" + ) + + super().__init__( + jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys", + issuer=issuer, + audience=client_id, + algorithm="RS256", + required_scopes=required_scopes, + ) + + @property + def scopes_supported(self) -> list[str]: + """Return scopes with Azure URI prefix for OAuth metadata. + + Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp`` + claim, but clients must request full URI scopes (e.g., + ``api://client-id/read``) from the Azure authorization endpoint. This + property returns the full-URI form for OAuth metadata while + ``required_scopes`` retains the short form for token validation. + """ + if not self.required_scopes: + return [] + prefixed = [] + for scope in self.required_scopes: + if scope in OIDC_SCOPES or "://" in scope or "/" in scope: + prefixed.append(scope) + else: + prefixed.append(f"{self._identifier_uri}/{scope}") + return prefixed diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 165d3229b..6bf25a50d 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -6,8 +6,12 @@ from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl -from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.auth.providers.azure import ( + OIDC_SCOPES, + AzureJWTVerifier, + AzureProvider, +) +from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair class TestAzureProvider: @@ -983,3 +987,138 @@ class TestAzureTokenExchangeScopes: ["read", "write"] ) assert len(refresh_scopes) > 0 + + +class TestAzureJWTVerifier: + """Tests for AzureJWTVerifier pre-configured JWT verifier.""" + + def test_auto_configures_from_client_and_tenant(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["access_as_user"], + ) + assert ( + verifier.jwks_uri + == "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys" + ) + assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0" + assert verifier.audience == "my-client-id" + assert verifier.algorithm == "RS256" + assert verifier.required_scopes == ["access_as_user"] + + async def test_validates_short_form_scopes(self): + key_pair = RSAKeyPair.generate() + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["access_as_user"], + ) + # Override to use our test key instead of JWKS + verifier.public_key = key_pair.public_key + verifier.jwks_uri = None + + token = key_pair.create_token( + subject="test-user", + issuer="https://login.microsoftonline.com/my-tenant-id/v2.0", + audience="my-client-id", + additional_claims={"scp": "access_as_user"}, + ) + result = await verifier.load_access_token(token) + assert result is not None + assert "access_as_user" in result.scopes + + def test_scopes_supported_returns_prefixed_form(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["read", "write"], + ) + assert verifier.scopes_supported == [ + "api://my-client-id/read", + "api://my-client-id/write", + ] + + def test_already_prefixed_scopes_pass_through(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["api://my-client-id/read"], + ) + assert verifier.scopes_supported == ["api://my-client-id/read"] + + def test_oidc_scopes_not_prefixed(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["openid", "read"], + ) + assert verifier.scopes_supported == ["openid", "api://my-client-id/read"] + + def test_custom_identifier_uri(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["read"], + identifier_uri="api://custom-uri", + ) + assert verifier.scopes_supported == ["api://custom-uri/read"] + + def test_custom_base_authority_for_gov_cloud(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + ) + assert ( + verifier.jwks_uri + == "https://login.microsoftonline.us/my-tenant-id/discovery/v2.0/keys" + ) + assert verifier.issuer == "https://login.microsoftonline.us/my-tenant-id/v2.0" + + def test_scopes_supported_empty_when_no_required_scopes(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + ) + assert verifier.scopes_supported == [] + + def test_default_identifier_uri_uses_client_id(self): + verifier = AzureJWTVerifier( + client_id="abc-123", + tenant_id="my-tenant-id", + required_scopes=["read"], + ) + assert verifier.scopes_supported == ["api://abc-123/read"] + + def test_multi_tenant_organizations_skips_issuer(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="organizations", + ) + assert verifier.issuer is None + + def test_multi_tenant_consumers_skips_issuer(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="consumers", + ) + assert verifier.issuer is None + + def test_multi_tenant_common_skips_issuer(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="common", + ) + assert verifier.issuer is None + + def test_specific_tenant_sets_issuer(self): + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="12345678-1234-1234-1234-123456789012", + ) + assert ( + verifier.issuer + == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0" + ) diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 4ecf622d9..14a81299e 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -1099,3 +1099,20 @@ class TestJWTVerifierImport: except ImportError as e: # If PyJWT not available, should get helpful error assert "PyJWT is required" in str(e) + + +class TestScopesSupported: + """Tests for the scopes_supported property on TokenVerifier.""" + + def test_defaults_to_required_scopes(self, rsa_key_pair: RSAKeyPair): + provider = JWTVerifier( + public_key=rsa_key_pair.public_key, + required_scopes=["read", "write"], + ) + assert provider.scopes_supported == ["read", "write"] + + def test_empty_when_no_required_scopes(self, rsa_key_pair: RSAKeyPair): + provider = JWTVerifier( + public_key=rsa_key_pair.public_key, + ) + assert provider.scopes_supported == [] diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py index 0419493f1..5d56c5b5c 100644 --- a/tests/server/auth/test_remote_auth_provider.py +++ b/tests/server/auth/test_remote_auth_provider.py @@ -483,3 +483,60 @@ class TestRemoteAuthProviderIntegration: data["resource_documentation"] == "https://doc.my-server.com/resource-docs" ) + + async def test_scopes_supported_overrides_metadata(self): + """Test that scopes_supported parameter overrides what's in metadata.""" + token_verifier = StaticTokenVerifier( + tokens={ + "test": {"client_id": "c", "scopes": ["read"]}, + }, + required_scopes=["read"], + ) + + provider = RemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://my-server.com", + scopes_supported=["api://my-api/read"], + ) + + mcp = FastMCP("test-server", auth=provider) + mcp_http_app = mcp.http_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mcp_http_app), + base_url="https://my-server.com", + ) as client: + response = await client.get("/.well-known/oauth-protected-resource/mcp") + + assert response.status_code == 200 + data = response.json() + assert data["scopes_supported"] == ["api://my-api/read"] + + async def test_scopes_supported_defaults_to_verifier(self): + """Test that metadata uses verifier scopes_supported when parameter not set.""" + token_verifier = StaticTokenVerifier( + tokens={ + "test": {"client_id": "c", "scopes": ["read"]}, + }, + required_scopes=["read"], + ) + + provider = RemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://my-server.com", + ) + + mcp = FastMCP("test-server", auth=provider) + mcp_http_app = mcp.http_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mcp_http_app), + base_url="https://my-server.com", + ) as client: + response = await client.get("/.well-known/oauth-protected-resource/mcp") + + assert response.status_code == 200 + data = response.json() + assert data["scopes_supported"] == ["read"] From fe432de156c575882c985b17a834ddbc4a98664a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:47:06 -0500 Subject: [PATCH 10/63] Add release notes for v2.14.4 and v2.14.5 (#3064) --- docs/changelog.mdx | 233 ++++++++++++++++++++++++++++++++++++++++++ docs/updates.mdx | 20 ++++ docs/v2/changelog.mdx | 32 ++++++ docs/v2/updates.mdx | 20 ++++ 4 files changed, 305 insertions(+) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 4fd1dca91..de6afbe6a 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,239 @@ icon: "list-check" rss: true --- + + +**[v3.0.0b1: This Beta Work](https://github.com/jlowin/fastmcp/releases/tag/v3.0.0b1)** + +FastMCP 3.0 rebuilds the framework around three primitives: components, providers, and transforms. Providers source components dynamically—from decorators, filesystems, OpenAPI specs, remote servers, or anywhere else. Transforms modify components as they flow to clients—renaming, namespacing, filtering, securing. The features that required specialized subsystems in v2 now compose naturally from these building blocks. + +🔌 **Provider Architecture** unifies how components are sourced. `FileSystemProvider` discovers decorated functions from directories with optional hot-reload. `SkillsProvider` exposes agent skill files as MCP resources. `OpenAPIProvider` and `ProxyProvider` get cleaner integrations. Providers are composable—share one across servers, or attach many to one server. + +🔄 **Transforms** add middleware for components. Namespace mounted servers, rename verbose tools, filter by version, control visibility—all without touching source code. `ResourcesAsTools` and `PromptsAsTools` expose non-tool components to tool-only clients. + +📋 **Component Versioning** lets you register `@tool(version="2.0")` alongside older versions. Clients see the highest version by default but can request specific versions. `VersionFilter` serves different API versions from one codebase. + +💾 **Session-Scoped State** persists across requests. `await ctx.set_state()` and `await ctx.get_state()` now survive the full session. Per-session visibility via `ctx.enable_components()` lets servers adapt dynamically to each client. + +⚡ **DX Improvements** include `--reload` for auto-restart during development, automatic threadpool dispatch for sync functions, tool timeouts, pagination for large component lists, and OpenTelemetry tracing. + +🔐 **Component Authorization** via `@tool(auth=require_scopes("admin"))` and `AuthMiddleware` for server-wide policies. + +Breaking changes are minimal: for most servers, updating the import statement is all you need. See the [migration guide](https://github.com/jlowin/fastmcp/blob/main/docs/development/upgrade-guide.mdx) for details. + +## What's Changed +### New Features 🎉 +* Refactor resource behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2611](https://github.com/jlowin/fastmcp/pull/2611) +* Refactor prompt behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2610](https://github.com/jlowin/fastmcp/pull/2610) +* feat: Provider abstraction for dynamic MCP components by [@jlowin](https://github.com/jlowin) in [#2622](https://github.com/jlowin/fastmcp/pull/2622) +* Unify component storage in LocalProvider by [@jlowin](https://github.com/jlowin) in [#2680](https://github.com/jlowin/fastmcp/pull/2680) +* Introduce ResourceResult as canonical resource return type by [@jlowin](https://github.com/jlowin) in [#2734](https://github.com/jlowin/fastmcp/pull/2734) +* Introduce Message and PromptResult as canonical prompt types by [@jlowin](https://github.com/jlowin) in [#2738](https://github.com/jlowin/fastmcp/pull/2738) +* Add --reload flag for auto-restart on file changes by [@jlowin](https://github.com/jlowin) in [#2816](https://github.com/jlowin/fastmcp/pull/2816) +* Add FileSystemProvider for filesystem-based component discovery by [@jlowin](https://github.com/jlowin) in [#2823](https://github.com/jlowin/fastmcp/pull/2823) +* Add standalone decorators and eliminate fastmcp.fs module by [@jlowin](https://github.com/jlowin) in [#2832](https://github.com/jlowin/fastmcp/pull/2832) +* Add authorization checks to components and servers by [@jlowin](https://github.com/jlowin) in [#2855](https://github.com/jlowin/fastmcp/pull/2855) +* Decorators return functions instead of component objects by [@jlowin](https://github.com/jlowin) in [#2856](https://github.com/jlowin/fastmcp/pull/2856) +* Add transform system for modifying components in provider chains by [@jlowin](https://github.com/jlowin) in [#2836](https://github.com/jlowin/fastmcp/pull/2836) +* Add OpenTelemetry tracing support by [@chrisguidry](https://github.com/chrisguidry) in [#2869](https://github.com/jlowin/fastmcp/pull/2869) +* Add component versioning and VersionFilter transform by [@jlowin](https://github.com/jlowin) in [#2894](https://github.com/jlowin/fastmcp/pull/2894) +* Add version discovery and calling a certain version for components by [@jlowin](https://github.com/jlowin) in [#2897](https://github.com/jlowin/fastmcp/pull/2897) +* Refactor visibility to mark-based enabled system by [@jlowin](https://github.com/jlowin) in [#2912](https://github.com/jlowin/fastmcp/pull/2912) +* Add session-specific visibility control via Context by [@jlowin](https://github.com/jlowin) in [#2917](https://github.com/jlowin/fastmcp/pull/2917) +* Add Skills Provider for exposing agent skills as MCP resources by [@jlowin](https://github.com/jlowin) in [#2944](https://github.com/jlowin/fastmcp/pull/2944) +### Enhancements 🔧 +* Convert mounted servers to MountedProvider by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/jlowin/fastmcp/pull/2635) +* Simplify .key as computed property by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/jlowin/fastmcp/pull/2648) +* Refactor MountedProvider into FastMCPProvider + TransformingProvider by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/jlowin/fastmcp/pull/2653) +* Enable background task support for custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2657](https://github.com/jlowin/fastmcp/pull/2657) +* Use CreateTaskResult for background task creation by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/jlowin/fastmcp/pull/2660) +* Refactor provider execution: components own their execution by [@jlowin](https://github.com/jlowin) in [#2663](https://github.com/jlowin/fastmcp/pull/2663) +* Add supports_tasks() method to replace string mode checks by [@jlowin](https://github.com/jlowin) in [#2664](https://github.com/jlowin/fastmcp/pull/2664) +* Replace type: ignore[attr-defined] with isinstance assertions in tests by [@jlowin](https://github.com/jlowin) in [#2665](https://github.com/jlowin/fastmcp/pull/2665) +* Add poll_interval to TaskConfig by [@jlowin](https://github.com/jlowin) in [#2666](https://github.com/jlowin/fastmcp/pull/2666) +* Refactor task module: rename protocol.py to requests.py and reduce redundancy by [@jlowin](https://github.com/jlowin) in [#2667](https://github.com/jlowin/fastmcp/pull/2667) +* Refactor FastMCPProxy into ProxyProvider by [@jlowin](https://github.com/jlowin) in [#2669](https://github.com/jlowin/fastmcp/pull/2669) +* Move OpenAPI to providers/openapi submodule by [@jlowin](https://github.com/jlowin) in [#2672](https://github.com/jlowin/fastmcp/pull/2672) +* Use ergonomic provider initialization pattern by [@jlowin](https://github.com/jlowin) in [#2675](https://github.com/jlowin/fastmcp/pull/2675) +* Fix ty 0.0.5 type errors by [@jlowin](https://github.com/jlowin) in [#2676](https://github.com/jlowin/fastmcp/pull/2676) +* Remove execution methods from Provider base class by [@jlowin](https://github.com/jlowin) in [#2681](https://github.com/jlowin/fastmcp/pull/2681) +* Add type-prefixed keys for globally unique component identification by [@jlowin](https://github.com/jlowin) in [#2704](https://github.com/jlowin/fastmcp/pull/2704) +* Skip parallel MCP config test on Windows by [@jlowin](https://github.com/jlowin) in [#2711](https://github.com/jlowin/fastmcp/pull/2711) +* Consolidate notification system with unified API by [@jlowin](https://github.com/jlowin) in [#2710](https://github.com/jlowin/fastmcp/pull/2710) +* Skip test_multi_client on Windows by [@jlowin](https://github.com/jlowin) in [#2714](https://github.com/jlowin/fastmcp/pull/2714) +* Parallelize provider operations by [@jlowin](https://github.com/jlowin) in [#2716](https://github.com/jlowin/fastmcp/pull/2716) +* Consolidate get_* and _list_* methods into single API by [@jlowin](https://github.com/jlowin) in [#2719](https://github.com/jlowin/fastmcp/pull/2719) +* Consolidate execution method chains into single public API by [@jlowin](https://github.com/jlowin) in [#2728](https://github.com/jlowin/fastmcp/pull/2728) +* Add documentation check to required PR workflow by [@jlowin](https://github.com/jlowin) in [#2730](https://github.com/jlowin/fastmcp/pull/2730) +* Parallelize list_* calls in Provider.get_tasks() by [@jlowin](https://github.com/jlowin) in [#2731](https://github.com/jlowin/fastmcp/pull/2731) +* Consistent decorator-based MCP handler registration by [@jlowin](https://github.com/jlowin) in [#2732](https://github.com/jlowin/fastmcp/pull/2732) +* Make ToolResult a BaseModel for serialization support by [@jlowin](https://github.com/jlowin) in [#2736](https://github.com/jlowin/fastmcp/pull/2736) +* Align prompt handler with resource pattern by [@jlowin](https://github.com/jlowin) in [#2740](https://github.com/jlowin/fastmcp/pull/2740) +* Update classes to inherit from FastMCPBaseModel instead of BaseModel by [@jlowin](https://github.com/jlowin) in [#2739](https://github.com/jlowin/fastmcp/pull/2739) +* Convert provider tests to use direct server calls by [@jlowin](https://github.com/jlowin) in [#2748](https://github.com/jlowin/fastmcp/pull/2748) +* Add explicit task_meta parameter to FastMCP.call_tool() by [@jlowin](https://github.com/jlowin) in [#2749](https://github.com/jlowin/fastmcp/pull/2749) +* Add task_meta parameter to read_resource() for explicit task control by [@jlowin](https://github.com/jlowin) in [#2750](https://github.com/jlowin/fastmcp/pull/2750) +* Add task_meta to prompts and centralize fn_key enrichment by [@jlowin](https://github.com/jlowin) in [#2751](https://github.com/jlowin/fastmcp/pull/2751) +* Remove unused include_tags/exclude_tags settings by [@jlowin](https://github.com/jlowin) in [#2756](https://github.com/jlowin/fastmcp/pull/2756) +* Parallelize provider access when executing components by [@jlowin](https://github.com/jlowin) in [#2744](https://github.com/jlowin/fastmcp/pull/2744) +* Add tests for OAuth generator cleanup and use aclosing by [@jlowin](https://github.com/jlowin) in [#2759](https://github.com/jlowin/fastmcp/pull/2759) +* Deprecate tool_serializer parameter by [@jlowin](https://github.com/jlowin) in [#2753](https://github.com/jlowin/fastmcp/pull/2753) +* Feature/supabase custom auth route by [@EloiZalczer](https://github.com/EloiZalczer) in [#2632](https://github.com/jlowin/fastmcp/pull/2632) +* Add regression tests for caching with mounted server prefixes by [@jlowin](https://github.com/jlowin) in [#2762](https://github.com/jlowin/fastmcp/pull/2762) +* Update CLI banner with FastMCP 3.0 notice by [@jlowin](https://github.com/jlowin) in [#2766](https://github.com/jlowin/fastmcp/pull/2766) +* Make FASTMCP_SHOW_SERVER_BANNER apply to all server startup methods by [@jlowin](https://github.com/jlowin) in [#2771](https://github.com/jlowin/fastmcp/pull/2771) +* Add MCP tool annotations to smart_home example by [@triepod-ai](https://github.com/triepod-ai) in [#2777](https://github.com/jlowin/fastmcp/pull/2777) +* Cherry-pick debug logging for OAuth token expiry to main by [@jlowin](https://github.com/jlowin) in [#2797](https://github.com/jlowin/fastmcp/pull/2797) +* Turn off negative CLI flags by default by [@jlowin](https://github.com/jlowin) in [#2801](https://github.com/jlowin/fastmcp/pull/2801) +* Configure ty to fail on warnings by [@jlowin](https://github.com/jlowin) in [#2804](https://github.com/jlowin/fastmcp/pull/2804) +* Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2814](https://github.com/jlowin/fastmcp/pull/2814) +* Add v3.0 feature tracking document by [@jlowin](https://github.com/jlowin) in [#2822](https://github.com/jlowin/fastmcp/pull/2822) +* Remove deprecated WSTransport by [@jlowin](https://github.com/jlowin) in [#2826](https://github.com/jlowin/fastmcp/pull/2826) +* Add composable lifespans by [@jlowin](https://github.com/jlowin) in [#2828](https://github.com/jlowin/fastmcp/pull/2828) +* Replace FastMCP.as_proxy() with create_proxy() function by [@jlowin](https://github.com/jlowin) in [#2829](https://github.com/jlowin/fastmcp/pull/2829) +* Add docs-broken-links command and fix docstring markdown parsing by [@jlowin](https://github.com/jlowin) in [#2830](https://github.com/jlowin/fastmcp/pull/2830) +* Add PingMiddleware for keepalive connections by [@jlowin](https://github.com/jlowin) in [#2838](https://github.com/jlowin/fastmcp/pull/2838) +* Add CLI update notifications by [@jlowin](https://github.com/jlowin) in [#2840](https://github.com/jlowin/fastmcp/pull/2840) +* Add agent skills for testing and code review by [@jlowin](https://github.com/jlowin) in [#2846](https://github.com/jlowin/fastmcp/pull/2846) +* Add loq pre-commit hook for file size enforcement by [@jlowin](https://github.com/jlowin) in [#2847](https://github.com/jlowin/fastmcp/pull/2847) +* Add transport property to Context by [@jlowin](https://github.com/jlowin) in [#2850](https://github.com/jlowin/fastmcp/pull/2850) +* Add loq file size limits and clean up type ignores by [@jlowin](https://github.com/jlowin) in [#2859](https://github.com/jlowin/fastmcp/pull/2859) +* Run sync tools/resources/prompts in threadpool automatically by [@jlowin](https://github.com/jlowin) in [#2865](https://github.com/jlowin/fastmcp/pull/2865) +* Add timeout parameter for tool foreground execution by [@jlowin](https://github.com/jlowin) in [#2872](https://github.com/jlowin/fastmcp/pull/2872) +* Adopt OpenTelemetry MCP semantic conventions by [@chrisguidry](https://github.com/chrisguidry) in [#2886](https://github.com/jlowin/fastmcp/pull/2886) +* Add client_secret_post authentication to IntrospectionTokenVerifier by [@shulkx](https://github.com/shulkx) in [#2884](https://github.com/jlowin/fastmcp/pull/2884) +* Add enable_rich_logging setting to disable rich formatting by [@strawgate](https://github.com/strawgate) in [#2893](https://github.com/jlowin/fastmcp/pull/2893) +* Rename _fastmcp metadata namespace to fastmcp and make non-optional by [@jlowin](https://github.com/jlowin) in [#2895](https://github.com/jlowin/fastmcp/pull/2895) +* Refactor FastMCP to inherit from Provider by [@jlowin](https://github.com/jlowin) in [#2901](https://github.com/jlowin/fastmcp/pull/2901) +* Swap public/private method naming in Provider by [@jlowin](https://github.com/jlowin) in [#2902](https://github.com/jlowin/fastmcp/pull/2902) +* Add MCP-compliant pagination support by [@jlowin](https://github.com/jlowin) in [#2903](https://github.com/jlowin/fastmcp/pull/2903) +* Support VersionSpec in enable/disable for range-based filtering by [@jlowin](https://github.com/jlowin) in [#2914](https://github.com/jlowin/fastmcp/pull/2914) +* Remove sync notification infrastructure by [@jlowin](https://github.com/jlowin) in [#2915](https://github.com/jlowin/fastmcp/pull/2915) +* Immutable transform wrapping for providers by [@jlowin](https://github.com/jlowin) in [#2913](https://github.com/jlowin/fastmcp/pull/2913) +* Unify discovery API: deduplicate at protocol layer only by [@jlowin](https://github.com/jlowin) in [#2919](https://github.com/jlowin/fastmcp/pull/2919) +* Split transports.py into modular structure by [@jlowin](https://github.com/jlowin) in [#2921](https://github.com/jlowin/fastmcp/pull/2921) +* Move session visibility logic to enabled.py by [@jlowin](https://github.com/jlowin) in [#2924](https://github.com/jlowin/fastmcp/pull/2924) +* Refactor Client class into mixins and add timeout utilities by [@jlowin](https://github.com/jlowin) in [#2933](https://github.com/jlowin/fastmcp/pull/2933) +* Refactor OAuthProxy into focused modules by [@jlowin](https://github.com/jlowin) in [#2935](https://github.com/jlowin/fastmcp/pull/2935) +* Refactor LocalProvider into mixin modules by [@jlowin](https://github.com/jlowin) in [#2936](https://github.com/jlowin/fastmcp/pull/2936) +* Refactor server.py into mixins by [@jlowin](https://github.com/jlowin) in [#2939](https://github.com/jlowin/fastmcp/pull/2939) +* Consolidate test fixtures and refactor large test files by [@jlowin](https://github.com/jlowin) in [#2941](https://github.com/jlowin/fastmcp/pull/2941) +* Refactor transform list methods to pure function pattern by [@jlowin](https://github.com/jlowin) in [#2942](https://github.com/jlowin/fastmcp/pull/2942) +* Add ResourcesAsTools transform by [@jlowin](https://github.com/jlowin) in [#2943](https://github.com/jlowin/fastmcp/pull/2943) +* Add PromptsAsTools transform by [@jlowin](https://github.com/jlowin) in [#2946](https://github.com/jlowin/fastmcp/pull/2946) +* Add client utilities for downloading skills by [@jlowin](https://github.com/jlowin) in [#2948](https://github.com/jlowin/fastmcp/pull/2948) +* Rename Enabled transform to Visibility by [@jlowin](https://github.com/jlowin) in [#2950](https://github.com/jlowin/fastmcp/pull/2950) +### Fixes 🐞 +* Let FastMCPError propagate from dependencies by [@chrisguidry](https://github.com/chrisguidry) in [#2646](https://github.com/jlowin/fastmcp/pull/2646) +* Fix task execution for tools with custom names by [@chrisguidry](https://github.com/chrisguidry) in [#2645](https://github.com/jlowin/fastmcp/pull/2645) +* fix: check the cause of the tool error by [@rjolaverria](https://github.com/rjolaverria) in [#2674](https://github.com/jlowin/fastmcp/pull/2674) +* Bump pydocket to 0.16.3 for task cancellation support by [@chrisguidry](https://github.com/chrisguidry) in [#2683](https://github.com/jlowin/fastmcp/pull/2683) +* Fix uvicorn 0.39+ test timeouts and FastMCPError propagation by [@jlowin](https://github.com/jlowin) in [#2699](https://github.com/jlowin/fastmcp/pull/2699) +* Fix Prefect website URL in docs footer by [@mgoldsborough](https://github.com/mgoldsborough) in [#2701](https://github.com/jlowin/fastmcp/pull/2701) +* Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2720](https://github.com/jlowin/fastmcp/pull/2720) +* Fix Provider.get_tasks() to include custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2729](https://github.com/jlowin/fastmcp/pull/2729) +* Fix Proxy provider to return all resource contents by [@jlowin](https://github.com/jlowin) in [#2742](https://github.com/jlowin/fastmcp/pull/2742) +* Fix prompt return type documentation by [@jlowin](https://github.com/jlowin) in [#2741](https://github.com/jlowin/fastmcp/pull/2741) +* fix: Client OAuth async_auth_flow() method causing MCP-SDK self.context.lock error. by [@lgndluke](https://github.com/lgndluke) in [#2644](https://github.com/jlowin/fastmcp/pull/2644) +* Fix rate limit detection during teardown phase by [@jlowin](https://github.com/jlowin) in [#2757](https://github.com/jlowin/fastmcp/pull/2757) +* fix: set pytest-asyncio default fixture loop scope to function by [@jlowin](https://github.com/jlowin) in [#2758](https://github.com/jlowin/fastmcp/pull/2758) +* Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2764](https://github.com/jlowin/fastmcp/pull/2764) +* [BugFix] Fix `openapi_version` Check So 3.1 Is Included by [@deeleeramone](https://github.com/deeleeramone) in [#2768](https://github.com/jlowin/fastmcp/pull/2768) +* Fix titled enum elicitation schema to comply with MCP spec by [@jlowin](https://github.com/jlowin) in [#2773](https://github.com/jlowin/fastmcp/pull/2773) +* Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2776](https://github.com/jlowin/fastmcp/pull/2776) +* Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2784](https://github.com/jlowin/fastmcp/pull/2784) +* Fix OAuth token storage TTL calculation by [@jlowin](https://github.com/jlowin) in [#2796](https://github.com/jlowin/fastmcp/pull/2796) +* Use consistent refresh_ttl for JTI mapping store by [@jlowin](https://github.com/jlowin) in [#2799](https://github.com/jlowin/fastmcp/pull/2799) +* Return 401 for invalid_grant token errors per MCP spec by [@jlowin](https://github.com/jlowin) in [#2800](https://github.com/jlowin/fastmcp/pull/2800) +* Fix client hanging on HTTP 4xx/5xx errors by [@jlowin](https://github.com/jlowin) in [#2803](https://github.com/jlowin/fastmcp/pull/2803) +* Fix unawaited coroutine warning and treat as test error by [@jlowin](https://github.com/jlowin) in [#2806](https://github.com/jlowin/fastmcp/pull/2806) +* Fix keep_alive passthrough in StdioMCPServer.to_transport() by [@jlowin](https://github.com/jlowin) in [#2791](https://github.com/jlowin/fastmcp/pull/2791) +* Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2808](https://github.com/jlowin/fastmcp/pull/2808) +* Prefix Redis keys with docket name for ACL isolation by [@chrisguidry](https://github.com/chrisguidry) in [#2811](https://github.com/jlowin/fastmcp/pull/2811) +* fix smart_home example: HueAttributes schema and deprecated prefix by [@zzstoatzz](https://github.com/zzstoatzz) in [#2818](https://github.com/jlowin/fastmcp/pull/2818) +* Fix redirect URI validation docs to match implementation by [@jlowin](https://github.com/jlowin) in [#2824](https://github.com/jlowin/fastmcp/pull/2824) +* Fix timeout not propagating to proxy clients in multi-server MCPConfig by [@jlowin](https://github.com/jlowin) in [#2809](https://github.com/jlowin/fastmcp/pull/2809) +* Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2844](https://github.com/jlowin/fastmcp/pull/2844) +* Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2849](https://github.com/jlowin/fastmcp/pull/2849) +* Fix decorator error messages to link to correct doc pages by [@jlowin](https://github.com/jlowin) in [#2858](https://github.com/jlowin/fastmcp/pull/2858) +* Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2875](https://github.com/jlowin/fastmcp/pull/2875) +* Bump the uv group across 1 directory with 2 updates by [@dependabot](https://github.com/dependabot)\[bot\] in [#2890](https://github.com/jlowin/fastmcp/pull/2890) +### Breaking Changes 🛫 +* Add VisibilityFilter for hierarchical enable/disable by [@jlowin](https://github.com/jlowin) in [#2708](https://github.com/jlowin/fastmcp/pull/2708) +* Remove automatic environment variable loading from auth providers by [@jlowin](https://github.com/jlowin) in [#2752](https://github.com/jlowin/fastmcp/pull/2752) +* Make pydocket optional and unify DI systems by [@jlowin](https://github.com/jlowin) in [#2835](https://github.com/jlowin/fastmcp/pull/2835) +* Add session-scoped state persistence by [@jlowin](https://github.com/jlowin) in [#2873](https://github.com/jlowin/fastmcp/pull/2873) +### Docs 📚 +* Undocumented `McpError` exceptions by [@ivanbelenky](https://github.com/ivanbelenky) in [#2656](https://github.com/jlowin/fastmcp/pull/2656) +* docs(server): add http to transport options in run() method docstring by [@Ashif4354](https://github.com/Ashif4354) in [#2707](https://github.com/jlowin/fastmcp/pull/2707) +* Add v3 breaking changes notice to README by [@jlowin](https://github.com/jlowin) in [#2712](https://github.com/jlowin/fastmcp/pull/2712) +* Add changelog entries for v2.13.1 through v2.14.1 by [@jlowin](https://github.com/jlowin) in [#2725](https://github.com/jlowin/fastmcp/pull/2725) +* Reorganize docs around provider architecture by [@jlowin](https://github.com/jlowin) in [#2723](https://github.com/jlowin/fastmcp/pull/2723) +* Fix documentation to use 'meta' instead of '_meta' for MCP spec field by [@jlowin](https://github.com/jlowin) in [#2735](https://github.com/jlowin/fastmcp/pull/2735) +* Enhance documentation on tool transformation by [@shea-parkes](https://github.com/shea-parkes) in [#2781](https://github.com/jlowin/fastmcp/pull/2781) +* Add FastMCP 4.0 preview to documentation by [@jlowin](https://github.com/jlowin) in [#2831](https://github.com/jlowin/fastmcp/pull/2831) +* Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2852](https://github.com/jlowin/fastmcp/pull/2852) +* Add missing 3.0.0 version badges and document tasks extra by [@jlowin](https://github.com/jlowin) in [#2866](https://github.com/jlowin/fastmcp/pull/2866) +* Fix custom provider docs to show correct interface by [@jlowin](https://github.com/jlowin) in [#2920](https://github.com/jlowin/fastmcp/pull/2920) +* Update v3 features that were missed in PRs by [@jlowin](https://github.com/jlowin) in [#2947](https://github.com/jlowin/fastmcp/pull/2947) +* Restructure documentation for FastMCP 3.0 by [@jlowin](https://github.com/jlowin) in [#2951](https://github.com/jlowin/fastmcp/pull/2951) +* Fix broken documentation links by [@jlowin](https://github.com/jlowin) in [#2952](https://github.com/jlowin/fastmcp/pull/2952) +* Clarify installation for FastMCP 3.0 beta by [@jlowin](https://github.com/jlowin) in [#2953](https://github.com/jlowin/fastmcp/pull/2953) +### Dependencies 📦 +* Bump peter-evans/create-pull-request from 7 to 8 by [@dependabot](https://github.com/dependabot)\[bot\] in [#2623](https://github.com/jlowin/fastmcp/pull/2623) +* Bump ty to 0.0.7+ by [@jlowin](https://github.com/jlowin) in [#2737](https://github.com/jlowin/fastmcp/pull/2737) +* Bump the uv group across 1 directory with 4 updates by [@dependabot](https://github.com/dependabot)\[bot\] in [#2891](https://github.com/jlowin/fastmcp/pull/2891) + +## New Contributors +* [@ivanbelenky](https://github.com/ivanbelenky) made their first contribution in [#2656](https://github.com/jlowin/fastmcp/pull/2656) +* [@rjolaverria](https://github.com/rjolaverria) made their first contribution in [#2674](https://github.com/jlowin/fastmcp/pull/2674) +* [@mgoldsborough](https://github.com/mgoldsborough) made their first contribution in [#2701](https://github.com/jlowin/fastmcp/pull/2701) +* [@Ashif4354](https://github.com/Ashif4354) made their first contribution in [#2707](https://github.com/jlowin/fastmcp/pull/2707) +* [@majiayu000](https://github.com/majiayu000) made their first contribution in [#2720](https://github.com/jlowin/fastmcp/pull/2720) +* [@lgndluke](https://github.com/lgndluke) made their first contribution in [#2644](https://github.com/jlowin/fastmcp/pull/2644) +* [@EloiZalczer](https://github.com/EloiZalczer) made their first contribution in [#2632](https://github.com/jlowin/fastmcp/pull/2632) +* [@deeleeramone](https://github.com/deeleeramone) made their first contribution in [#2768](https://github.com/jlowin/fastmcp/pull/2768) +* [@shea-parkes](https://github.com/shea-parkes) made their first contribution in [#2781](https://github.com/jlowin/fastmcp/pull/2781) +* [@triepod-ai](https://github.com/triepod-ai) made their first contribution in [#2777](https://github.com/jlowin/fastmcp/pull/2777) +* [@bhbs](https://github.com/bhbs) made their first contribution in [#2776](https://github.com/jlowin/fastmcp/pull/2776) +* [@shulkx](https://github.com/shulkx) made their first contribution in [#2884](https://github.com/jlowin/fastmcp/pull/2884) + +**Full Changelog**: [v2.14.1...v3.0.0b1](https://github.com/jlowin/fastmcp/compare/v2.14.1...v3.0.0b1) + + + + + +**[v2.14.5: Sealed Docket](https://github.com/jlowin/fastmcp/releases/tag/v2.14.5)** + +Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2. + +## What's Changed +### Enhancements 🔧 +* Bump pydocket to 0.17.2 (memory leak fix) by [@chrisguidry](https://github.com/chrisguidry) in [#2992](https://github.com/jlowin/fastmcp/pull/2992) + +**Full Changelog**: [v2.14.4...v2.14.5](https://github.com/jlowin/fastmcp/compare/v2.14.4...v2.14.5) + + + + + +**[v2.14.4: Package Deal](https://github.com/jlowin/fastmcp/releases/tag/v2.14.4)** + +Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports from 3.x for $ref dereferencing in tool schemas and a task capabilities location fix. + +## What's Changed +### Enhancements 🔧 +* Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2851](https://github.com/jlowin/fastmcp/pull/2851) +### Fixes 🐞 +* Backport: Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2861](https://github.com/jlowin/fastmcp/pull/2861) +* Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2874](https://github.com/jlowin/fastmcp/pull/2874) +* Add missing packaging dependency by [@jlowin](https://github.com/jlowin) in [#2989](https://github.com/jlowin/fastmcp/pull/2989) + +**Full Changelog**: [v2.14.3...v2.14.4](https://github.com/jlowin/fastmcp/compare/v2.14.3...v2.14.4) + + + **[v2.14.3: Time After Timeout](https://github.com/jlowin/fastmcp/releases/tag/v2.14.3)** diff --git a/docs/updates.mdx b/docs/updates.mdx index b048d8640..26ab3be1e 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -25,6 +25,26 @@ FastMCP 3.0 rebuilds the framework around three primitives: components, provider + + +Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2. + + + + + +Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports $ref dereferencing in tool schemas and a task capabilities location fix. + + + + +**[v2.14.5: Sealed Docket](https://github.com/jlowin/fastmcp/releases/tag/v2.14.5)** + +Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2. + +## What's Changed +### Enhancements 🔧 +* Bump pydocket to 0.17.2 (memory leak fix) by [@chrisguidry](https://github.com/chrisguidry) in [#2992](https://github.com/jlowin/fastmcp/pull/2992) + +**Full Changelog**: [v2.14.4...v2.14.5](https://github.com/jlowin/fastmcp/compare/v2.14.4...v2.14.5) + + + + + +**[v2.14.4: Package Deal](https://github.com/jlowin/fastmcp/releases/tag/v2.14.4)** + +Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports from 3.x for $ref dereferencing in tool schemas and a task capabilities location fix. + +## What's Changed +### Enhancements 🔧 +* Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2851](https://github.com/jlowin/fastmcp/pull/2851) +### Fixes 🐞 +* Backport: Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2861](https://github.com/jlowin/fastmcp/pull/2861) +* Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2874](https://github.com/jlowin/fastmcp/pull/2874) +* Add missing packaging dependency by [@jlowin](https://github.com/jlowin) in [#2989](https://github.com/jlowin/fastmcp/pull/2989) + +**Full Changelog**: [v2.14.3...v2.14.4](https://github.com/jlowin/fastmcp/compare/v2.14.3...v2.14.4) + + + **[v2.14.3: Time After Timeout](https://github.com/jlowin/fastmcp/releases/tag/v2.14.3)** diff --git a/docs/v2/updates.mdx b/docs/v2/updates.mdx index 021ac2357..a846a6ba6 100644 --- a/docs/v2/updates.mdx +++ b/docs/v2/updates.mdx @@ -5,6 +5,26 @@ icon: "sparkles" tag: NEW --- + + +Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2. + + + + + +Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports $ref dereferencing in tool schemas and a task capabilities location fix. + + + Date: Tue, 3 Feb 2026 21:08:09 -0500 Subject: [PATCH 11/63] Remove OpenAPI timeout parameter, make client optional, surface timeout errors (#3067) * Remove OpenAPI timeout param, make client optional, surface timeout errors * Close auto-created httpx client via provider lifespan --- docs/development/upgrade-guide.mdx | 13 ++++++ src/fastmcp/server/openapi/server.py | 9 ++-- .../server/providers/openapi/components.py | 18 ++++---- .../server/providers/openapi/provider.py | 42 +++++++++++++++---- src/fastmcp/server/server.py | 15 +++---- .../providers/openapi/test_comprehensive.py | 24 +++++++++++ tests/server/providers/openapi/test_server.py | 24 +++++++---- 7 files changed, 102 insertions(+), 43 deletions(-) diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx index 247e03a2b..8bfe713af 100644 --- a/docs/development/upgrade-guide.mdx +++ b/docs/development/upgrade-guide.mdx @@ -95,6 +95,19 @@ value = await ctx.get_state("key") `FASTMCP_SHOW_CLI_BANNER` is now `FASTMCP_SHOW_SERVER_BANNER`. +#### OpenAPI `timeout` Parameter Removed + +Configure timeout on the httpx client directly. The `client` parameter is now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout. + +```python +# Before +provider = OpenAPIProvider(spec, client, timeout=60) + +# After +client = httpx.AsyncClient(base_url="https://api.example.com", timeout=60) +provider = OpenAPIProvider(spec, client) +``` + #### Metadata Namespace Renamed The FastMCP metadata namespace changed from `_fastmcp` to `fastmcp`, and metadata is now always included. The `include_fastmcp_meta` parameter has been removed from `FastMCP()` and `to_mcp_tool()`—remove any usage of this parameter. diff --git a/src/fastmcp/server/openapi/server.py b/src/fastmcp/server/openapi/server.py index 2a6b4a8b4..3171ca763 100644 --- a/src/fastmcp/server/openapi/server.py +++ b/src/fastmcp/server/openapi/server.py @@ -60,14 +60,13 @@ class FastMCPOpenAPI(FastMCP): def __init__( self, openapi_spec: dict[str, Any], - client: httpx.AsyncClient, + client: httpx.AsyncClient | None = None, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: RouteMapFn | None = None, mcp_component_fn: ComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, - timeout: float | None = None, **settings: Any, ): """Initialize a FastMCP server from an OpenAPI schema. @@ -77,14 +76,14 @@ class FastMCPOpenAPI(FastMCP): Args: openapi_spec: OpenAPI schema as a dictionary - client: httpx AsyncClient for making HTTP requests + client: Optional httpx AsyncClient for making HTTP requests. + If not provided, a default client is created from the spec. name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components - timeout: Optional timeout (in seconds) for all requests **settings: Additional settings for FastMCP """ warnings.warn( @@ -99,7 +98,6 @@ class FastMCPOpenAPI(FastMCP): # Store references for backwards compatibility self._client = client - self._timeout = timeout self._mcp_component_fn = mcp_component_fn # Create provider with the client @@ -111,7 +109,6 @@ class FastMCPOpenAPI(FastMCP): mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, - timeout=timeout, ) self.add_provider(provider) diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index a671ff69f..43c58b956 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -76,7 +76,6 @@ class OpenAPITool(Tool): parameters: dict[str, Any], output_schema: dict[str, Any] | None = None, tags: set[str] | None = None, - timeout: float | None = None, annotations: ToolAnnotations | None = None, serializer: Callable[[Any], str] | None = None, # Deprecated ): @@ -100,7 +99,6 @@ class OpenAPITool(Tool): self._client = client self._route = route self._director = director - self._timeout = timeout def __repr__(self) -> str: return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})" @@ -160,8 +158,11 @@ class OpenAPITool(Tool): error_message += f" - {e.response.text}" raise ValueError(error_message) from e + except httpx.TimeoutException as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + except httpx.RequestError as e: - raise ValueError(f"Request error: {e!s}") from e + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e class OpenAPIResource(Resource): @@ -179,7 +180,6 @@ class OpenAPIResource(Resource): description: str, mime_type: str = "application/json", tags: set[str] | None = None, - timeout: float | None = None, ): super().__init__( uri=AnyUrl(uri), @@ -191,7 +191,6 @@ class OpenAPIResource(Resource): self._client = client self._route = route self._director = director - self._timeout = timeout def __repr__(self) -> str: return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})" @@ -232,7 +231,6 @@ class OpenAPIResource(Resource): method=self._route.method, url=path, headers=headers, - timeout=self._timeout, ) response.raise_for_status() @@ -274,8 +272,11 @@ class OpenAPIResource(Resource): error_message += f" - {e.response.text}" raise ValueError(error_message) from e + except httpx.TimeoutException as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + except httpx.RequestError as e: - raise ValueError(f"Request error: {e!s}") from e + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e class OpenAPIResourceTemplate(ResourceTemplate): @@ -293,7 +294,6 @@ class OpenAPIResourceTemplate(ResourceTemplate): description: str, parameters: dict[str, Any], tags: set[str] | None = None, - timeout: float | None = None, ): super().__init__( uri_template=uri_template, @@ -305,7 +305,6 @@ class OpenAPIResourceTemplate(ResourceTemplate): self._client = client self._route = route self._director = director - self._timeout = timeout def __repr__(self) -> str: return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})" @@ -328,5 +327,4 @@ class OpenAPIResourceTemplate(ResourceTemplate): description=self.description or f"Resource for {self._route.path}", mime_type="application/json", tags=set(self._route.tags or []), - timeout=self._timeout, ) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index 7f13fb4da..a93be1a41 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -3,7 +3,8 @@ from __future__ import annotations from collections import Counter -from collections.abc import Sequence +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager from typing import Any, Literal import httpx @@ -44,6 +45,8 @@ __all__ = [ logger = get_logger(__name__) +DEFAULT_TIMEOUT: float = 30.0 + class OpenAPIProvider(Provider): """Provider that creates MCP components from an OpenAPI specification. @@ -68,31 +71,34 @@ class OpenAPIProvider(Provider): def __init__( self, openapi_spec: dict[str, Any], - client: httpx.AsyncClient, + client: httpx.AsyncClient | None = None, *, route_maps: list[RouteMap] | None = None, route_map_fn: RouteMapFn | None = None, mcp_component_fn: ComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, - timeout: float | None = None, ): """Initialize provider by parsing OpenAPI spec and creating components. Args: openapi_spec: OpenAPI schema as a dictionary - client: httpx AsyncClient for making HTTP requests + client: Optional httpx AsyncClient for making HTTP requests. + If not provided, a default client is created using the first + server URL from the OpenAPI spec with a 30-second timeout. + To customize timeout or other settings, pass your own client. route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components - timeout: Optional timeout (in seconds) for all requests """ super().__init__() + self._owns_client = client is None + if client is None: + client = self._create_default_client(openapi_spec) self._client = client - self._timeout = timeout self._mcp_component_fn = mcp_component_fn # Keep track of names to detect collisions @@ -153,6 +159,27 @@ class OpenAPIProvider(Provider): logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes") + @classmethod + def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient: + """Create a default httpx client from the OpenAPI spec's server URL.""" + servers = openapi_spec.get("servers", []) + if not servers or not servers[0].get("url"): + raise ValueError( + "No server URL found in OpenAPI spec. Either add a 'servers' " + "entry to the spec or provide an httpx.AsyncClient explicitly." + ) + base_url = servers[0]["url"] + return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT) + + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + """Manage the lifecycle of the auto-created httpx client.""" + if self._owns_client: + async with self._client: + yield + else: + yield + def _generate_default_name( self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None ) -> str: @@ -225,7 +252,6 @@ class OpenAPIProvider(Provider): parameters=combined_schema, output_schema=output_schema, tags=set(route.tags or []) | tags, - timeout=self._timeout, ) if self._mcp_component_fn is not None: @@ -263,7 +289,6 @@ class OpenAPIProvider(Provider): name=resource_name, description=enhanced_description, tags=set(route.tags or []) | tags, - timeout=self._timeout, ) if self._mcp_component_fn is not None: @@ -331,7 +356,6 @@ class OpenAPIProvider(Provider): description=enhanced_description, parameters=template_params_schema, tags=set(route.tags or []) | tags, - timeout=self._timeout, ) if self._mcp_component_fn is not None: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index a09b58a6e..1172793c9 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1969,14 +1969,13 @@ class FastMCP( def from_openapi( cls, openapi_spec: dict[str, Any], - client: httpx.AsyncClient, + client: httpx.AsyncClient | None = None, name: str = "OpenAPI Server", route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, - timeout: float | None = None, **settings: Any, ) -> Self: """ @@ -1984,14 +1983,15 @@ class FastMCP( Args: openapi_spec: OpenAPI schema as a dictionary - client: httpx AsyncClient for making HTTP requests + client: Optional httpx AsyncClient for making HTTP requests. + If not provided, a default client is created using the first + server URL from the OpenAPI spec with a 30-second timeout. name: Name for the MCP server route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components - timeout: Optional timeout (in seconds) for all requests **settings: Additional settings passed to FastMCP Returns: @@ -2007,7 +2007,6 @@ class FastMCP( mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, - timeout=timeout, ) return cls(name=name, providers=[provider], **settings) @@ -2022,7 +2021,6 @@ class FastMCP( mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, - timeout: float | None = None, **settings: Any, ) -> Self: """ @@ -2035,9 +2033,9 @@ class FastMCP( route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names - httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient + httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient. + Use this to configure timeout and other client settings. tags: Optional set of tags to add to all components - timeout: Optional timeout (in seconds) for all requests **settings: Additional settings passed to FastMCP Returns: @@ -2064,7 +2062,6 @@ class FastMCP( mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, - timeout=timeout, ) return cls(name=server_name, providers=[provider], **settings) diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py index 62b137af7..f55786e65 100644 --- a/tests/server/providers/openapi/test_comprehensive.py +++ b/tests/server/providers/openapi/test_comprehensive.py @@ -739,3 +739,27 @@ class TestOpenAPIComprehensive: assert provider is not None assert hasattr(provider, "_director") assert hasattr(provider, "_spec") + + async def test_timeout_error_produces_useful_message( + self, comprehensive_openapi_spec + ): + """ReadTimeout should surface a clear error, not an empty string.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + # httpx internally raises ReadTimeout with an empty message + mock_client.send = AsyncMock(side_effect=httpx.ReadTimeout("")) + + server = create_openapi_server( + openapi_spec=comprehensive_openapi_spec, + client=mock_client, + ) + + async with Client(server) as mcp_client: + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool("get_user", {"id": 1}) + + error_message = str(exc_info.value) + assert "timed out" in error_message + assert "ReadTimeout" in error_message diff --git a/tests/server/providers/openapi/test_server.py b/tests/server/providers/openapi/test_server.py index 59d037ba8..0d7447eab 100644 --- a/tests/server/providers/openapi/test_server.py +++ b/tests/server/providers/openapi/test_server.py @@ -6,6 +6,7 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.providers.openapi.provider import DEFAULT_TIMEOUT class TestOpenAPIProviderBasicFunctionality: @@ -146,16 +147,21 @@ class TestOpenAPIProviderBasicFunctionality: assert get_user_tool is not None assert get_user_tool.description is not None - def test_provider_with_timeout(self, simple_openapi_spec): - """Test provider initialization with timeout setting.""" - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider( - openapi_spec=simple_openapi_spec, - client=client, - timeout=30.0, - ) + def test_provider_creates_default_client_from_spec(self, simple_openapi_spec): + """Test that omitting client creates one from the spec's servers URL.""" + provider = OpenAPIProvider(openapi_spec=simple_openapi_spec) + assert str(provider._client.base_url).rstrip("/") == "https://api.example.com" + assert provider._client.timeout == httpx.Timeout(DEFAULT_TIMEOUT) - assert provider._timeout == 30.0 + def test_provider_default_client_requires_servers(self): + """Test that omitting client without servers in spec raises.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "No Servers", "version": "1.0.0"}, + "paths": {}, + } + with pytest.raises(ValueError, match="No server URL"): + OpenAPIProvider(openapi_spec=spec) def test_provider_with_empty_spec(self): """Test provider with minimal OpenAPI spec.""" From f6988c920604e223923b3bcd5aed678742dbbf28 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:08:20 -0500 Subject: [PATCH 12/63] chore: Update SDK documentation (#2995) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/docs.json | 7 +- docs/python-sdk/fastmcp-cli-cli.mdx | 12 +- docs/python-sdk/fastmcp-cli-client.mdx | 133 +++++++++++++++++ docs/python-sdk/fastmcp-cli-discovery.mdx | 71 +++++++++ .../python-sdk/fastmcp-cli-install-cursor.mdx | 12 +- docs/python-sdk/fastmcp-cli-install-goose.mdx | 67 +++++++++ .../python-sdk/fastmcp-cli-install-shared.mdx | 21 ++- docs/python-sdk/fastmcp-cli-install-stdio.mdx | 50 +++++++ docs/python-sdk/fastmcp-cli-run.mdx | 14 +- .../fastmcp-client-transports-config.mdx | 2 +- .../fastmcp-prompts-function_prompt.mdx | 16 +- docs/python-sdk/fastmcp-prompts-prompt.mdx | 26 ++-- .../fastmcp-resources-function_resource.mdx | 14 +- .../python-sdk/fastmcp-resources-resource.mdx | 39 +++-- .../python-sdk/fastmcp-resources-template.mdx | 44 +++--- docs/python-sdk/fastmcp-server-apps.mdx | 87 +++++++++++ docs/python-sdk/fastmcp-server-auth-auth.mdx | 30 +++- .../fastmcp-server-auth-jwt_issuer.mdx | 10 +- .../fastmcp-server-auth-oauth_proxy-proxy.mdx | 26 ++-- .../fastmcp-server-auth-providers-azure.mdx | 54 ++++++- .../fastmcp-server-auth-providers-jwt.mdx | 8 +- docs/python-sdk/fastmcp-server-context.mdx | 140 ++++++++++++------ .../fastmcp-server-dependencies.mdx | 123 +++++++++++---- docs/python-sdk/fastmcp-server-low_level.mdx | 30 ++-- ...stmcp-server-middleware-error_handling.mdx | 8 +- ...tmcp-server-providers-fastmcp_provider.mdx | 38 ++--- docs/python-sdk/fastmcp-server-server.mdx | 104 ++++++------- .../fastmcp-server-tasks-config.mdx | 10 +- .../fastmcp-server-tasks-elicitation.mdx | 74 +++++++++ .../fastmcp-tools-function_tool.mdx | 18 +-- docs/python-sdk/fastmcp-tools-tool.mdx | 24 +-- .../fastmcp-tools-tool_transform.mdx | 22 +-- docs/python-sdk/fastmcp-utilities-auth.mdx | 44 +++++- .../python-sdk/fastmcp-utilities-lifespan.mdx | 2 +- docs/python-sdk/fastmcp-utilities-skills.mdx | 12 +- 35 files changed, 1068 insertions(+), 324 deletions(-) create mode 100644 docs/python-sdk/fastmcp-cli-client.mdx create mode 100644 docs/python-sdk/fastmcp-cli-discovery.mdx create mode 100644 docs/python-sdk/fastmcp-cli-install-goose.mdx create mode 100644 docs/python-sdk/fastmcp-cli-install-stdio.mdx create mode 100644 docs/python-sdk/fastmcp-server-apps.mdx create mode 100644 docs/python-sdk/fastmcp-server-tasks-elicitation.mdx diff --git a/docs/docs.json b/docs/docs.json index de6369d10..b7f26ab6b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -301,6 +301,8 @@ "pages": [ "python-sdk/fastmcp-cli-__init__", "python-sdk/fastmcp-cli-cli", + "python-sdk/fastmcp-cli-client", + "python-sdk/fastmcp-cli-discovery", { "group": "install", "pages": [ @@ -311,7 +313,8 @@ "python-sdk/fastmcp-cli-install-gemini_cli", "python-sdk/fastmcp-cli-install-goose", "python-sdk/fastmcp-cli-install-mcp_json", - "python-sdk/fastmcp-cli-install-shared" + "python-sdk/fastmcp-cli-install-shared", + "python-sdk/fastmcp-cli-install-stdio" ] }, "python-sdk/fastmcp-cli-run", @@ -400,6 +403,7 @@ "group": "fastmcp.server", "pages": [ "python-sdk/fastmcp-server-__init__", + "python-sdk/fastmcp-server-apps", { "group": "auth", "pages": [ @@ -547,6 +551,7 @@ "python-sdk/fastmcp-server-tasks-__init__", "python-sdk/fastmcp-server-tasks-capabilities", "python-sdk/fastmcp-server-tasks-config", + "python-sdk/fastmcp-server-tasks-elicitation", "python-sdk/fastmcp-server-tasks-handlers", "python-sdk/fastmcp-server-tasks-keys", "python-sdk/fastmcp-server-tasks-requests", diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 759a4324d..df21e8105 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-client.mdx b/docs/python-sdk/fastmcp-cli-client.mdx new file mode 100644 index 000000000..e256d90bc --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-client.mdx @@ -0,0 +1,133 @@ +--- +title: client +sidebarTitle: client +--- + +# `fastmcp.cli.client` + + +Client-side CLI commands for querying and invoking MCP servers. + +## Functions + +### `resolve_server_spec` + +```python +resolve_server_spec(server_spec: str | None) -> str | dict[str, Any] | ClientTransport +``` + + +Turn CLI inputs into something ``Client()`` accepts. + +Exactly one of ``server_spec`` or ``command`` should be provided. + +Resolution order for ``server_spec``: +1. URLs (``http://``, ``https://``) — passed through as-is. + If ``--transport`` is ``sse``, the URL is rewritten to end with ``/sse`` + so ``infer_transport`` picks the right transport. +2. Existing file paths, or strings ending in ``.py``/``.js``/``.json``. +3. Anything else — name-based resolution via ``resolve_name``. + +When ``command`` is provided, the string is shell-split into a +``StdioTransport(command, args)``. + + +### `coerce_value` + +```python +coerce_value(raw: str, schema: dict[str, Any]) -> Any +``` + + +Coerce a string CLI value according to a JSON-Schema type hint. + + +### `parse_tool_arguments` + +```python +parse_tool_arguments(raw_args: tuple[str, ...], input_json: str | None, input_schema: dict[str, Any]) -> dict[str, Any] +``` + + +Build a tool-call argument dict from CLI inputs. + +A single JSON object argument is treated as the full argument dict. +``--input-json`` provides the base dict; ``key=value`` pairs override. +Values are coerced using the tool's ``inputSchema``. + + +### `format_tool_signature` + +```python +format_tool_signature(tool: mcp.types.Tool) -> str +``` + + +Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas. + + +### `list_command` + +```python +list_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None) -> None +``` + + +List tools available on an MCP server. + +**Examples:** + +fastmcp list http://localhost:8000/mcp +fastmcp list server.py +fastmcp list mcp.json --json +fastmcp list --command 'npx -y @mcp/server' --resources +fastmcp list http://server/mcp --transport sse + + +### `call_command` + +```python +call_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None, target: Annotated[str, cyclopts.Parameter(help='Tool name, resource URI, or prompt name (with --prompt)')] = '', *arguments: str) -> None +``` + + +Call a tool, read a resource, or get a prompt on an MCP server. + +By default the target is treated as a tool name. If the target +contains ``://`` it is treated as a resource URI. Pass ``--prompt`` +to treat it as a prompt name. + +Arguments are passed as key=value pairs. Use --input-json for complex +or nested arguments. + +**Examples:** + +fastmcp call server.py greet name=World +fastmcp call server.py resource://docs/readme +fastmcp call server.py analyze --prompt data='[1,2,3]' +fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}' + + +### `discover_command` + +```python +discover_command() -> None +``` + + +Discover MCP servers configured in editor and project configs. + +Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and +project-level mcp.json files for MCP server definitions. + +Discovered server names can be used directly with ``fastmcp list`` +and ``fastmcp call`` instead of specifying a URL or file path. + +**Examples:** + +fastmcp discover +fastmcp discover --source claude-code +fastmcp discover --source cursor --source gemini --json +fastmcp list weather +fastmcp call cursor:weather get_forecast city=London + diff --git a/docs/python-sdk/fastmcp-cli-discovery.mdx b/docs/python-sdk/fastmcp-cli-discovery.mdx new file mode 100644 index 000000000..3cc6866bd --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-discovery.mdx @@ -0,0 +1,71 @@ +--- +title: discovery +sidebarTitle: discovery +--- + +# `fastmcp.cli.discovery` + + +Discover MCP servers configured in editor config files. + +Scans filesystem-readable config files from editors like Claude Desktop, +Claude Code, Cursor, Gemini CLI, and Goose, as well as project-level +``mcp.json`` files. Each discovered server can be resolved by name +(or ``source:name``) so the CLI can connect without requiring a URL +or file path. + + +## Functions + +### `discover_servers` + +```python +discover_servers(start_dir: Path | None = None) -> list[DiscoveredServer] +``` + + +Run all scanners and return the combined results. + +Duplicate names across sources are preserved — callers can +use :pyattr:`DiscoveredServer.qualified_name` to disambiguate. + + +### `resolve_name` + +```python +resolve_name(name: str, start_dir: Path | None = None) -> ClientTransport +``` + + +Resolve a server name (or ``source:name``) to a transport. + +Raises :class:`ValueError` when the name is not found or is ambiguous. + + +## Classes + +### `DiscoveredServer` + + +A single MCP server found in an editor or project config. + + +**Methods:** + +#### `qualified_name` + +```python +qualified_name(self) -> str +``` + +Fully qualified ``source:name`` identifier. + + +#### `transport_summary` + +```python +transport_summary(self) -> str +``` + +Human-readable one-liner describing the transport. + diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx index 40c0c5c6a..adb8b0bfd 100644 --- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx +++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx @@ -10,7 +10,7 @@ Cursor integration for FastMCP install using Cyclopts. ## Functions -### `generate_cursor_deeplink` +### `generate_cursor_deeplink` ```python generate_cursor_deeplink(server_name: str, server_config: StdioMCPServer) -> str @@ -27,14 +27,14 @@ Generate a Cursor deeplink for installing the MCP server. - Deeplink URL that can be clicked to install the server -### `open_deeplink` +### `open_deeplink` ```python open_deeplink(deeplink: str) -> bool ``` -Attempt to open a deeplink URL using the system's default handler. +Attempt to open a Cursor deeplink URL using the system's default handler. **Args:** - `deeplink`: The deeplink URL to open @@ -43,7 +43,7 @@ Attempt to open a deeplink URL using the system's default handler. - True if the command succeeded, False otherwise -### `install_cursor_workspace` +### `install_cursor_workspace` ```python install_cursor_workspace(file: Path, server_object: str | None, name: str, workspace_path: Path) -> bool @@ -68,7 +68,7 @@ Install FastMCP server to workspace-specific Cursor configuration. - True if installation was successful, False otherwise -### `install_cursor` +### `install_cursor` ```python install_cursor(file: Path, server_object: str | None, name: str) -> bool @@ -93,7 +93,7 @@ Install FastMCP server in Cursor. - True if installation was successful, False otherwise -### `cursor_command` +### `cursor_command` ```python cursor_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-goose.mdx b/docs/python-sdk/fastmcp-cli-install-goose.mdx new file mode 100644 index 000000000..9db2bdf18 --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-install-goose.mdx @@ -0,0 +1,67 @@ +--- +title: goose +sidebarTitle: goose +--- + +# `fastmcp.cli.install.goose` + + +Goose integration for FastMCP install using Cyclopts. + +## Functions + +### `generate_goose_deeplink` + +```python +generate_goose_deeplink(name: str, command: str, args: list[str]) -> str +``` + + +Generate a Goose deeplink for installing an MCP extension. + +**Args:** +- `name`: Human-readable display name for the extension. +- `command`: The executable command (e.g. "uv"). +- `args`: Arguments to the command. +- `description`: Short description shown in Goose. + +**Returns:** +- A goose://extension?... deeplink URL. + + +### `install_goose` + +```python +install_goose(file: Path, server_object: str | None, name: str) -> bool +``` + + +Install FastMCP server in Goose via deeplink. + +**Args:** +- `file`: Path to the server file. +- `server_object`: Optional server object name (for \:object suffix). +- `name`: Name for the extension in Goose. +- `with_packages`: Optional list of additional packages to install. +- `python_version`: Optional Python version to use. + +**Returns:** +- True if installation was successful, False otherwise. + + +### `goose_command` + +```python +goose_command(server_spec: str) -> None +``` + + +Install an MCP server in Goose. + +Uses uvx to run the server. Environment variables are not included +in the deeplink; use `fastmcp install mcp-json` to generate a full +config for manual installation. + +**Args:** +- `server_spec`: Python file to install, optionally with \:object suffix + diff --git a/docs/python-sdk/fastmcp-cli-install-shared.mdx b/docs/python-sdk/fastmcp-cli-install-shared.mdx index 337179267..1df969961 100644 --- a/docs/python-sdk/fastmcp-cli-install-shared.mdx +++ b/docs/python-sdk/fastmcp-cli-install-shared.mdx @@ -10,7 +10,7 @@ Shared utilities for install commands. ## Functions -### `parse_env_var` +### `parse_env_var` ```python parse_env_var(env_var: str) -> tuple[str, str] @@ -20,7 +20,7 @@ parse_env_var(env_var: str) -> tuple[str, str] Parse environment variable string in format KEY=VALUE. -### `process_common_args` +### `process_common_args` ```python process_common_args(server_spec: str, server_name: str | None, with_packages: list[str] | None, env_vars: list[str] | None, env_file: Path | None) -> tuple[Path, str | None, str, list[str], dict[str, str] | None] @@ -31,3 +31,20 @@ Process common arguments shared by all install commands. Handles both fastmcp.json config files and traditional file.py:object syntax. + +### `open_deeplink` + +```python +open_deeplink(url: str) -> bool +``` + + +Attempt to open a deeplink URL using the system's default handler. + +**Args:** +- `url`: The deeplink URL to open. +- `expected_scheme`: The URL scheme to validate (e.g. "cursor", "goose"). + +**Returns:** +- True if the command succeeded, False otherwise. + diff --git a/docs/python-sdk/fastmcp-cli-install-stdio.mdx b/docs/python-sdk/fastmcp-cli-install-stdio.mdx new file mode 100644 index 000000000..d7d7c28d4 --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-install-stdio.mdx @@ -0,0 +1,50 @@ +--- +title: stdio +sidebarTitle: stdio +--- + +# `fastmcp.cli.install.stdio` + + +Stdio command generation for FastMCP install using Cyclopts. + +## Functions + +### `install_stdio` + +```python +install_stdio(file: Path, server_object: str | None) -> bool +``` + + +Generate the stdio command for running a FastMCP server. + +**Args:** +- `file`: Path to the server file +- `server_object`: Optional server object name (for \:object suffix) +- `with_editable`: Optional list of directories to install in editable mode +- `with_packages`: Optional list of additional packages to install +- `copy`: If True, copy to clipboard instead of printing to stdout +- `python_version`: Optional Python version to use +- `with_requirements`: Optional requirements file to install from +- `project`: Optional project directory to run within + +**Returns:** +- True if generation was successful, False otherwise + + +### `stdio_command` + +```python +stdio_command(server_spec: str) -> None +``` + + +Generate the stdio command for running a FastMCP server. + +Outputs the shell command that an MCP host would use to start this server +over stdio transport. Useful for manual configuration or debugging. + +**Args:** +- `server_spec`: Python file to run, optionally with \:object suffix + diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx index c3192a611..4141cd3f1 100644 --- a/docs/python-sdk/fastmcp-cli-run.mdx +++ b/docs/python-sdk/fastmcp-cli-run.mdx @@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints. ## Functions -### `is_url` +### `is_url` ```python is_url(path: str) -> bool @@ -20,7 +20,7 @@ is_url(path: str) -> bool Check if a string is a URL. -### `create_client_server` +### `create_client_server` ```python create_client_server(url: str) -> Any @@ -36,7 +36,7 @@ Create a FastMCP server from a client URL. - A FastMCP server instance -### `create_mcp_config_server` +### `create_mcp_config_server` ```python create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] @@ -46,7 +46,7 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] Create a FastMCP server from a MCPConfig. -### `load_mcp_server_config` +### `load_mcp_server_config` ```python load_mcp_server_config(config_path: Path) -> MCPServerConfig @@ -62,7 +62,7 @@ Load a FastMCP configuration from a fastmcp.json file. - MCPServerConfig object -### `run_command` +### `run_command` ```python run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, skip_source: bool = False, stateless: bool = False) -> None @@ -85,7 +85,7 @@ Run a MCP server or connect to a remote one. - `stateless`: Whether to run in stateless mode (no session) -### `run_v1_server_async` +### `run_v1_server_async` ```python run_v1_server_async(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None @@ -101,7 +101,7 @@ Run a FastMCP 1.x server using async methods. - `transport`: Transport protocol to use -### `run_with_reload` +### `run_with_reload` ```python run_with_reload(cmd: list[str], reload_dirs: list[Path] | None = None, is_stdio: bool = False) -> None diff --git a/docs/python-sdk/fastmcp-client-transports-config.mdx b/docs/python-sdk/fastmcp-client-transports-config.mdx index d19f709f2..2e802df0a 100644 --- a/docs/python-sdk/fastmcp-client-transports-config.mdx +++ b/docs/python-sdk/fastmcp-client-transports-config.mdx @@ -19,7 +19,7 @@ object or dictionary matching the MCPConfig schema. It supports two key scenario 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting all servers on a single FastMCP instance, with each server's name, by default, used as its mounting prefix. -In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}` +In the multiserver case, tools are accessible with the prefix pattern `{server_name}_{tool_name}` and resources with the pattern `protocol://{server_name}/path/to/resource`. This is particularly useful for creating clients that need to interact with multiple specialized diff --git a/docs/python-sdk/fastmcp-prompts-function_prompt.mdx b/docs/python-sdk/fastmcp-prompts-function_prompt.mdx index 3c543de88..edc9527f2 100644 --- a/docs/python-sdk/fastmcp-prompts-function_prompt.mdx +++ b/docs/python-sdk/fastmcp-prompts-function_prompt.mdx @@ -10,7 +10,7 @@ Standalone @prompt decorator for FastMCP. ## Functions -### `prompt` +### `prompt` ```python prompt(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -25,19 +25,19 @@ using mcp.add_prompt(). ## Classes -### `DecoratedPrompt` +### `DecoratedPrompt` Protocol for functions decorated with @prompt. -### `PromptMeta` +### `PromptMeta` Metadata attached to functions by the @prompt decorator. -### `FunctionPrompt` +### `FunctionPrompt` A prompt that is a function. @@ -45,7 +45,7 @@ A prompt that is a function. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionPrompt @@ -66,7 +66,7 @@ The function can return: - PromptResult: used directly -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult @@ -75,7 +75,7 @@ render(self, arguments: dict[str, Any] | None = None) -> PromptResult Render the prompt with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -87,7 +87,7 @@ FunctionPrompt registers the underlying function, which has the user's Depends parameters for docket to resolve. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any] | None, **kwargs: Any) -> Execution diff --git a/docs/python-sdk/fastmcp-prompts-prompt.mdx b/docs/python-sdk/fastmcp-prompts-prompt.mdx index 1541e9c58..b0e33f1e3 100644 --- a/docs/python-sdk/fastmcp-prompts-prompt.mdx +++ b/docs/python-sdk/fastmcp-prompts-prompt.mdx @@ -10,7 +10,7 @@ Base classes for FastMCP prompts. ## Classes -### `Message` +### `Message` Wrapper for prompt message with auto-serialization. @@ -21,7 +21,7 @@ Accepts any content - strings pass through, other types **Methods:** -#### `to_mcp_prompt_message` +#### `to_mcp_prompt_message` ```python to_mcp_prompt_message(self) -> PromptMessage @@ -30,13 +30,13 @@ to_mcp_prompt_message(self) -> PromptMessage Convert to MCP PromptMessage. -### `PromptArgument` +### `PromptArgument` An argument that can be passed to a prompt. -### `PromptResult` +### `PromptResult` Canonical result type for prompt rendering. @@ -47,7 +47,7 @@ roles, and metadata at both the message and result level. **Methods:** -#### `to_mcp_prompt_result` +#### `to_mcp_prompt_result` ```python to_mcp_prompt_result(self) -> GetPromptResult @@ -56,7 +56,7 @@ to_mcp_prompt_result(self) -> GetPromptResult Convert to MCP GetPromptResult. -### `Prompt` +### `Prompt` A prompt template that can be rendered with parameters. @@ -64,7 +64,7 @@ A prompt template that can be rendered with parameters. **Methods:** -#### `to_mcp_prompt` +#### `to_mcp_prompt` ```python to_mcp_prompt(self, **overrides: Any) -> SDKPrompt @@ -73,7 +73,7 @@ to_mcp_prompt(self, **overrides: Any) -> SDKPrompt Convert the prompt to an MCP prompt. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionPrompt @@ -87,7 +87,7 @@ The function can return: - PromptResult: used directly -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> str | list[Message | str] | PromptResult @@ -101,7 +101,7 @@ Subclasses must implement this method. Return one of: - PromptResult: Used directly -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> PromptResult @@ -113,7 +113,7 @@ Convert a raw return value to PromptResult. - `TypeError`: for unsupported types -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -122,7 +122,7 @@ register_with_docket(self, docket: Docket) -> None Register this prompt with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any] | None, **kwargs: Any) -> Execution @@ -138,7 +138,7 @@ Schedule this prompt for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-resources-function_resource.mdx b/docs/python-sdk/fastmcp-resources-function_resource.mdx index e7d710744..c5de76443 100644 --- a/docs/python-sdk/fastmcp-resources-function_resource.mdx +++ b/docs/python-sdk/fastmcp-resources-function_resource.mdx @@ -10,7 +10,7 @@ Standalone @resource decorator for FastMCP. ## Functions -### `resource` +### `resource` ```python resource(uri: str) -> Callable[[F], F] @@ -25,19 +25,19 @@ using mcp.add_resource(). ## Classes -### `DecoratedResource` +### `DecoratedResource` Protocol for functions decorated with @resource. -### `ResourceMeta` +### `ResourceMeta` Metadata attached to functions by the @resource decorator. -### `FunctionResource` +### `FunctionResource` A resource that defers data loading by wrapping a function. @@ -54,7 +54,7 @@ The function can return: **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl | None = None) -> FunctionResource @@ -71,7 +71,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `read` +#### `read` ```python read(self) -> str | bytes | ResourceResult @@ -80,7 +80,7 @@ read(self) -> str | bytes | ResourceResult Read the resource by calling the wrapped function. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None diff --git a/docs/python-sdk/fastmcp-resources-resource.mdx b/docs/python-sdk/fastmcp-resources-resource.mdx index b064ac47b..2c5972094 100644 --- a/docs/python-sdk/fastmcp-resources-resource.mdx +++ b/docs/python-sdk/fastmcp-resources-resource.mdx @@ -10,7 +10,7 @@ Base classes and interfaces for FastMCP resources. ## Classes -### `ResourceContent` +### `ResourceContent` Wrapper for resource content with optional MIME type and metadata. @@ -21,7 +21,7 @@ other types (dict, list, BaseModel, etc.) are automatically JSON-serialized. **Methods:** -#### `to_mcp_resource_contents` +#### `to_mcp_resource_contents` ```python to_mcp_resource_contents(self, uri: AnyUrl | str) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents @@ -36,7 +36,7 @@ Convert to MCP resource contents type. - TextResourceContents for str content, BlobResourceContents for bytes -### `ResourceResult` +### `ResourceResult` Canonical result type for resource reads. @@ -47,7 +47,7 @@ per-item MIME types, and metadata at both the item and result level. **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult @@ -62,7 +62,7 @@ Convert to MCP ReadResourceResult. - MCP ReadResourceResult with converted contents -### `Resource` +### `Resource` Base class for all resources. @@ -70,13 +70,13 @@ Base class for all resources. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl) -> FunctionResource ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -85,7 +85,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `set_default_name` +#### `set_default_name` ```python set_default_name(self) -> Self @@ -94,7 +94,7 @@ set_default_name(self) -> Self Set default name from URI if not provided. -#### `read` +#### `read` ```python read(self) -> str | bytes | ResourceResult @@ -108,7 +108,7 @@ Subclasses implement this to return resource data. Supported return types: - ResourceResult: Full control over contents and result-level meta -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult @@ -121,10 +121,17 @@ This is used in two contexts: 2. In tasks_result_handler() to convert Docket task results to ResourceResult Handles ResourceResult passthrough and converts raw values using -ResourceResult's normalization. +ResourceResult's normalization. When the raw value is a plain +string or bytes, the resource's own ``mime_type`` is forwarded so +that ``ui://`` resources (and others with non-default MIME types) +don't fall back to ``text/plain``. + +The resource's component-level ``meta`` (e.g. ``ui`` metadata for +MCP Apps CSP/permissions) is propagated to each content item so +that hosts can read it from the ``resources/read`` response. -#### `to_mcp_resource` +#### `to_mcp_resource` ```python to_mcp_resource(self, **overrides: Any) -> SDKResource @@ -133,7 +140,7 @@ to_mcp_resource(self, **overrides: Any) -> SDKResource Convert the resource to an SDKResource. -#### `key` +#### `key` ```python key(self) -> str @@ -142,7 +149,7 @@ key(self) -> str The globally unique lookup key for this resource. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -151,7 +158,7 @@ register_with_docket(self, docket: Docket) -> None Register this resource with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, **kwargs: Any) -> Execution @@ -166,7 +173,7 @@ Schedule this resource for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx index e1b932d88..e55bae102 100644 --- a/docs/python-sdk/fastmcp-resources-template.mdx +++ b/docs/python-sdk/fastmcp-resources-template.mdx @@ -10,7 +10,7 @@ Resource template functionality. ## Functions -### `extract_query_params` +### `extract_query_params` ```python extract_query_params(uri_template: str) -> set[str] @@ -20,7 +20,7 @@ extract_query_params(uri_template: str) -> set[str] Extract query parameter names from RFC 6570 `{?param1,param2}` syntax. -### `build_regex` +### `build_regex` ```python build_regex(template: str) -> re.Pattern @@ -35,7 +35,7 @@ Supports: - `{?var1,var2}` - query parameters (ignored in path matching) -### `match_uri_template` +### `match_uri_template` ```python match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None @@ -51,7 +51,7 @@ Supports RFC 6570 URI templates: ## Classes -### `ResourceTemplate` +### `ResourceTemplate` A template for dynamically creating resources. @@ -59,13 +59,13 @@ A template for dynamically creating resources. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None) -> FunctionResourceTemplate ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -74,7 +74,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `matches` +#### `matches` ```python matches(self, uri: str) -> dict[str, Any] | None @@ -83,7 +83,7 @@ matches(self, uri: str) -> dict[str, Any] | None Check if URI matches template and extract parameters. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -92,7 +92,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult Read the resource content. -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult @@ -108,7 +108,7 @@ Handles ResourceResult passthrough and converts raw values using ResourceResult's normalization. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -120,7 +120,7 @@ The base implementation does not support background tasks. Use FunctionResourceTemplate for task support. -#### `to_mcp_template` +#### `to_mcp_template` ```python to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate @@ -129,7 +129,7 @@ to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate Convert the resource template to an SDKResourceTemplate. -#### `from_mcp_template` +#### `from_mcp_template` ```python from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate @@ -138,7 +138,7 @@ from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object. -#### `key` +#### `key` ```python key(self) -> str @@ -147,7 +147,7 @@ key(self) -> str The globally unique lookup key for this template. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -156,7 +156,7 @@ register_with_docket(self, docket: Docket) -> None Register this template with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -172,13 +172,13 @@ Schedule this template for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FunctionResourceTemplate` +### `FunctionResourceTemplate` A template for dynamically creating resources. @@ -186,7 +186,7 @@ A template for dynamically creating resources. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -195,7 +195,7 @@ create_resource(self, uri: str, params: dict[str, Any]) -> Resource Create a resource from the template with the given parameters. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -204,7 +204,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult Read the resource content. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -216,7 +216,7 @@ FunctionResourceTemplate registers the underlying function, which has the user's Depends parameters for docket to resolve. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -234,7 +234,7 @@ FunctionResourceTemplate splats the params dict since .fn expects **kwargs. - `**kwargs`: Additional kwargs passed to docket.add() -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None) -> FunctionResourceTemplate diff --git a/docs/python-sdk/fastmcp-server-apps.mdx b/docs/python-sdk/fastmcp-server-apps.mdx new file mode 100644 index 000000000..c72052339 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-apps.mdx @@ -0,0 +1,87 @@ +--- +title: apps +sidebarTitle: apps +--- + +# `fastmcp.server.apps` + + +MCP Apps support — extension negotiation and typed UI metadata models. + +Provides constants and Pydantic models for the MCP Apps extension +(io.modelcontextprotocol/ui), enabling tools and resources to carry +UI metadata for clients that support interactive app rendering. + + +## Functions + +### `ui_to_meta_dict` + +```python +ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any] +``` + + +Convert a UI model or dict to the wire-format dict for ``meta["ui"]``. + + +### `resolve_ui_mime_type` + +```python +resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None +``` + + +Return the appropriate MIME type for a resource URI. + +For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no +explicit MIME type is provided. This ensures UI resources are correctly +identified regardless of how they're registered (via FastMCP.resource, +the standalone @resource decorator, or resource templates). + +**Args:** +- `uri`: The resource URI string +- `explicit_mime_type`: The MIME type explicitly provided by the user + +**Returns:** +- The resolved MIME type (explicit value, UI default, or None) + + +## Classes + +### `ResourceCSP` + + +Content Security Policy for MCP App resources. + +Declares which external origins the app is allowed to connect to or +load resources from. Hosts use these declarations to build the +``Content-Security-Policy`` header for the sandboxed iframe. + + +### `ResourcePermissions` + + +Iframe sandbox permissions for MCP App resources. + +Each field, when set (typically to ``{}``), requests that the host +grant the corresponding Permission Policy feature to the sandboxed +iframe. Hosts MAY honour these; apps should use JS feature detection +as a fallback. + + +### `ToolUI` + + +Typed ``_meta.ui`` for tools — links a tool to its UI resource. + +All fields use ``exclude_none`` serialization so only explicitly-set +values appear on the wire. Aliases match the MCP Apps wire format +(camelCase). + + +### `ResourceUI` + + +Typed ``_meta.ui`` for resources — rendering hints for UI-capable clients. + diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index 8f58e8be6..723b5a08f 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -164,7 +164,21 @@ Token verifiers typically don't provide authentication routes by default. **Methods:** -#### `verify_token` +#### `scopes_supported` + +```python +scopes_supported(self) -> list[str] +``` + +Scopes to advertise in OAuth metadata. + +Defaults to required_scopes. Override in subclasses when the +advertised scopes differ from the validation scopes (e.g., Azure AD +where tokens contain short-form scopes but clients request full URI +scopes). + + +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -173,7 +187,7 @@ verify_token(self, token: str) -> AccessToken | None Verify a bearer token and return access info if valid. -### `RemoteAuthProvider` +### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. @@ -190,7 +204,7 @@ the authorization servers that issue valid tokens. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -199,7 +213,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token using the configured token verifier. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -210,7 +224,7 @@ Get routes for this provider. Creates protected resource metadata routes (RFC 9728). -### `OAuthProvider` +### `OAuthProvider` OAuth Authorization Server provider. @@ -221,7 +235,7 @@ authorization flows, token issuance, and token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -239,7 +253,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -255,7 +269,7 @@ This method creates the full set of OAuth routes including: - List of OAuth routes -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx b/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx index cbaa6eb04..9b168337e 100644 --- a/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx +++ b/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx @@ -42,7 +42,7 @@ a key derived from the upstream client secret. #### `issue_access_token` ```python -issue_access_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int = 3600) -> str +issue_access_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int = 3600, upstream_claims: dict[str, Any] | None = None) -> str ``` Issue a minimal FastMCP access token. @@ -56,15 +56,16 @@ which contains actual user identity and authorization data. - `scopes`: Token scopes - `jti`: Unique token identifier (maps to upstream token) - `expires_in`: Token lifetime in seconds +- `upstream_claims`: Optional claims from upstream IdP token to include **Returns:** - Signed JWT token -#### `issue_refresh_token` +#### `issue_refresh_token` ```python -issue_refresh_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int) -> str +issue_refresh_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int, upstream_claims: dict[str, Any] | None = None) -> str ``` Issue a minimal FastMCP refresh token. @@ -78,12 +79,13 @@ token which contains actual user identity and authorization data. - `scopes`: Token scopes - `jti`: Unique token identifier (maps to upstream token) - `expires_in`: Token lifetime in seconds (should match upstream refresh expiry) +- `upstream_claims`: Optional claims from upstream IdP token to include **Returns:** - Signed JWT token -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx index 20f839f46..26f80c876 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx @@ -26,7 +26,7 @@ production use with enterprise identity providers. ## Classes -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -140,7 +140,7 @@ Handles provider-specific requirements: **Methods:** -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -157,7 +157,7 @@ this specific MCP endpoint. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `jwt_issuer` +#### `jwt_issuer` ```python jwt_issuer(self) -> JWTIssuer @@ -169,7 +169,7 @@ The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -181,7 +181,7 @@ 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). -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -195,7 +195,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -213,7 +213,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -225,7 +225,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -243,7 +243,7 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -255,7 +255,7 @@ Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -272,7 +272,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -291,7 +291,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -304,7 +304,7 @@ For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 411a17550..2e403a611 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -49,7 +49,7 @@ Setup: **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -68,3 +68,55 @@ scopes to determine the resource/audience instead of a separate parameter. **Returns:** - Authorization URL to redirect the user to Azure AD + +### `AzureJWTVerifier` + + +JWT verifier pre-configured for Azure AD / Microsoft Entra ID. + +Auto-configures JWKS URI, issuer, audience, and scope handling from your +Azure app registration details. Designed for Managed Identity and other +token-verification-only scenarios where AzureProvider's full OAuth proxy +isn't needed. + +Handles Azure's scope format automatically: +- Validates tokens using short-form scopes (what Azure puts in ``scp`` claims) +- Advertises full-URI scopes in OAuth metadata (what clients need to request) + +Example:: + + from fastmcp.server.auth import RemoteAuthProvider + from fastmcp.server.auth.providers.azure import AzureJWTVerifier + from pydantic import AnyHttpUrl + + verifier = AzureJWTVerifier( + client_id="your-client-id", + tenant_id="your-tenant-id", + required_scopes=["access_as_user"], + ) + + auth = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[ + AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0") + ], + base_url="https://my-server.com", + ) + + +**Methods:** + +#### `scopes_supported` + +```python +scopes_supported(self) -> list[str] +``` + +Return scopes with Azure URI prefix for OAuth metadata. + +Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp`` +claim, but clients must request full URI scopes (e.g., +``api://client-id/read``) from the Azure authorization endpoint. This +property returns the full-URI form for OAuth metadata while +``required_scopes`` retains the short form for token validation. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx index 9ea8f2701..9dd176f2e 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx @@ -82,7 +82,7 @@ Use this when: **Methods:** -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -97,7 +97,7 @@ Validate a JWT bearer token and return an AccessToken when the token is valid. - AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -115,7 +115,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -### `StaticTokenVerifier` +### `StaticTokenVerifier` Simple static token verifier for testing and development. @@ -136,7 +136,7 @@ WARNING: Never use this in production - tokens are stored in plain text! **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 1edf918c8..060f0c4d5 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -7,7 +7,7 @@ sidebarTitle: context ## Functions -### `set_transport` +### `set_transport` ```python set_transport(transport: TransportType) -> Token[TransportType | None] @@ -17,7 +17,7 @@ set_transport(transport: TransportType) -> Token[TransportType | None] Set the current transport type. Returns token for reset. -### `reset_transport` +### `reset_transport` ```python reset_transport(token: Token[TransportType | None]) -> None @@ -27,7 +27,7 @@ reset_transport(token: Token[TransportType | None]) -> None Reset transport to previous value. -### `set_context` +### `set_context` ```python set_context(context: Context) -> Generator[Context, None, None] @@ -35,7 +35,7 @@ set_context(context: Context) -> Generator[Context, None, None] ## Classes -### `LogData` +### `LogData` Data object for passing log arguments to client-side handlers. @@ -44,7 +44,7 @@ This provides an interface to match the Python standard library logging, for compatibility with structured logging. -### `Context` +### `Context` Context object providing access to MCP capabilities. @@ -96,7 +96,31 @@ The context is optional - tools that don't need it can omit the parameter. **Methods:** -#### `fastmcp` +#### `is_background_task` + +```python +is_background_task(self) -> bool +``` + +True when this context is running in a background task (Docket worker). + +When True, certain operations like elicit() and sample() will use +task-aware implementations that can pause the task and wait for +client input. + + +#### `task_id` + +```python +task_id(self) -> str | None +``` + +Get the background task ID if running in a background task. + +Returns None if not running in a background task context. + + +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -105,7 +129,7 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `request_context` +#### `request_context` ```python request_context(self) -> RequestContext[ServerSession, Any, Request] | None @@ -134,7 +158,7 @@ async def on_request(self, context, call_next): ``` -#### `lifespan_context` +#### `lifespan_context` ```python lifespan_context(self) -> dict[str, Any] @@ -157,7 +181,7 @@ def my_tool(ctx: Context) -> str: ``` -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -170,7 +194,7 @@ Report progress for the current operation. - `total`: Optional total value e.g. 100 -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[SDKResource] @@ -182,7 +206,7 @@ List all available resources from the server. - List of Resource objects available on the server -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[SDKPrompt] @@ -194,7 +218,7 @@ List all available prompts from the server. - List of Prompt objects available on the server -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult @@ -210,7 +234,7 @@ Get a prompt by name with optional arguments. - The prompt result -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> ResourceResult @@ -225,7 +249,7 @@ Read a resource by URI. - ResourceResult with contents -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -243,7 +267,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien - `extra`: Optional mapping for additional arguments -#### `transport` +#### `transport` ```python transport(self) -> TransportType | None @@ -255,7 +279,32 @@ Returns the transport type used to run this server: "stdio", "sse", or "streamable-http". Returns None if called outside of a server context. -#### `client_id` +#### `client_supports_extension` + +```python +client_supports_extension(self, extension_id: str) -> bool +``` + +Check whether the connected client supports a given MCP extension. + +Inspects the ``extensions`` extra field on ``ClientCapabilities`` +sent by the client during initialization. + +Returns ``False`` when no session is available (e.g., outside a +request context) or when the client did not advertise the extension. + +Example:: + + from fastmcp.server.apps import UI_EXTENSION_ID + + @mcp.tool + async def my_tool(ctx: Context) -> str: + if ctx.client_supports_extension(UI_EXTENSION_ID): + return "UI-capable client" + return "text-only client" + + +#### `client_id` ```python client_id(self) -> str | None @@ -264,7 +313,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -275,7 +324,7 @@ Get the unique ID for this request. Raises RuntimeError if MCP request context is not available. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -292,7 +341,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -300,10 +349,13 @@ session(self) -> ServerSession Access to the underlying session for advanced usage. -Raises RuntimeError if MCP request context is not available. +In request mode: Returns the session from the active request context. +In background task mode: Returns the session stored at Context creation. + +Raises RuntimeError if no session is available. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -314,7 +366,7 @@ Send a `DEBUG`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -325,7 +377,7 @@ Send a `INFO`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -336,7 +388,7 @@ Send a `WARNING`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -347,7 +399,7 @@ Send a `ERROR`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -356,7 +408,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_notification` +#### `send_notification` ```python send_notification(self, notification: mcp.types.ServerNotificationType) -> None @@ -368,7 +420,7 @@ Send a notification to the client immediately. - `notification`: An MCP notification instance (e.g., ToolListChangedNotification()) -#### `close_sse_stream` +#### `close_sse_stream` ```python close_sse_stream(self) -> None @@ -386,7 +438,7 @@ Instead of holding a connection open for minutes, you can periodically close and let the client reconnect. -#### `sample_step` +#### `sample_step` ```python sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -423,7 +475,7 @@ Tools can raise ToolError to bypass masking. - - .text: The text content (if any) -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -432,7 +484,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: With result_type, returns SamplingResult[ResultT]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str] @@ -441,7 +493,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: Without result_type, returns SamplingResult[str]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str] @@ -483,43 +535,43 @@ Tools can raise ToolError to bypass masking. - - .history: All messages exchanged during sampling -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation @@ -548,7 +600,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -561,7 +613,7 @@ The key is automatically prefixed with the session identifier. State expires after 1 day to prevent unbounded memory growth. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any @@ -572,7 +624,7 @@ Get a value from the session-scoped state store. Returns None if the key is not found. -#### `delete_state` +#### `delete_state` ```python delete_state(self, key: str) -> None @@ -581,7 +633,7 @@ delete_state(self, key: str) -> None Delete a value from the session-scoped state store. -#### `enable_components` +#### `enable_components` ```python enable_components(self) -> None @@ -605,7 +657,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `disable_components` +#### `disable_components` ```python disable_components(self) -> None @@ -629,7 +681,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `reset_visibility` +#### `reset_visibility` ```python reset_visibility(self) -> None diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index 71d7a91f6..d718b2fc3 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -15,7 +15,57 @@ CurrentWorker) and background task execution require fastmcp[tasks]. ## Functions -### `is_docket_available` +### `get_task_context` + +```python +get_task_context() -> TaskContextInfo | None +``` + + +Get the current task context if running inside a background task worker. + +This function extracts task information from the Docket execution context. +Returns None if not running in a task context (e.g., foreground execution). + +**Returns:** +- TaskContextInfo with task_id and session_id, or None if not in a task. + + +### `register_task_session` + +```python +register_task_session(session_id: str, session: ServerSession) -> None +``` + + +Register a session for Context access in background tasks. + +Called automatically when a task is submitted to Docket. The session is +stored as a weakref so it doesn't prevent garbage collection when the +client disconnects. + +**Args:** +- `session_id`: The session identifier +- `session`: The ServerSession instance + + +### `get_task_session` + +```python +get_task_session(session_id: str) -> ServerSession | None +``` + + +Get a registered session by ID if still alive. + +**Args:** +- `session_id`: The session identifier + +**Returns:** +- The ServerSession if found and alive, None otherwise + + +### `is_docket_available` ```python is_docket_available() -> bool @@ -25,7 +75,7 @@ is_docket_available() -> bool Check if pydocket is installed. -### `require_docket` +### `require_docket` ```python require_docket(feature: str) -> None @@ -39,7 +89,7 @@ Raise ImportError with install instructions if docket not available. "CurrentDocket()"). Will be included in the error message. -### `transform_context_annotations` +### `transform_context_annotations` ```python transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] @@ -65,7 +115,7 @@ allows them to have defaults in any order. - Function with modified signature (same function object, updated __signature__) -### `get_context` +### `get_context` ```python get_context() -> Context @@ -75,7 +125,7 @@ get_context() -> Context Get the current FastMCP Context instance directly. -### `get_server` +### `get_server` ```python get_server() -> FastMCP @@ -91,7 +141,7 @@ Get the current FastMCP server instance directly. - `RuntimeError`: If no server in context -### `get_http_request` +### `get_http_request` ```python get_http_request() -> Request @@ -103,7 +153,7 @@ Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. -### `get_http_headers` +### `get_http_headers` ```python get_http_headers(include_all: bool = False) -> dict[str, str] @@ -119,7 +169,7 @@ By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients. If `include_all` is True, all headers are returned. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -137,7 +187,7 @@ request is available. - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -162,7 +212,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -188,7 +238,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -207,7 +257,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -227,7 +277,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -247,7 +297,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -265,7 +315,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -285,7 +335,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -302,7 +352,7 @@ safe to use in code that might run over any transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -323,7 +373,16 @@ authenticated request. Raises an error if no authentication is present. ## Classes -### `ProgressLike` +### `TaskContextInfo` + + +Information about the current background task context. + +Returned by ``get_task_context()`` when running inside a Docket worker. +Contains identifiers needed to communicate with the MCP session. + + +### `ProgressLike` Protocol for progress tracking interface. @@ -334,7 +393,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -343,7 +402,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -352,7 +411,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -361,7 +420,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -370,7 +429,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -379,7 +438,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -388,7 +447,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -400,25 +459,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -427,7 +486,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -436,7 +495,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -445,7 +504,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. diff --git a/docs/python-sdk/fastmcp-server-low_level.mdx b/docs/python-sdk/fastmcp-server-low_level.mdx index 4a2212305..07445bac0 100644 --- a/docs/python-sdk/fastmcp-server-low_level.mdx +++ b/docs/python-sdk/fastmcp-server-low_level.mdx @@ -7,7 +7,7 @@ sidebarTitle: low_level ## Classes -### `MiddlewareServerSession` +### `MiddlewareServerSession` ServerSession that routes initialization requests through FastMCP middleware. @@ -15,7 +15,7 @@ ServerSession that routes initialization requests through FastMCP middleware. **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -24,11 +24,23 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -### `LowLevelServer` +#### `client_supports_extension` + +```python +client_supports_extension(self, extension_id: str) -> bool +``` + +Check if the connected client supports a given MCP extension. + +Inspects the ``extensions`` extra field on ``ClientCapabilities`` +sent by the client during initialization. + + +### `LowLevelServer` **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -37,13 +49,13 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `create_initialization_options` +#### `create_initialization_options` ```python create_initialization_options(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, **kwargs: Any) -> InitializationOptions ``` -#### `get_capabilities` +#### `get_capabilities` ```python get_capabilities(self, notification_options: NotificationOptions, experimental_capabilities: dict[str, dict[str, Any]]) -> mcp.types.ServerCapabilities @@ -56,7 +68,7 @@ capabilities.experimental.tasks, which is required by the MCP spec and enables proper task detection by clients like VS Code Copilot 1.107+. -#### `run` +#### `run` ```python run(self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], initialization_options: InitializationOptions, raise_exceptions: bool = False, stateless: bool = False) @@ -65,7 +77,7 @@ run(self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], wr Overrides the run method to use the MiddlewareServerSession. -#### `read_resource` +#### `read_resource` ```python read_resource(self) -> Callable[[Callable[[AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult]]], Callable[[AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult]]] @@ -80,7 +92,7 @@ This decorator can be removed once the MCP SDK adds native CreateTaskResult supp for resources. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self) -> Callable[[Callable[[str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult]]], Callable[[str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult]]] diff --git a/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx b/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx index 5339b4a8c..be1218a20 100644 --- a/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx @@ -21,7 +21,7 @@ proper MCP error responses. Also tracks error patterns for monitoring. **Methods:** -#### `on_message` +#### `on_message` ```python on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any @@ -30,7 +30,7 @@ on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any Handle errors for all messages. -#### `get_error_stats` +#### `get_error_stats` ```python get_error_stats(self) -> dict[str, int] @@ -39,7 +39,7 @@ get_error_stats(self) -> dict[str, int] Get error statistics for monitoring. -### `RetryMiddleware` +### `RetryMiddleware` Middleware that implements automatic retry logic for failed requests. @@ -50,7 +50,7 @@ backoff to avoid overwhelming the server or external dependencies. **Methods:** -#### `on_request` +#### `on_request` ```python on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any diff --git a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx index 2059f6e17..f4fb6ed28 100644 --- a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx @@ -39,7 +39,7 @@ wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool Wrap a Tool to delegate execution to the server's middleware. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -51,13 +51,13 @@ This is called when the tool is used within a TransformedTool forwarding function or other contexts where task_meta is not available. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResource` +### `FastMCPProviderResource` Resource that delegates reading to a wrapped server's read_resource(). @@ -68,7 +68,7 @@ When `read()` is called, this resource invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource @@ -77,13 +77,13 @@ wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource Wrap a Resource to delegate reading to the server's middleware. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderPrompt` +### `FastMCPProviderPrompt` Prompt that delegates rendering to a wrapped server's render_prompt(). @@ -94,7 +94,7 @@ When `render()` is called, this prompt invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt @@ -103,7 +103,7 @@ wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt Wrap a Prompt to delegate rendering to the server's middleware. -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult @@ -115,13 +115,13 @@ This is called when the prompt is used within a transformed context or other contexts where task_meta is not available. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResourceTemplate` +### `FastMCPProviderResourceTemplate` Resource template that creates FastMCPProviderResources. @@ -133,7 +133,7 @@ when read. **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTemplate @@ -142,7 +142,7 @@ wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTem Wrap a ResourceTemplate to create FastMCPProviderResources. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -155,7 +155,7 @@ We use `_original_uri_template` with `params` to construct the internal URI that the nested server understands. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -167,7 +167,7 @@ Reads the resource via the wrapped server and returns the ResourceResult. This method is called by Docket during background task execution. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -176,7 +176,7 @@ register_with_docket(self, docket: Docket) -> None No-op: the child's actual template is registered via get_tasks(). -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -188,13 +188,13 @@ The child's FunctionResourceTemplate.fn is registered (via get_tasks), and it expects splatted **kwargs, so we splat params here. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProvider` +### `FastMCPProvider` Provider that wraps a FastMCP server. @@ -210,7 +210,7 @@ This ensures middleware runs when components are executed. **Methods:** -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -224,7 +224,7 @@ server's transforms applied, then applies this provider's transforms for correct registration keys. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 21db24fad..fc85ea732 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -54,65 +54,65 @@ use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.pr ## Classes -### `StateValue` +### `StateValue` Wrapper for stored context state values. -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -132,7 +132,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -144,7 +144,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -159,7 +159,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -171,7 +171,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -183,7 +183,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -196,7 +196,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -216,7 +216,7 @@ session transforms can override provider-level disables. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -229,7 +229,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -248,7 +248,7 @@ transforms (including session-level) have been applied. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -261,7 +261,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -280,7 +280,7 @@ all transforms (including session-level) have been applied. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -293,7 +293,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -312,19 +312,19 @@ transforms (including session-level) have been applied. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -354,19 +354,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -395,19 +395,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -437,7 +437,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -455,7 +455,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -471,19 +471,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -539,7 +539,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -554,7 +554,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -569,7 +569,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction] @@ -628,7 +628,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -643,19 +643,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -732,7 +732,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -779,7 +779,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -820,7 +820,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, timeout: float | None = None, **settings: Any) -> Self @@ -844,7 +844,7 @@ Create a FastMCP server from an OpenAPI specification. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, timeout: float | None = None, **settings: Any) -> Self @@ -868,7 +868,7 @@ Create a FastMCP server from a FastAPI application. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -886,7 +886,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-server-tasks-config.mdx b/docs/python-sdk/fastmcp-server-tasks-config.mdx index 2dbbc63dd..cc78f7156 100644 --- a/docs/python-sdk/fastmcp-server-tasks-config.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-config.mdx @@ -14,7 +14,7 @@ handle task-augmented execution as specified in SEP-1686. ## Classes -### `TaskMeta` +### `TaskMeta` Metadata for task-augmented execution requests. @@ -27,7 +27,7 @@ the operation should be submitted as a background task. - `fn_key`: Docket routing key. Auto-derived from component name if None. -### `TaskConfig` +### `TaskConfig` Configuration for MCP background task execution (SEP-1686). @@ -44,7 +44,7 @@ Controls how a component handles task-augmented requests: **Methods:** -#### `from_bool` +#### `from_bool` ```python from_bool(cls, value: bool) -> TaskConfig @@ -59,7 +59,7 @@ Convert boolean task flag to TaskConfig. - TaskConfig with appropriate mode. -#### `supports_tasks` +#### `supports_tasks` ```python supports_tasks(self) -> bool @@ -71,7 +71,7 @@ Check if this component supports task execution. - True if mode is "optional" or "required", False if "forbidden". -#### `validate_function` +#### `validate_function` ```python validate_function(self, fn: Callable[..., Any], name: str) -> None diff --git a/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx new file mode 100644 index 000000000..1b06baa8e --- /dev/null +++ b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx @@ -0,0 +1,74 @@ +--- +title: elicitation +sidebarTitle: elicitation +--- + +# `fastmcp.server.tasks.elicitation` + + +Background task elicitation support (SEP-1686). + +This module provides elicitation capabilities for background tasks running +in Docket workers. Unlike regular MCP requests, background tasks don't have +an active request context, so elicitation requires special handling: + +1. Set task status to "input_required" via Redis +2. Send notifications/tasks/updated with elicitation metadata +3. Wait for client to send input via tasks/sendInput +4. Resume task execution with the provided input + +This uses the public MCP SDK APIs where possible, with minimal use of +internal APIs for background task coordination. + + +## Functions + +### `elicit_for_task` + +```python +elicit_for_task(task_id: str, session: ServerSession, message: str, schema: dict[str, Any], fastmcp: FastMCP) -> mcp.types.ElicitResult +``` + + +Send an elicitation request from a background task. + +This function handles the complexity of eliciting user input when running +in a Docket worker context where there's no active MCP request. + +**Args:** +- `task_id`: The background task ID +- `session`: The MCP ServerSession for this task +- `message`: The message to display to the user +- `schema`: The JSON schema for the expected response +- `fastmcp`: The FastMCP server instance + +**Returns:** +- ElicitResult containing the user's response + +**Raises:** +- `RuntimeError`: If Docket is not available +- `McpError`: If the elicitation request fails + + +### `handle_task_input` + +```python +handle_task_input(task_id: str, session_id: str, action: str, content: dict[str, Any] | None, fastmcp: FastMCP) -> bool +``` + + +Handle input sent to a background task via tasks/sendInput. + +This is called when a client sends input in response to an elicitation +request from a background task. + +**Args:** +- `task_id`: The background task ID +- `session_id`: The MCP session ID +- `action`: The elicitation action ("accept", "decline", "cancel") +- `content`: The response content (for "accept" action) +- `fastmcp`: The FastMCP server instance + +**Returns:** +- True if the input was successfully stored, False otherwise + diff --git a/docs/python-sdk/fastmcp-tools-function_tool.mdx b/docs/python-sdk/fastmcp-tools-function_tool.mdx index 0e4b35333..bd66818a0 100644 --- a/docs/python-sdk/fastmcp-tools-function_tool.mdx +++ b/docs/python-sdk/fastmcp-tools-function_tool.mdx @@ -10,7 +10,7 @@ Standalone @tool decorator for FastMCP. ## Functions -### `tool` +### `tool` ```python tool(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -25,23 +25,23 @@ using mcp.add_tool(). ## Classes -### `DecoratedTool` +### `DecoratedTool` Protocol for functions decorated with @tool. -### `ToolMeta` +### `ToolMeta` Metadata attached to functions by the @tool decorator. -### `FunctionTool` +### `FunctionTool` **Methods:** -#### `to_mcp_tool` +#### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> mcp.types.Tool @@ -52,7 +52,7 @@ Convert the FastMCP tool to an MCP tool. Extends the base implementation to add task execution mode if enabled. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool @@ -68,7 +68,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -77,7 +77,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Run the tool with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -89,7 +89,7 @@ FunctionTool registers the underlying function, which has the user's Depends parameters for docket to resolve. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx index 9acb15695..1068a7716 100644 --- a/docs/python-sdk/fastmcp-tools-tool.mdx +++ b/docs/python-sdk/fastmcp-tools-tool.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool ## Functions -### `default_serializer` +### `default_serializer` ```python default_serializer(data: Any) -> str @@ -15,17 +15,17 @@ default_serializer(data: Any) -> str ## Classes -### `ToolResult` +### `ToolResult` **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ``` -### `Tool` +### `Tool` Internal tool registration info. @@ -33,7 +33,7 @@ Internal tool registration info. **Methods:** -#### `to_mcp_tool` +#### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> MCPTool @@ -42,7 +42,7 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool Convert the FastMCP tool to an MCP tool. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool @@ -51,7 +51,7 @@ from_function(cls, fn: Callable[..., Any]) -> FunctionTool Create a Tool from a function. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -66,7 +66,7 @@ implemented by subclasses. (list of ContentBlocks, dict of structured output). -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ToolResult @@ -78,7 +78,7 @@ Handles ToolResult passthrough and converts raw values using the tool's attributes (serializer, output_schema) for proper conversion. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -87,7 +87,7 @@ register_with_docket(self, docket: Docket) -> None Register this tool with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution @@ -103,13 +103,13 @@ Schedule this tool for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool) -> TransformedTool ``` -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index 8506d990e..dba6cf8a6 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool_transform ## Functions -### `forward` +### `forward` ```python forward(**kwargs: Any) -> ToolResult @@ -36,7 +36,7 @@ tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to - `TypeError`: If provided arguments don't match the transformed schema. -### `forward_raw` +### `forward_raw` ```python forward_raw(**kwargs: Any) -> ToolResult @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -78,7 +78,7 @@ but transformations are keyed by tool name (e.g., "my_tool"). ## Classes -### `ArgTransform` +### `ArgTransform` Configuration for transforming a parent tool's argument. @@ -150,7 +150,7 @@ ArgTransform(name="new_name", description="New desc", default=None, type=int) ``` -### `ArgTransformConfig` +### `ArgTransformConfig` A model for requesting a single argument transform. @@ -158,7 +158,7 @@ A model for requesting a single argument transform. **Methods:** -#### `to_arg_transform` +#### `to_arg_transform` ```python to_arg_transform(self) -> ArgTransform @@ -167,7 +167,7 @@ to_arg_transform(self) -> ArgTransform Convert the argument transform to a FastMCP argument transform. -### `TransformedTool` +### `TransformedTool` A tool that is transformed from another tool. @@ -191,7 +191,7 @@ validation when forward() is called from custom functions. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -210,7 +210,7 @@ functions. - ToolResult object containing content and optional structured output. -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool, name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet) -> TransformedTool @@ -293,7 +293,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -301,7 +301,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/docs/python-sdk/fastmcp-utilities-auth.mdx b/docs/python-sdk/fastmcp-utilities-auth.mdx index f30966e96..3e8271e9e 100644 --- a/docs/python-sdk/fastmcp-utilities-auth.mdx +++ b/docs/python-sdk/fastmcp-utilities-auth.mdx @@ -10,7 +10,49 @@ Authentication utility helpers. ## Functions -### `parse_scopes` +### `decode_jwt_header` + +```python +decode_jwt_header(token: str) -> dict[str, Any] +``` + + +Decode JWT header without signature verification. + +Useful for extracting the key ID (kid) for JWKS lookup. + +**Args:** +- `token`: JWT token string (header.payload.signature) + +**Returns:** +- Decoded header as a dictionary + +**Raises:** +- `ValueError`: If token is not a valid JWT format + + +### `decode_jwt_payload` + +```python +decode_jwt_payload(token: str) -> dict[str, Any] +``` + + +Decode JWT payload without signature verification. + +Use only for tokens received directly from trusted sources (e.g., IdP token endpoints). + +**Args:** +- `token`: JWT token string (header.payload.signature) + +**Returns:** +- Decoded payload as a dictionary + +**Raises:** +- `ValueError`: If token is not a valid JWT format + + +### `parse_scopes` ```python parse_scopes(value: Any) -> list[str] | None diff --git a/docs/python-sdk/fastmcp-utilities-lifespan.mdx b/docs/python-sdk/fastmcp-utilities-lifespan.mdx index 319dbc29c..cf347d794 100644 --- a/docs/python-sdk/fastmcp-utilities-lifespan.mdx +++ b/docs/python-sdk/fastmcp-utilities-lifespan.mdx @@ -13,7 +13,7 @@ Lifespan utilities for combining async context manager lifespans. ### `combine_lifespans` ```python -combine_lifespans(*lifespans: Callable[[AppT], AbstractAsyncContextManager[dict[str, Any] | None]]) -> Callable[[AppT], AbstractAsyncContextManager[dict[str, Any]]] +combine_lifespans(*lifespans: Callable[[AppT], AbstractAsyncContextManager[Mapping[str, Any] | None]]) -> Callable[[AppT], AbstractAsyncContextManager[dict[str, Any]]] ``` diff --git a/docs/python-sdk/fastmcp-utilities-skills.mdx b/docs/python-sdk/fastmcp-utilities-skills.mdx index 32aed81ca..ba1c9430f 100644 --- a/docs/python-sdk/fastmcp-utilities-skills.mdx +++ b/docs/python-sdk/fastmcp-utilities-skills.mdx @@ -10,7 +10,7 @@ Client utilities for discovering and downloading skills from MCP servers. ## Functions -### `list_skills` +### `list_skills` ```python list_skills(client: Client) -> list[SkillSummary] @@ -29,7 +29,7 @@ Discovers skills by finding resources with URIs matching the - List of SkillSummary objects with name, description, and URI -### `get_skill_manifest` +### `get_skill_manifest` ```python get_skill_manifest(client: Client, skill_name: str) -> SkillManifest @@ -49,7 +49,7 @@ Get the manifest for a specific skill. - `ValueError`: If manifest cannot be read or parsed -### `download_skill` +### `download_skill` ```python download_skill(client: Client, skill_name: str, target_dir: str | Path) -> Path @@ -95,19 +95,19 @@ Download all available skills from a server. ## Classes -### `SkillSummary` +### `SkillSummary` Summary information about a skill available on a server. -### `SkillFile` +### `SkillFile` Information about a file within a skill. -### `SkillManifest` +### `SkillManifest` Full manifest of a skill including all files. From 4262cfc16a0db181d98d905f5f3dffe77c005706 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:08:51 -0500 Subject: [PATCH 13/63] Add `fastmcp generate-cli` command (#3065) * Add `fastmcp generate-cli` command Connects to any MCP server, reads its tool/resource/prompt schemas, and writes a standalone Python CLI script with typed subcommands. * docs: add generate-cli documentation * docs: add generate-cli documentation; skip Windows executable test * fix: address PR review feedback - Sanitize tool and parameter names to valid Python identifiers - Replace bare except Exception with specific exception types - Escape server name in generated string literals - Handle trailing colon edge case in _derive_server_name - Clarify in docs that generated CLI is a client, not a bundled server * Fix string escaping issues in generate-cli - Use single-quoted docstrings to avoid triple-quote escaping issues - Escape quotes in app_name derived from server_name - Add tests for descriptions with quotes and server names with quotes Addresses CodeRabbit review comments about insufficient escaping. * Implement smart parameter handling for generate-cli - Simple types (str, int, float, bool): Direct typed flags - Arrays of simple types (list[str], list[int]): Repeatable flags via cyclopts - Complex types (objects, nested arrays): Accept JSON strings with parsing - JSON schema shown in help text for complex parameters - Proper escaping of newlines and quotes in help text - Filter out None and empty list defaults when calling tools This gives typed, discoverable CLIs for common cases while handling complex schemas via JSON input. * Update generate-cli docs to explain smart parameter handling - Document simple types as direct typed flags - Document arrays of simple types as repeatable flags - Document complex types as JSON strings with schema in help - Add examples showing all three patterns * Fix Codex review issues in generate-cli High priority fixes: - Complex type defaults: Serialize dict/list defaults to JSON strings - List params: Preserve help metadata with Annotated wrapper - Name collisions: Detect and error on sanitized name conflicts - JSON parsing: Use isinstance check for safety with defaults Added tests for: - Complex types with default values - Parameter name collision detection - Updated existing tests to match new format * Use pydantic_core.to_json for consistency - Generator now uses pydantic_core.to_json() instead of json.dumps() - Consistent with rest of fastmcp codebase - Generated CLI still uses plain json module (standalone script) * Move local imports to module level in generate-cli * Handle union item types and Python keyword collisions in generate-cli --- docs/clients/generate-cli.mdx | 129 +++++++ docs/docs.json | 1 + src/fastmcp/cli/cli.py | 2 + src/fastmcp/cli/generate.py | 634 ++++++++++++++++++++++++++++++++ tests/cli/test_generate_cli.py | 638 +++++++++++++++++++++++++++++++++ 5 files changed, 1404 insertions(+) create mode 100644 docs/clients/generate-cli.mdx create mode 100644 src/fastmcp/cli/generate.py create mode 100644 tests/cli/test_generate_cli.py diff --git a/docs/clients/generate-cli.mdx b/docs/clients/generate-cli.mdx new file mode 100644 index 000000000..833637423 --- /dev/null +++ b/docs/clients/generate-cli.mdx @@ -0,0 +1,129 @@ +--- +title: Generate CLI +sidebarTitle: Generate CLI +description: Turn any MCP server into a standalone, typed command-line tool. +icon: wand-magic-sparkles +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`fastmcp list` and `fastmcp call` let you poke at a server interactively, but they're developer tools — you always have to spell out the server spec, the tool name, and the arguments. `fastmcp generate-cli` takes the next step: it connects to a server, reads its schemas, and writes a standalone Python script where every tool is a proper subcommand with typed flags, help text, and tab completion. The result is a CLI that feels like it was hand-written for that specific server. + +The key insight is that MCP tool schemas already contain everything a CLI framework needs: parameter names, types, descriptions, required/optional status, and defaults. `generate-cli` maps that schema into [cyclopts](https://cyclopts.readthedocs.io/) commands, so JSON Schema types become Python type annotations, descriptions become `--help` text, and required parameters become mandatory flags. + +## Generating a Script + +Point the command at any server spec — URLs, Python files, discovered server names, MCPConfig JSON — and it writes a CLI script: + +```bash +fastmcp generate-cli weather +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`: + +```bash +fastmcp generate-cli weather -f +fastmcp generate-cli weather my_cli.py -f +``` + +Name-based resolution works here too, so if you have a server configured in Claude Desktop, Cursor, or any other supported editor, you can reference it by name. Run [`fastmcp discover`](/clients/cli#discovering-configured-servers) to see what's available. + +```bash +fastmcp generate-cli claude-code:my-server output.py +``` + +The `--timeout` and `--auth` flags work the same way they do in `fastmcp list` and `fastmcp call`. + +## What You Get + +The generated script is a regular Python file — executable, editable, and yours. Here's what it looks like in practice: + +``` +$ python cli.py --help +Usage: weather-cli COMMAND + +CLI for weather MCP server + +Commands: + call-tool Call a tool on the server + list-tools List available tools. + list-resources List available resources. + read-resource Read a resource by URI. + list-prompts List available prompts. + get-prompt Get a prompt by name. Pass arguments as key=value pairs. +``` + +The `call-tool` subcommand is where the generated code lives. Each tool on the server becomes its own command: + +``` +$ python cli.py call-tool --help +Usage: weather-cli call-tool COMMAND + +Call a tool on the server + +Commands: + get_forecast Get the weather forecast for a city. + search_city Search for a city by name. +``` + +And each tool has typed parameters with help text pulled directly from the server's schema: + +``` +$ python cli.py call-tool get_forecast --help +Usage: weather-cli call-tool get_forecast [OPTIONS] + +Get the weather forecast for a city. + +Options: + --city [str] City name (required) + --days [int] Number of forecast days (default: 3) +``` + +Tool names are preserved exactly as the server defines them — underscores stay as underscores, so `call-tool get_forecast` matches what the server expects. + +## 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`. + +At the top of the generated file, a `CLIENT_SPEC` variable holds the resolved transport: either a URL string or a `StdioTransport` with the command and arguments baked in. Every invocation connects through this spec, so the script works without any external configuration. + +### Parameter Handling + +Parameters are mapped intelligently based on their complexity: + +**Simple types** (`string`, `integer`, `number`, `boolean`) become typed Python parameters with clean flags: +```bash +python cli.py call-tool get_forecast --city London --days 3 +``` + +**Arrays of simple types** (`array` with `string`/`integer`/`number`/`boolean` items) become `list[T]` parameters that accept multiple flags: +```bash +python cli.py call-tool tag_items --tags python --tags fastapi --tags mcp +``` + +**Complex types** (objects, nested arrays, or unions) accept JSON strings. The tool's `--help` displays the full JSON schema so you know exactly what structure to pass: +```bash +python cli.py call-tool create_user \ + --name John \ + --metadata '{"role": "admin", "dept": "engineering"}' +``` + +Required parameters are mandatory flags; optional ones default to their schema default or `None`. Empty values are filtered out before calling the server. + +Beyond tool commands, the script includes generic commands that work regardless of what the server exposes: `list-tools`, `list-resources`, `read-resource`, `list-prompts`, and `get-prompt`. These connect to the server at runtime, so they always reflect the server's current state even if the tools have changed since generation. + +## Editing the Output + +The most common edit is changing `CLIENT_SPEC`. If you generated from a local dev server and want to point at production, just change the string. If you generated from a discovered name and want to pin the transport, replace it with an explicit URL or `StdioTransport`. + +Beyond that, it's a regular Python file. You can add commands, change the output formatting, integrate it into a larger application, or strip out the parts you don't need. The helper functions (`_call_tool`, `_print_tool_result`) are thin wrappers around `fastmcp.Client` that are easy to adapt. + +The generated script requires `fastmcp` as a dependency. If the script lives outside a project that already has fastmcp installed, `uv run` is the easiest way to run it without permanent installation: + +```bash +uv run --with fastmcp python cli.py call-tool get_forecast --city London +``` diff --git a/docs/docs.json b/docs/docs.json index b7f26ab6b..916fa2640 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -169,6 +169,7 @@ "pages": [ "clients/client", "clients/cli", + "clients/generate-cli", "clients/transports", { "group": "Core Operations", diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 85b9d9ff5..147d9dc2d 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -20,6 +20,7 @@ from rich.table import Table import fastmcp from fastmcp.cli import run as run_module 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 from fastmcp.cli.tasks import tasks_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config @@ -957,6 +958,7 @@ app.command(tasks_app) app.command(list_command, name="list") app.command(call_command, name="call") app.command(discover_command, name="discover") +app.command(generate_cli_command, name="generate-cli") if __name__ == "__main__": diff --git a/src/fastmcp/cli/generate.py b/src/fastmcp/cli/generate.py new file mode 100644 index 000000000..7fdc6cd8a --- /dev/null +++ b/src/fastmcp/cli/generate.py @@ -0,0 +1,634 @@ +"""Generate a standalone CLI script from an MCP server's capabilities.""" + +import keyword +import re +import sys +import textwrap +from pathlib import Path +from typing import Annotated, Any +from urllib.parse import urlparse + +import cyclopts +import mcp.types +import pydantic_core +from mcp import McpError +from rich.console import Console + +from fastmcp.cli.client import _build_client, resolve_server_spec +from fastmcp.client.transports.base import ClientTransport +from fastmcp.client.transports.stdio import StdioTransport +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli.generate") +console = Console() + +# --------------------------------------------------------------------------- +# JSON Schema type → Python type string +# --------------------------------------------------------------------------- + +_SIMPLE_TYPES = {"string", "integer", "number", "boolean", "null"} + + +def _is_simple_type(schema: dict[str, Any]) -> bool: + """Check if a schema represents a simple (non-complex) type.""" + schema_type = schema.get("type") + if isinstance(schema_type, list): + # Union of types - simple only if all are simple + return all(t in _SIMPLE_TYPES for t in schema_type) + return schema_type in _SIMPLE_TYPES + + +def _is_simple_array(schema: dict[str, Any]) -> tuple[bool, str | None]: + """Check if schema is an array of simple types. + + Returns (is_simple_array, item_type_str). + """ + if schema.get("type") != "array": + return False, None + + items = schema.get("items", {}) + if not _is_simple_type(items): + return False, None + + # Map JSON Schema type to Python type + item_type = items.get("type", "string") + if isinstance(item_type, list): + return False, None + type_map = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + } + py_type = type_map.get(item_type) + if py_type is None: + return False, None + return True, py_type + + +def _schema_to_python_type(schema: dict[str, Any]) -> tuple[str, bool]: + """Convert a JSON Schema to a Python type annotation. + + Returns (type_annotation, needs_json_parsing). + """ + # Check for simple array first + is_simple_arr, item_type = _is_simple_array(schema) + if is_simple_arr: + return f"list[{item_type}]", False + + # Check for simple type + if _is_simple_type(schema): + schema_type = schema.get("type", "string") + if isinstance(schema_type, list): + # Union of simple types + type_map = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + "null": "None", + } + parts = [type_map.get(t, "str") for t in schema_type] + return " | ".join(parts), False + + type_map = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + "null": "None", + } + return type_map.get(schema_type, "str"), False + + # Complex type - needs JSON parsing + return "str", True + + +def _format_schema_for_help(schema: dict[str, Any]) -> str: + """Format a JSON schema for display in help text.""" + # Pretty print the schema, indented for help text + schema_str = pydantic_core.to_json(schema, indent=2).decode() + # Indent each line for help text alignment + lines = schema_str.split("\n") + indented = "\n ".join(lines) + return f"JSON Schema: {indented}" + + +# --------------------------------------------------------------------------- +# Transport serialization +# --------------------------------------------------------------------------- + + +def serialize_transport( + resolved: str | dict[str, Any] | ClientTransport, +) -> tuple[str, set[str]]: + """Serialize a resolved transport to a Python expression string. + + Returns ``(expression, extra_imports)`` where *extra_imports* is a set of + import lines needed by the expression. + """ + if isinstance(resolved, str): + return repr(resolved), set() + + if isinstance(resolved, StdioTransport): + parts = [f"command={resolved.command!r}", f"args={resolved.args!r}"] + if resolved.env: + parts.append(f"env={resolved.env!r}") + if resolved.cwd: + parts.append(f"cwd={resolved.cwd!r}") + expr = f"StdioTransport({', '.join(parts)})" + imports = {"from fastmcp.client.transports import StdioTransport"} + return expr, imports + + if isinstance(resolved, dict): + return repr(resolved), set() + + # Fallback: try repr + return repr(resolved), set() + + +# --------------------------------------------------------------------------- +# Per-tool code generation +# --------------------------------------------------------------------------- + + +def _to_python_identifier(name: str) -> str: + """Sanitize a string into a valid Python identifier.""" + safe = re.sub(r"[^a-zA-Z0-9_]", "_", name) + if safe and safe[0].isdigit(): + safe = f"_{safe}" + safe = safe or "_unnamed" + if keyword.iskeyword(safe): + safe = f"{safe}_" + return safe + + +def _tool_function_source(tool: mcp.types.Tool) -> str: + """Generate the source for a single ``@call_tool_app.command`` function.""" + schema = tool.inputSchema + properties: dict[str, Any] = schema.get("properties", {}) + required = set(schema.get("required", [])) + + # Build parameter lines and track which need JSON parsing + param_lines: list[str] = [] + call_args: list[str] = [] + json_params: list[tuple[str, str]] = [] # (prop_name, safe_name) + seen_names: dict[str, str] = {} # safe_name -> original prop_name + + for prop_name, prop_schema in properties.items(): + py_type, needs_json = _schema_to_python_type(prop_schema) + help_text = prop_schema.get("description", "") + is_required = prop_name in required + safe_name = _to_python_identifier(prop_name) + + # Check for name collisions after sanitization + if safe_name in seen_names: + raise ValueError( + f"Parameter name collision: '{prop_name}' and '{seen_names[safe_name]}' " + f"both sanitize to '{safe_name}'" + ) + seen_names[safe_name] = prop_name + + # For complex types, add schema to help text + if needs_json: + schema_help = _format_schema_for_help(prop_schema) + help_text = f"{help_text}\\n{schema_help}" if help_text else schema_help + json_params.append((prop_name, safe_name)) + + # Escape special characters in help text + help_escaped = ( + help_text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + ) + + # Build parameter annotation + if is_required: + annotation = ( + f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' + ) + param_lines.append(f" {safe_name}: {annotation},") + else: + default = prop_schema.get("default") + if default is not None: + # For complex types with defaults, serialize to JSON string + if needs_json: + default_str = pydantic_core.to_json(default, fallback=str).decode() + annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' + param_lines.append( + f" {safe_name}: {annotation} = {default_str!r}," + ) + else: + annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' + param_lines.append(f" {safe_name}: {annotation} = {default!r},") + else: + # For list types, default to empty list; others default to None + if py_type.startswith("list["): + annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' + param_lines.append(f" {safe_name}: {annotation} = [],") + else: + annotation = f'Annotated[{py_type} | None, cyclopts.Parameter(help="{help_escaped}")]' + param_lines.append(f" {safe_name}: {annotation} = None,") + + call_args.append(f"{prop_name!r}: {safe_name}") + + # Function name: sanitize to valid Python identifier + fn_name = _to_python_identifier(tool.name) + + # Docstring - use single-quoted docstrings to avoid triple-quote escaping issues + description = (tool.description or "").replace("\\", "\\\\").replace("'", "\\'") + + lines = [] + lines.append("") + # Always pass name= to preserve the original tool name (cyclopts + # would otherwise convert underscores to hyphens). + lines.append(f"@call_tool_app.command(name={tool.name!r})") + lines.append(f"async def {fn_name}(") + + if param_lines: + lines.append(" *,") + lines.extend(param_lines) + + lines.append(") -> None:") + lines.append(f" '''{description}'''") + + # Add JSON parsing for complex parameters + if json_params: + lines.append(" # Parse JSON parameters") + for _prop_name, safe_name in json_params: + lines.append( + f" {safe_name}_parsed = json.loads({safe_name}) if isinstance({safe_name}, str) else {safe_name}" + ) + lines.append("") + + # Build call arguments, using parsed versions for JSON params + call_arg_parts = [] + for prop_name, _ in properties.items(): + safe_name = _to_python_identifier(prop_name) + if any(pn == prop_name for pn, _ in json_params): + call_arg_parts.append(f"{prop_name!r}: {safe_name}_parsed") + else: + call_arg_parts.append(f"{prop_name!r}: {safe_name}") + + dict_items = ", ".join(call_arg_parts) + lines.append(f" await _call_tool({tool.name!r}, {{{dict_items}}})") + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Full script generation +# --------------------------------------------------------------------------- + + +def generate_cli_script( + server_name: str, + server_spec: str, + transport_code: str, + extra_imports: set[str], + tools: list[mcp.types.Tool], +) -> str: + """Generate the full CLI script source code.""" + + # Determine app name from server_name - sanitize for use in string literal + app_name = ( + server_name.replace(" ", "-").lower().replace("\\", "\\\\").replace('"', '\\"') + ) + + # --- Header --- + lines: list[str] = [] + lines.append("#!/usr/bin/env python3") + lines.append(f'"""CLI for {server_name} MCP server.') + lines.append("") + lines.append(f"Generated by: fastmcp generate-cli {server_spec}") + lines.append('"""') + lines.append("") + + # --- Imports --- + lines.append("import json") + lines.append("import sys") + lines.append("from typing import Annotated") + lines.append("") + lines.append("import cyclopts") + lines.append("import mcp.types") + lines.append("from rich.console import Console") + lines.append("") + lines.append("from fastmcp import Client") + for imp in sorted(extra_imports): + lines.append(imp) + lines.append("") + + # --- Transport config --- + lines.append("# Modify this to change how the CLI connects to the MCP server.") + lines.append(f"CLIENT_SPEC = {transport_code}") + lines.append("") + + # --- App setup --- + server_name_escaped = server_name.replace("\\", "\\\\").replace('"', '\\"') + lines.append( + f'app = cyclopts.App(name="{app_name}", help="CLI for {server_name_escaped} MCP server")' + ) + lines.append( + 'call_tool_app = cyclopts.App(name="call-tool", help="Call a tool on the server")' + ) + lines.append("app.command(call_tool_app)") + lines.append("") + lines.append("console = Console()") + lines.append("") + lines.append("") + + # --- Shared helpers --- + lines.append( + textwrap.dedent("""\ + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + + def _print_tool_result(result): + if result.is_error: + for block in result.content: + if isinstance(block, mcp.types.TextContent): + console.print(f"[bold red]Error:[/bold red] {block.text}") + else: + console.print(f"[bold red]Error:[/bold red] {block}") + sys.exit(1) + + if result.structured_content is not None: + console.print_json(json.dumps(result.structured_content)) + return + + for block in result.content: + if isinstance(block, mcp.types.TextContent): + console.print(block.text) + elif isinstance(block, mcp.types.ImageContent): + size = len(block.data) * 3 // 4 + console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") + elif isinstance(block, mcp.types.AudioContent): + size = len(block.data) * 3 // 4 + console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") + + + async def _call_tool(tool_name: str, arguments: dict) -> None: + # Filter out None values and empty lists (defaults for optional array params) + filtered = { + k: v + for k, v in arguments.items() + if v is not None and (not isinstance(v, list) or len(v) > 0) + } + async with Client(CLIENT_SPEC) as client: + result = await client.call_tool(tool_name, filtered, raise_on_error=False) + _print_tool_result(result) + if result.is_error: + sys.exit(1)""") + ) + lines.append("") + lines.append("") + + # --- Generic commands --- + lines.append( + textwrap.dedent("""\ + # --------------------------------------------------------------------------- + # List / read commands + # --------------------------------------------------------------------------- + + + @app.command + async def list_tools() -> None: + \"\"\"List available tools.\"\"\" + async with Client(CLIENT_SPEC) as client: + tools = await client.list_tools() + if not tools: + console.print("[dim]No tools found.[/dim]") + return + for tool in tools: + sig_parts = [] + props = tool.inputSchema.get("properties", {}) + required = set(tool.inputSchema.get("required", [])) + for pname, pschema in props.items(): + ptype = pschema.get("type", "string") + if pname in required: + sig_parts.append(f"{pname}: {ptype}") + else: + sig_parts.append(f"{pname}: {ptype} = ...") + sig = f"{tool.name}({', '.join(sig_parts)})" + console.print(f" [cyan]{sig}[/cyan]") + if tool.description: + console.print(f" {tool.description}") + console.print() + + + @app.command + async def list_resources() -> None: + \"\"\"List available resources.\"\"\" + async with Client(CLIENT_SPEC) as client: + resources = await client.list_resources() + if not resources: + console.print("[dim]No resources found.[/dim]") + return + for r in resources: + console.print(f" [cyan]{r.uri}[/cyan]") + desc_parts = [r.name or "", r.description or ""] + desc = " — ".join(p for p in desc_parts if p) + if desc: + console.print(f" {desc}") + console.print() + + + @app.command + async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource URI")]) -> None: + \"\"\"Read a resource by URI.\"\"\" + async with Client(CLIENT_SPEC) as client: + contents = await client.read_resource(uri) + for block in contents: + if isinstance(block, mcp.types.TextResourceContents): + console.print(block.text) + elif isinstance(block, mcp.types.BlobResourceContents): + size = len(block.blob) * 3 // 4 + console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") + + + @app.command + async def list_prompts() -> None: + \"\"\"List available prompts.\"\"\" + async with Client(CLIENT_SPEC) as client: + prompts = await client.list_prompts() + if not prompts: + console.print("[dim]No prompts found.[/dim]") + return + for p in prompts: + args_str = "" + if p.arguments: + parts = [a.name for a in p.arguments] + args_str = f"({', '.join(parts)})" + console.print(f" [cyan]{p.name}{args_str}[/cyan]") + if p.description: + console.print(f" {p.description}") + console.print() + + + @app.command + async def get_prompt( + name: Annotated[str, cyclopts.Parameter(help="Prompt name")], + *arguments: str, + ) -> None: + \"\"\"Get a prompt by name. Pass arguments as key=value pairs.\"\"\" + parsed: dict[str, str] = {} + for arg in arguments: + if "=" not in arg: + console.print(f"[bold red]Error:[/bold red] Invalid argument {arg!r} — expected key=value") + sys.exit(1) + key, value = arg.split("=", 1) + parsed[key] = value + + async with Client(CLIENT_SPEC) as client: + result = await client.get_prompt(name, parsed or None) + for msg in result.messages: + console.print(f"[bold]{msg.role}:[/bold]") + if isinstance(msg.content, mcp.types.TextContent): + console.print(f" {msg.content.text}") + elif isinstance(msg.content, mcp.types.ImageContent): + size = len(msg.content.data) * 3 // 4 + console.print(f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]") + else: + console.print(f" {msg.content}") + console.print()""") + ) + lines.append("") + lines.append("") + + # --- Generated tool commands --- + if tools: + lines.append( + "# ---------------------------------------------------------------------------" + ) + lines.append("# Tool commands (generated from server schema)") + lines.append( + "# ---------------------------------------------------------------------------" + ) + + for tool in tools: + lines.append(_tool_function_source(tool)) + + # --- Entry point --- + lines.append("") + lines.append('if __name__ == "__main__":') + lines.append(" app()") + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI command +# --------------------------------------------------------------------------- + + +async def generate_cli_command( + server_spec: Annotated[ + str, + cyclopts.Parameter( + help="Server URL, Python file, MCPConfig JSON, discovered name, or .js file", + ), + ], + output: Annotated[ + str, + cyclopts.Parameter( + help="Output file path (default: cli.py)", + ), + ] = "cli.py", + *, + force: Annotated[ + bool, + cyclopts.Parameter( + name=["-f", "--force"], + help="Overwrite output file if it exists", + ), + ] = False, + timeout: Annotated[ + float | None, + cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), + ] = None, + auth: Annotated[ + str | None, + cyclopts.Parameter( + "--auth", + help="Auth method: 'oauth', a bearer token string, or 'none' to disable", + ), + ] = None, +) -> 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. + + 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 + """ + output_path = Path(output) + if output_path.exists() and not force: + console.print( + f"[bold red]Error:[/bold red] [cyan]{output_path}[/cyan] already exists. " + f"Use [cyan]-f[/cyan] to overwrite." + ) + sys.exit(1) + + # Resolve the server spec to a transport + resolved = resolve_server_spec(server_spec) + transport_code, extra_imports = serialize_transport(resolved) + + # Derive a human-friendly server name from the spec + server_name = _derive_server_name(server_spec) + + # Connect and discover capabilities + client = _build_client(resolved, timeout=timeout, auth=auth) + + try: + async with client: + tools = await client.list_tools() + console.print( + f"[dim]Discovered {len(tools)} tool(s) from {server_spec}[/dim]" + ) + + except (RuntimeError, TimeoutError, McpError, OSError) as exc: + console.print(f"[bold red]Error:[/bold red] Could not connect: {exc}") + sys.exit(1) + + # Generate and write the script + script = generate_cli_script( + server_name=server_name, + server_spec=server_spec, + transport_code=transport_code, + extra_imports=extra_imports, + tools=tools, + ) + + output_path.write_text(script) + output_path.chmod(output_path.stat().st_mode | 0o111) # make executable + + console.print( + f"[green]✓[/green] Wrote [cyan]{output_path}[/cyan] " + f"with {len(tools)} tool command(s)" + ) + console.print(f"[dim]Run: python {output_path} --help[/dim]") + + +def _derive_server_name(server_spec: str) -> str: + """Derive a human-friendly name from a server spec.""" + # URL — use hostname + if server_spec.startswith(("http://", "https://")): + parsed = urlparse(server_spec) + return parsed.hostname or "server" + + # File path — use stem + if server_spec.endswith((".py", ".js", ".json")): + return Path(server_spec).stem + + # Bare name or qualified name + if ":" in server_spec: + name = server_spec.split(":", 1)[1] + return name or server_spec.split(":", 1)[0] + + return server_spec diff --git a/tests/cli/test_generate_cli.py b/tests/cli/test_generate_cli.py new file mode 100644 index 000000000..f513338d7 --- /dev/null +++ b/tests/cli/test_generate_cli.py @@ -0,0 +1,638 @@ +"""Tests for fastmcp generate-cli command.""" + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import mcp.types +import pytest + +from fastmcp import FastMCP +from fastmcp.cli import generate as generate_module +from fastmcp.cli.client import Client +from fastmcp.cli.generate import ( + _derive_server_name, + _schema_to_python_type, + _to_python_identifier, + _tool_function_source, + generate_cli_command, + generate_cli_script, + serialize_transport, +) +from fastmcp.client.transports.stdio import StdioTransport + +# --------------------------------------------------------------------------- +# _schema_to_python_type +# --------------------------------------------------------------------------- + + +class TestSchemaToPythonType: + def test_simple_string(self): + py_type, needs_json = _schema_to_python_type({"type": "string"}) + assert py_type == "str" + assert needs_json is False + + def test_simple_integer(self): + py_type, needs_json = _schema_to_python_type({"type": "integer"}) + assert py_type == "int" + assert needs_json is False + + def test_simple_number(self): + py_type, needs_json = _schema_to_python_type({"type": "number"}) + assert py_type == "float" + assert needs_json is False + + def test_simple_boolean(self): + py_type, needs_json = _schema_to_python_type({"type": "boolean"}) + assert py_type == "bool" + assert needs_json is False + + def test_array_of_strings(self): + py_type, needs_json = _schema_to_python_type( + {"type": "array", "items": {"type": "string"}} + ) + assert py_type == "list[str]" + assert needs_json is False + + def test_array_of_integers(self): + py_type, needs_json = _schema_to_python_type( + {"type": "array", "items": {"type": "integer"}} + ) + assert py_type == "list[int]" + assert needs_json is False + + def test_complex_object(self): + py_type, needs_json = _schema_to_python_type({"type": "object"}) + assert py_type == "str" + assert needs_json is True + + def test_complex_nested_array(self): + py_type, needs_json = _schema_to_python_type( + {"type": "array", "items": {"type": "object"}} + ) + assert py_type == "str" + assert needs_json is True + + def test_union_of_simple_types(self): + py_type, needs_json = _schema_to_python_type({"type": ["string", "null"]}) + assert py_type == "str | None" + assert needs_json is False + + +# --------------------------------------------------------------------------- +# _to_python_identifier +# --------------------------------------------------------------------------- + + +class TestToPythonIdentifier: + def test_plain_name(self): + assert _to_python_identifier("hello") == "hello" + + def test_hyphens(self): + assert _to_python_identifier("get-forecast") == "get_forecast" + + def test_dots_and_slashes(self): + assert _to_python_identifier("a.b/c") == "a_b_c" + + def test_leading_digit(self): + assert _to_python_identifier("3d_render") == "_3d_render" + + def test_spaces(self): + assert _to_python_identifier("my tool") == "my_tool" + + def test_empty_string(self): + assert _to_python_identifier("") == "_unnamed" + + +# --------------------------------------------------------------------------- +# serialize_transport +# --------------------------------------------------------------------------- + + +class TestSerializeTransport: + def test_url_string(self): + code, imports = serialize_transport("http://localhost:8000/mcp") + assert code == "'http://localhost:8000/mcp'" + assert imports == set() + + def test_stdio_transport_basic(self): + transport = StdioTransport(command="fastmcp", args=["run", "server.py"]) + code, imports = serialize_transport(transport) + assert "StdioTransport" in code + assert "command='fastmcp'" in code + assert "args=['run', 'server.py']" in code + assert "from fastmcp.client.transports import StdioTransport" in imports + + def test_stdio_transport_with_env(self): + transport = StdioTransport( + command="python", args=["-m", "myserver"], env={"KEY": "val"} + ) + code, imports = serialize_transport(transport) + assert "env={'KEY': 'val'}" in code + + def test_dict_passthrough(self): + d: dict[str, Any] = {"mcpServers": {"test": {"url": "http://localhost"}}} + code, imports = serialize_transport(d) + assert "mcpServers" in code + assert imports == set() + + +# --------------------------------------------------------------------------- +# _tool_function_source +# --------------------------------------------------------------------------- + + +class TestToolFunctionSource: + def test_required_param(self): + tool = mcp.types.Tool( + name="greet", + inputSchema={ + "properties": {"name": {"type": "string", "description": "Who"}}, + "required": ["name"], + }, + ) + source = _tool_function_source(tool) + assert "async def greet(" in source + assert "name: Annotated[str" in source + assert "= None" not in source + assert "_call_tool('greet', {'name': name})" in source + + def test_optional_param(self): + tool = mcp.types.Tool( + name="search", + inputSchema={ + "properties": { + "query": {"type": "string", "description": "Search query"}, + "limit": {"type": "integer", "description": "Max results"}, + }, + "required": ["query"], + }, + ) + source = _tool_function_source(tool) + assert "query: Annotated[str" in source + assert "limit: Annotated[int | None" in source + assert "= None" in source + + def test_param_with_default(self): + tool = mcp.types.Tool( + name="fetch", + inputSchema={ + "properties": { + "url": {"type": "string", "description": "URL"}, + "timeout": { + "type": "integer", + "description": "Timeout", + "default": 30, + }, + }, + "required": ["url"], + }, + ) + source = _tool_function_source(tool) + assert "timeout: Annotated[int" in source + assert "= 30" in source + + def test_no_params(self): + tool = mcp.types.Tool( + name="ping", + inputSchema={"properties": {}}, + ) + source = _tool_function_source(tool) + assert "async def ping(" in source + assert "_call_tool('ping', {})" in source + + def test_preserves_underscores(self): + tool = mcp.types.Tool( + name="get_forecast", + inputSchema={ + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + ) + source = _tool_function_source(tool) + assert "async def get_forecast(" in source + + def test_sanitizes_tool_name(self): + tool = mcp.types.Tool( + name="my.tool/v2", + inputSchema={"properties": {}}, + ) + source = _tool_function_source(tool) + assert "async def my_tool_v2(" in source + assert "name='my.tool/v2'" in source + + def test_sanitizes_param_name(self): + tool = mcp.types.Tool( + name="fetch", + inputSchema={ + "properties": {"content-type": {"type": "string", "description": "CT"}}, + "required": ["content-type"], + }, + ) + source = _tool_function_source(tool) + assert "content_type: Annotated[str" in source + assert "'content-type': content_type" in source + + def test_description_in_docstring(self): + tool = mcp.types.Tool( + name="greet", + description="Say hello to someone.", + inputSchema={ + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + source = _tool_function_source(tool) + assert "'''Say hello to someone.'''" in source + + def test_description_with_quotes(self): + tool = mcp.types.Tool( + name="fetch", + description="Fetch data from 'source' API.", + inputSchema={ + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + ) + source = _tool_function_source(tool) + # Should escape single quotes in the description + assert r"Fetch data from \'source\' API." in source + # Generated code should compile + compile(source, "", "exec") + + def test_array_of_strings_parameter(self): + tool = mcp.types.Tool( + name="tag_items", + description="Tag multiple items.", + inputSchema={ + "properties": { + "item_id": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["item_id"], + }, + ) + source = _tool_function_source(tool) + # Should use list[str] type with help metadata + assert "tags: Annotated[list[str]" in source + assert "= []" in source + # Should not have JSON parsing for simple arrays + assert "json.loads" not in source + compile(source, "", "exec") + + def test_complex_object_parameter(self): + tool = mcp.types.Tool( + name="create_user", + description="Create a user.", + inputSchema={ + "properties": { + "name": {"type": "string"}, + "metadata": { + "type": "object", + "properties": { + "role": {"type": "string"}, + "dept": {"type": "string"}, + }, + }, + }, + "required": ["name"], + }, + ) + source = _tool_function_source(tool) + # Should use str type for complex object + assert "metadata: Annotated[str | None" in source + # Should include JSON schema in help (with escaped quotes) + assert "JSON Schema:" in source + assert '\\"type\\": \\"object\\"' in source + # Should have JSON parsing with isinstance check + assert ( + "metadata_parsed = json.loads(metadata) if isinstance(metadata, str) else metadata" + in source + ) + # Should use parsed version in call + assert "'metadata': metadata_parsed" in source + compile(source, "", "exec") + + def test_nested_array_parameter(self): + tool = mcp.types.Tool( + name="batch_process", + description="Process batches.", + inputSchema={ + "properties": { + "batches": { + "type": "array", + "items": { + "type": "object", + "properties": {"id": {"type": "string"}}, + }, + }, + }, + "required": ["batches"], + }, + ) + source = _tool_function_source(tool) + # Nested arrays need JSON parsing + assert "batches: Annotated[str" in source + assert "JSON Schema:" in source + assert ( + "batches_parsed = json.loads(batches) if isinstance(batches, str) else batches" + in source + ) + compile(source, "", "exec") + + def test_complex_type_with_default(self): + """Test that complex types with defaults are JSON-serialized.""" + tool = mcp.types.Tool( + name="configure", + inputSchema={ + "properties": { + "options": { + "type": "object", + "default": {"timeout": 30, "retry": True}, + }, + }, + }, + ) + source = _tool_function_source(tool) + # Default should be JSON string, not Python dict + # pydantic_core.to_json produces compact JSON + assert '= \'{"timeout":30,"retry":true}\'' in source + # Should parse safely even with default + assert "isinstance(options, str)" in source + compile(source, "", "exec") + + def test_name_collision_detection(self): + """Test that parameter name collisions are detected.""" + tool = mcp.types.Tool( + name="test", + inputSchema={ + "properties": { + "content-type": {"type": "string"}, + "content_type": {"type": "string"}, + }, + }, + ) + # Should raise ValueError for collision + with pytest.raises(ValueError, match="both sanitize to 'content_type'"): + _tool_function_source(tool) + + +# --------------------------------------------------------------------------- +# _derive_server_name +# --------------------------------------------------------------------------- + + +class TestDeriveServerName: + def test_bare_name(self): + assert _derive_server_name("weather") == "weather" + + def test_qualified_name(self): + assert _derive_server_name("cursor:weather") == "weather" + + def test_python_file(self): + assert _derive_server_name("server.py") == "server" + + def test_url(self): + assert _derive_server_name("http://localhost:8000/mcp") == "localhost" + + def test_trailing_colon(self): + assert _derive_server_name("source:") == "source" + + +# --------------------------------------------------------------------------- +# generate_cli_script — produces compilable Python +# --------------------------------------------------------------------------- + + +class TestGenerateCliScript: + def _make_tools(self) -> list[mcp.types.Tool]: + return [ + mcp.types.Tool( + name="greet", + description="Say hello", + inputSchema={ + "properties": { + "name": {"type": "string", "description": "Who to greet"}, + }, + "required": ["name"], + }, + ), + mcp.types.Tool( + name="add_numbers", + description="Add two numbers", + inputSchema={ + "properties": { + "a": {"type": "integer", "description": "First number"}, + "b": {"type": "integer", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + ), + ] + + def test_compiles(self): + script = generate_cli_script( + server_name="test", + server_spec="test", + transport_code='"http://localhost:8000/mcp"', + extra_imports=set(), + tools=self._make_tools(), + ) + compile(script, "", "exec") + + def test_contains_tool_functions(self): + script = generate_cli_script( + server_name="test", + server_spec="test", + transport_code='"http://localhost:8000/mcp"', + extra_imports=set(), + tools=self._make_tools(), + ) + assert "async def greet(" in script + assert "async def add_numbers(" in script + + def test_contains_generic_commands(self): + script = generate_cli_script( + server_name="test", + server_spec="test", + transport_code='"http://localhost:8000/mcp"', + extra_imports=set(), + tools=[], + ) + assert "async def list_tools(" in script + assert "async def list_resources(" in script + assert "async def list_prompts(" in script + assert "async def read_resource(" in script + assert "async def get_prompt(" in script + + def test_embeds_transport(self): + script = generate_cli_script( + server_name="test", + server_spec="test", + transport_code="StdioTransport(command='fastmcp', args=['run', 'x.py'])", + extra_imports={"from fastmcp.client.transports import StdioTransport"}, + tools=[], + ) + assert "StdioTransport(command='fastmcp'" in script + assert "from fastmcp.client.transports import StdioTransport" in script + + def test_no_tools_still_valid(self): + script = generate_cli_script( + server_name="empty", + server_spec="empty", + transport_code='"http://localhost"', + extra_imports=set(), + tools=[], + ) + compile(script, "", "exec") + assert "call_tool_app" in script + + def test_server_name_with_quotes(self): + """Test that server names with quotes are properly escaped.""" + script = generate_cli_script( + server_name='Test "Server" Name', + server_spec="test", + transport_code='"http://localhost"', + extra_imports=set(), + tools=[], + ) + # Should compile without syntax errors + compile(script, "", "exec") + # App name should have escaped quotes + assert r'app = cyclopts.App(name="test-\"server\"-name"' in script + + def test_compiles_with_unusual_names(self): + tools = [ + mcp.types.Tool( + name="my.tool/v2", + description="A tool with dots and slashes", + inputSchema={ + "properties": { + "content-type": {"type": "string", "description": "CT"}, + }, + "required": ["content-type"], + }, + ), + ] + script = generate_cli_script( + server_name="test", + server_spec="test", + transport_code='"http://localhost:8000/mcp"', + extra_imports=set(), + tools=tools, + ) + compile(script, "", "exec") + + def test_compiles_with_stdio_transport(self): + transport = StdioTransport(command="fastmcp", args=["run", "server.py"]) + transport_code, extra_imports = serialize_transport(transport) + script = generate_cli_script( + server_name="test", + server_spec="server.py", + transport_code=transport_code, + extra_imports=extra_imports, + tools=self._make_tools(), + ) + compile(script, "", "exec") + + +# --------------------------------------------------------------------------- +# generate_cli_command — integration tests +# --------------------------------------------------------------------------- + + +def _build_test_server() -> FastMCP: + """Create a minimal FastMCP server for integration tests.""" + server = FastMCP("TestServer") + + @server.tool + def greet(name: str) -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + @server.tool + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + @server.resource("test://greeting") + def greeting_resource() -> str: + """A static greeting resource.""" + return "Hello from resource!" + + @server.prompt + def ask(topic: str) -> str: + """Ask about a topic.""" + return f"Tell me about {topic}" + + return server + + +@pytest.fixture() +def _patch_client(): + """Patch resolve_server_spec and _build_client to use an in-process server.""" + server = _build_test_server() + + def fake_resolve(server_spec: Any, **kwargs: Any) -> str: + return "fake://server" + + def fake_build_client(resolved: Any, **kwargs: Any) -> Client: + return Client(server) + + with ( + patch.object(generate_module, "resolve_server_spec", side_effect=fake_resolve), + patch.object(generate_module, "_build_client", side_effect=fake_build_client), + ): + yield + + +class TestGenerateCliCommand: + @pytest.mark.usefixtures("_patch_client") + async def test_writes_file(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + assert output.exists() + content = output.read_text() + compile(content, str(output), "exec") + + @pytest.mark.usefixtures("_patch_client") + async def test_contains_tools(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + content = output.read_text() + assert "async def greet(" in content + assert "async def add(" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_default_output_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + await generate_cli_command("test-server") + assert (tmp_path / "cli.py").exists() + + @pytest.mark.usefixtures("_patch_client") + async def test_error_if_exists(self, tmp_path: Path): + output = tmp_path / "cli.py" + output.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(self, tmp_path: Path): + output = tmp_path / "cli.py" + output.write_text("existing") + await generate_cli_command("test-server", str(output), force=True) + content = output.read_text() + assert content != "existing" + assert "async def greet(" in content + + @pytest.mark.skipif( + sys.platform == "win32", reason="Unix executable bits N/A on Windows" + ) + @pytest.mark.usefixtures("_patch_client") + async def test_file_is_executable(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + assert output.stat().st_mode & 0o111 From 76f054e9574d6fd67c5b556ef692ee7ab922b8d8 Mon Sep 17 00:00:00 2001 From: Nathan <36056683+nathanwelsh8@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:33:33 +0000 Subject: [PATCH 14/63] fix: enforce redirect URI validation when allowed_client_redirect_uris is supplied (#3066) * fix: enforce redirect URI validation when patterns are explicitly configured Security fix: When allowed_redirect_uri_patterns is explicitly set, reject redirect URIs that don't match the patterns instead of falling back to parent validation. This prevents unauthorized OAuth clients from bypassing the allowlist and accessing protected resources. * Update models.py no need to return twice * fix redirect uri access issue * update style * feat: add unit test to enforce fallback not applied when redirect uri's supplied * fix: improve test case * apply linter * refactor: simplify logic and do not exposed allowed redirect patterns --------- Co-authored-by: Nathan <2381793w@student.gla.ac.uk> --- src/fastmcp/server/auth/oauth_proxy/models.py | 11 ++++-- .../test_oauth_proxy_redirect_validation.py | 36 ++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py index fe6c77941..53c939f2a 100644 --- a/src/fastmcp/server/auth/oauth_proxy/models.py +++ b/src/fastmcp/server/auth/oauth_proxy/models.py @@ -8,7 +8,7 @@ from __future__ import annotations import hashlib from typing import Any, Final -from mcp.shared.auth import OAuthClientInformationFull +from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull from pydantic import AnyUrl, BaseModel, Field from fastmcp.server.auth.redirect_validation import validate_redirect_uri @@ -172,7 +172,12 @@ class ProxyDCRClient(OAuthClientInformationFull): allowed_patterns=self.allowed_redirect_uri_patterns, ): return redirect_uri - # Fall back to normal validation if not in allowed patterns - return super().validate_redirect_uri(redirect_uri) + + # If patterns are explicitly configured then reject non-matching URIs + 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 return super().validate_redirect_uri(redirect_uri) diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 336029197..20d4afdd7 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -67,6 +67,38 @@ class TestProxyDCRClient: # Not allowed by patterns - will fallback to base validation with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000")) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri( + AnyUrl("cursor://anysphere.cursor-mcp/oauth/callback") + ) + + def test_default_not_applied_when_custom_patterns_supplied(self): + """Test that default validation is not applied when custom patterns are supplied.""" + allowed_patterns = [ + "cursor://anysphere.cursor-mcp/oauth/callback", + "https://app.example.com/*", + ] + + client = ProxyDCRClient( + client_id="test", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:3000")], + allowed_redirect_uri_patterns=allowed_patterns, + ) + + assert client.validate_redirect_uri( + AnyUrl("https://app.example.com/oauth/callback") + ) + assert client.validate_redirect_uri( + AnyUrl("cursor://anysphere.cursor-mcp/oauth/callback") + ) + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:3000")) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000")) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("https://example.com")) def test_empty_list_allows_none(self): """Test that empty pattern list allows no URIs.""" @@ -77,7 +109,7 @@ class TestProxyDCRClient: allowed_redirect_uri_patterns=[], ) - # Nothing should be allowed (except the pre-registered one via fallback) + # Nothing should be allowed (except the pre-registered redirect_uris via fallback) # Pre-registered URI should work via fallback to base validation assert client.validate_redirect_uri(AnyUrl("http://localhost:3000")) @@ -86,6 +118,8 @@ class TestProxyDCRClient: client.validate_redirect_uri(AnyUrl("http://example.com")) with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(AnyUrl("https://anywhere.com:9999/path")) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:5000")) def test_none_redirect_uri(self): """Test that None redirect URI uses default behavior.""" From 422384e576ac2ac34d58db9473a7db888b5ce258 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:35:39 -0500 Subject: [PATCH 15/63] Fix --reload port conflict when using explicit port (#3070) * Fix --reload port conflict by killing entire process group * Gate start_new_session on Unix (no-op on Windows) --- src/fastmcp/cli/run.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 64fbf0d06..68ec876bb 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -3,6 +3,7 @@ import asyncio import contextlib import json +import os import re import signal import sys @@ -288,10 +289,34 @@ def _watch_filter(_change: Change, path: str) -> bool: async def _terminate_process(process: asyncio.subprocess.Process) -> None: - """Terminate a subprocess immediately.""" + """Terminate a subprocess and all its children. + + Sends SIGTERM to the process group first for graceful shutdown, + then falls back to SIGKILL if the process doesn't exit in time. + """ if process.returncode is not None: return - process.kill() + + pid = process.pid + + if sys.platform != "win32": + # Send SIGTERM to the entire process group for graceful shutdown + with contextlib.suppress(ProcessLookupError, OSError): + os.killpg(os.getpgid(pid), signal.SIGTERM) + + # Wait briefly for graceful exit + try: + await asyncio.wait_for(process.wait(), timeout=3.0) + return + except asyncio.TimeoutError: + pass + + # Force kill the entire process group + with contextlib.suppress(ProcessLookupError, OSError): + os.killpg(os.getpgid(pid), signal.SIGKILL) + else: + process.kill() + await process.wait() @@ -347,6 +372,8 @@ async def run_with_reload( stdin=None, stdout=None, stderr=None, + # Own process group so _terminate_process can kill the whole tree + start_new_session=sys.platform != "win32", ) # Watch for either: file changes OR process death From d5f5300e63efa8ceceec5c0a6895b9cf9be24f39 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Wed, 4 Feb 2026 14:40:09 -0800 Subject: [PATCH 16/63] Add server version to banner (#3076) --- src/fastmcp/utilities/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 51b6007a6..070931fa6 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -217,7 +217,10 @@ def log_server_banner(server: FastMCP[Any]) -> None: info_table.add_column(style="cyan", justify="left") # Label column info_table.add_column(style="dim", justify="left") # Value column - info_table.add_row("🖥", "Server:", Text(server.name, style="dim")) + server_info = server.name + if server.version: + server_info += f", {server.version}" + info_table.add_row("🖥", "Server:", Text(server_info, style="dim")) info_table.add_row("🚀", "Deploy free:", "https://fastmcp.cloud") # Create panel with logo, title, and information using Group From b776089ecc0662d357410e98be8d6e66928b4ac7 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Wed, 4 Feb 2026 23:30:31 +0000 Subject: [PATCH 17/63] Add @handle_tool_errors decorator for standardized error handling (#2885) * Add @handle_tool_errors decorator for standardized error handling * Add tests for @handle_tool_errors decorator * Add documentation for @handle_tool_errors decorator * Fix type checking: use getattr for func.__name__ with fallback * Add @overload declarations for proper async/sync type checking * Fix type checking: reorder overloads and use Coroutine for async typing * Update lockfile and fix test formatting * Improve error_handling module: add docstrings, fix logging, handle cancellation, and update documentation * Add auth error mappings, doc tweaks, and doc fix * Pivot to hybrid approach * Remove decorator, keep only core 429/timeout handling --------- Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- src/fastmcp/server/server.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1172793c9..27494f706 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1095,6 +1095,18 @@ class FastMCP( raise except Exception as e: logger.exception(f"Error calling tool {name!r}") + # Handle actionable errors that should reach the LLM + # even when masking is enabled + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 429: + raise ToolError( + "Rate limited by upstream API, please retry later" + ) from e + if isinstance(e, httpx.TimeoutException): + raise ToolError( + "Upstream request timed out, please retry" + ) from e + # Standard masking logic if self._mask_error_details: raise ToolError(f"Error calling tool {name!r}") from e raise ToolError(f"Error calling tool {name!r}: {e}") from e @@ -1198,6 +1210,17 @@ class FastMCP( raise except Exception as e: logger.exception(f"Error reading resource {uri!r}") + # Handle actionable errors that should reach the LLM + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 429: + raise ResourceError( + "Rate limited by upstream API, please retry later" + ) from e + if isinstance(e, httpx.TimeoutException): + raise ResourceError( + "Upstream request timed out, please retry" + ) from e + # Standard masking logic if self._mask_error_details: raise ResourceError( f"Error reading resource {uri!r}" @@ -1226,6 +1249,17 @@ class FastMCP( raise except Exception as e: logger.exception(f"Error reading resource {uri!r}") + # Handle actionable errors that should reach the LLM + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 429: + raise ResourceError( + "Rate limited by upstream API, please retry later" + ) from e + if isinstance(e, httpx.TimeoutException): + raise ResourceError( + "Upstream request timed out, please retry" + ) from e + # Standard masking logic if self._mask_error_details: raise ResourceError(f"Error reading resource {uri!r}") from e raise ResourceError(f"Error reading resource {uri!r}: {e}") from e From 5e0211dd5ed8bfaa763ca9ad83fea900234cfdc0 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:31:07 -0500 Subject: [PATCH 18/63] chore: Update SDK documentation (#3069) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/docs.json | 1 + docs/python-sdk/fastmcp-cli-cli.mdx | 12 ++-- docs/python-sdk/fastmcp-cli-generate.mdx | 54 ++++++++++++++++ docs/python-sdk/fastmcp-cli-run.mdx | 14 ++--- ...cp-server-providers-openapi-components.mdx | 10 +-- ...tmcp-server-providers-openapi-provider.mdx | 13 +++- docs/python-sdk/fastmcp-server-server.mdx | 63 ++++++++++--------- 7 files changed, 116 insertions(+), 51 deletions(-) create mode 100644 docs/python-sdk/fastmcp-cli-generate.mdx diff --git a/docs/docs.json b/docs/docs.json index 916fa2640..f20343ca1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -304,6 +304,7 @@ "python-sdk/fastmcp-cli-cli", "python-sdk/fastmcp-cli-client", "python-sdk/fastmcp-cli-discovery", + "python-sdk/fastmcp-cli-generate", { "group": "install", "pages": [ diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index df21e8105..26e8f0621 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-generate.mdx b/docs/python-sdk/fastmcp-cli-generate.mdx new file mode 100644 index 000000000..28bd3ea7f --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-generate.mdx @@ -0,0 +1,54 @@ +--- +title: generate +sidebarTitle: generate +--- + +# `fastmcp.cli.generate` + + +Generate a standalone CLI script from an MCP server's capabilities. + +## Functions + +### `serialize_transport` + +```python +serialize_transport(resolved: str | dict[str, Any] | ClientTransport) -> tuple[str, set[str]] +``` + + +Serialize a resolved transport to a Python expression string. + +Returns ``(expression, extra_imports)`` where *extra_imports* is a set of +import lines needed by the expression. + + +### `generate_cli_script` + +```python +generate_cli_script(server_name: str, server_spec: str, transport_code: str, extra_imports: set[str], tools: list[mcp.types.Tool]) -> str +``` + + +Generate the full CLI script source code. + + +### `generate_cli_command` + +```python +generate_cli_command(server_spec: Annotated[str, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, discovered name, or .js file')], output: Annotated[str, cyclopts.Parameter(help='Output file path (default: cli.py)')] = 'cli.py') -> 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. + +**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 + diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx index 4141cd3f1..be76e75ba 100644 --- a/docs/python-sdk/fastmcp-cli-run.mdx +++ b/docs/python-sdk/fastmcp-cli-run.mdx @@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints. ## Functions -### `is_url` +### `is_url` ```python is_url(path: str) -> bool @@ -20,7 +20,7 @@ is_url(path: str) -> bool Check if a string is a URL. -### `create_client_server` +### `create_client_server` ```python create_client_server(url: str) -> Any @@ -36,7 +36,7 @@ Create a FastMCP server from a client URL. - A FastMCP server instance -### `create_mcp_config_server` +### `create_mcp_config_server` ```python create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] @@ -46,7 +46,7 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] Create a FastMCP server from a MCPConfig. -### `load_mcp_server_config` +### `load_mcp_server_config` ```python load_mcp_server_config(config_path: Path) -> MCPServerConfig @@ -62,7 +62,7 @@ Load a FastMCP configuration from a fastmcp.json file. - MCPServerConfig object -### `run_command` +### `run_command` ```python run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, skip_source: bool = False, stateless: bool = False) -> None @@ -85,7 +85,7 @@ Run a MCP server or connect to a remote one. - `stateless`: Whether to run in stateless mode (no session) -### `run_v1_server_async` +### `run_v1_server_async` ```python run_v1_server_async(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None @@ -101,7 +101,7 @@ Run a FastMCP 1.x server using async methods. - `transport`: Transport protocol to use -### `run_with_reload` +### `run_with_reload` ```python run_with_reload(cmd: list[str], reload_dirs: list[Path] | None = None, is_stdio: bool = False) -> None diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index 476e54f1f..ed4b9ce41 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx @@ -18,7 +18,7 @@ Tool implementation for OpenAPI endpoints. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Execute the HTTP request using RequestDirector. -### `OpenAPIResource` +### `OpenAPIResource` Resource implementation for OpenAPI endpoints. @@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -44,7 +44,7 @@ read(self) -> ResourceResult Fetch the resource data by making an HTTP request. -### `OpenAPIResourceTemplate` +### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. @@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx index 87768733d..6be6e07e4 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx @@ -10,7 +10,7 @@ OpenAPIProvider for creating MCP components from OpenAPI specifications. ## Classes -### `OpenAPIProvider` +### `OpenAPIProvider` Provider that creates MCP components from an OpenAPI specification. @@ -21,7 +21,16 @@ spec. Each component makes HTTP calls to the described API endpoints. **Methods:** -#### `get_tasks` +#### `lifespan` + +```python +lifespan(self) -> AsyncIterator[None] +``` + +Manage the lifecycle of the auto-created httpx client. + + +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index fc85ea732..004fc53c8 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -354,19 +354,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -395,19 +395,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -437,7 +437,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -455,7 +455,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -471,19 +471,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -539,7 +539,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -554,7 +554,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -569,7 +569,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction] @@ -628,7 +628,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -643,19 +643,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -732,7 +732,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -779,7 +779,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -820,34 +820,35 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python -from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, timeout: float | None = None, **settings: Any) -> Self +from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> Self ``` Create a FastMCP server from an OpenAPI specification. **Args:** - `openapi_spec`: OpenAPI schema as a dictionary -- `client`: httpx AsyncClient for making HTTP requests +- `client`: Optional httpx AsyncClient for making HTTP requests. +If not provided, a default client is created using the first +server URL from the OpenAPI spec with a 30-second timeout. - `name`: Name for the MCP server - `route_maps`: Optional list of RouteMap objects defining route mappings - `route_map_fn`: Optional callable for advanced route type mapping - `mcp_component_fn`: Optional callable for component customization - `mcp_names`: Optional dictionary mapping operationId to component names - `tags`: Optional set of tags to add to all components -- `timeout`: Optional timeout (in seconds) for all requests - `**settings`: Additional settings passed to FastMCP **Returns:** - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python -from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, timeout: float | None = None, **settings: Any) -> Self +from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self ``` Create a FastMCP server from a FastAPI application. @@ -859,16 +860,16 @@ Create a FastMCP server from a FastAPI application. - `route_map_fn`: Optional callable for advanced route type mapping - `mcp_component_fn`: Optional callable for component customization - `mcp_names`: Optional dictionary mapping operationId to component names -- `httpx_client_kwargs`: Optional kwargs passed to httpx.AsyncClient +- `httpx_client_kwargs`: Optional kwargs passed to httpx.AsyncClient. +Use this to configure timeout and other client settings. - `tags`: Optional set of tags to add to all components -- `timeout`: Optional timeout (in seconds) for all requests - `**settings`: Additional settings passed to FastMCP **Returns:** - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -886,7 +887,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str From db650ca7cbdfe1e0121da9e234704926e3c54e94 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:40:25 -0500 Subject: [PATCH 19/63] Update Anthropic and OpenAI clients to use Omit instead of NotGiven (#3088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix ty 0.0.15 type errors: use Omit/omit instead of NotGiven/NOT_GIVEN 🤖 Generated with Claude Code https://claude.ai/code/session_01Fs5vHiWaUebe826pGq4eCN * Use kwargs dict to avoid NotGiven/Omit sentinel type issues across SDK versions 🤖 Generated with Claude Code https://claude.ai/code/session_01Fs5vHiWaUebe826pGq4eCN * Bump ty minimum to 0.0.15 🤖 Generated with Claude Code https://claude.ai/code/session_01Fs5vHiWaUebe826pGq4eCN --------- Co-authored-by: Claude --- pyproject.toml | 2 +- .../client/sampling/handlers/anthropic.py | 44 ++++++++++--------- .../client/sampling/handlers/openai.py | 34 ++++++++------ .../openapi/test_openapi_performance.py | 5 ++- uv.lock | 38 ++++++++-------- 5 files changed, 66 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da38224a8..d1a7c5814 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff>=0.12.8", - "ty>=0.0.7", + "ty>=0.0.15", "prek>=0.2.12", "loq>=0.1.0a3", "opentelemetry-exporter-otlp-proto-grpc>=1.39.0", diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py index b7ab17b6e..4bef921b3 100644 --- a/src/fastmcp/client/sampling/handlers/anthropic.py +++ b/src/fastmcp/client/sampling/handlers/anthropic.py @@ -19,8 +19,7 @@ from mcp.types import ( ) try: - from anthropic import AsyncAnthropic, NotGiven - from anthropic._types import NOT_GIVEN + from anthropic import AsyncAnthropic from anthropic.types import ( Message, MessageParam, @@ -81,37 +80,40 @@ class AnthropicSamplingHandler: model: ModelParam = self._select_model_from_preferences(params.modelPreferences) # Convert MCP tools to Anthropic format - anthropic_tools: list[ToolParam] | NotGiven = NOT_GIVEN + anthropic_tools: list[ToolParam] | None = None if params.tools: anthropic_tools = self._convert_tools_to_anthropic(params.tools) # Convert tool_choice to Anthropic format # Returns None if mode is "none", signaling tools should be omitted - anthropic_tool_choice: ToolChoiceParam | NotGiven = NOT_GIVEN + anthropic_tool_choice: ToolChoiceParam | None = None if params.toolChoice: converted = self._convert_tool_choice_to_anthropic(params.toolChoice) if converted is None: # tool_choice="none" means don't use tools - anthropic_tools = NOT_GIVEN + anthropic_tools = None else: anthropic_tool_choice = converted - response = await self.client.messages.create( - model=model, - messages=anthropic_messages, - system=( - params.systemPrompt if params.systemPrompt is not None else NOT_GIVEN - ), - temperature=( - params.temperature if params.temperature is not None else NOT_GIVEN - ), - max_tokens=params.maxTokens, - stop_sequences=( - params.stopSequences if params.stopSequences is not None else NOT_GIVEN - ), - tools=anthropic_tools, - tool_choice=anthropic_tool_choice, - ) + # Build kwargs to avoid sentinel type compatibility issues across + # anthropic SDK versions (NotGiven vs Omit) + kwargs: dict[str, Any] = { + "model": model, + "messages": anthropic_messages, + "max_tokens": params.maxTokens, + } + if params.systemPrompt is not None: + kwargs["system"] = params.systemPrompt + if params.temperature is not None: + kwargs["temperature"] = params.temperature + if params.stopSequences is not None: + kwargs["stop_sequences"] = params.stopSequences + if anthropic_tools is not None: + kwargs["tools"] = anthropic_tools + if anthropic_tool_choice is not None: + kwargs["tool_choice"] = anthropic_tool_choice + + response = await self.client.messages.create(**kwargs) # Return appropriate result type based on whether tools were provided if params.tools: diff --git a/src/fastmcp/client/sampling/handlers/openai.py b/src/fastmcp/client/sampling/handlers/openai.py index f844af5d0..3ddcadaca 100644 --- a/src/fastmcp/client/sampling/handlers/openai.py +++ b/src/fastmcp/client/sampling/handlers/openai.py @@ -21,7 +21,7 @@ from mcp.types import ( ) try: - from openai import NOT_GIVEN, AsyncOpenAI, NotGiven + from openai import AsyncOpenAI from openai.types.chat import ( ChatCompletion, ChatCompletionAssistantMessageParam, @@ -70,26 +70,32 @@ class OpenAISamplingHandler: model: ChatModel = self._select_model_from_preferences(params.modelPreferences) # Convert MCP tools to OpenAI format - openai_tools: list[ChatCompletionToolParam] | NotGiven = NOT_GIVEN + openai_tools: list[ChatCompletionToolParam] | None = None if params.tools: openai_tools = self._convert_tools_to_openai(params.tools) # Convert tool_choice to OpenAI format - openai_tool_choice: ChatCompletionToolChoiceOptionParam | NotGiven = NOT_GIVEN + openai_tool_choice: ChatCompletionToolChoiceOptionParam | None = None if params.toolChoice: openai_tool_choice = self._convert_tool_choice_to_openai(params.toolChoice) - response = await self.client.chat.completions.create( - model=model, - messages=openai_messages, - temperature=( - params.temperature if params.temperature is not None else NOT_GIVEN - ), - max_tokens=params.maxTokens, - stop=params.stopSequences if params.stopSequences else NOT_GIVEN, - tools=openai_tools, - tool_choice=openai_tool_choice, - ) + # Build kwargs to avoid sentinel type compatibility issues across + # openai SDK versions (NotGiven vs Omit) + kwargs: dict[str, Any] = { + "model": model, + "messages": openai_messages, + "max_tokens": params.maxTokens, + } + if params.temperature is not None: + kwargs["temperature"] = params.temperature + if params.stopSequences: + kwargs["stop"] = params.stopSequences + if openai_tools is not None: + kwargs["tools"] = openai_tools + if openai_tool_choice is not None: + kwargs["tool_choice"] = openai_tool_choice + + response = await self.client.chat.completions.create(**kwargs) # Return appropriate result type based on whether tools were provided if params.tools: diff --git a/tests/server/providers/openapi/test_openapi_performance.py b/tests/server/providers/openapi/test_openapi_performance.py index d8129aa05..c09a3ff64 100644 --- a/tests/server/providers/openapi/test_openapi_performance.py +++ b/tests/server/providers/openapi/test_openapi_performance.py @@ -5,6 +5,7 @@ and don't regress to the slow performance we had before optimization. """ import time +from typing import Any import httpx import pytest @@ -72,7 +73,7 @@ class TestOpenAPIPerformance: for performance testing in CI environments. """ # Create a medium-sized synthetic schema - schema = { + schema: dict[str, Any] = { "openapi": "3.0.0", "info": {"title": "Test API", "version": "1.0.0"}, "paths": {}, @@ -81,7 +82,7 @@ class TestOpenAPIPerformance: # Generate multiple paths to create a reasonably sized schema for i in range(100): path = f"/test/{i}" - schema["paths"][path] = { # type: ignore[index] + schema["paths"][path] = { "get": { "operationId": f"test_{i}", "parameters": [ diff --git a/uv.lock b/uv.lock index 32c27dc99..c158b7fb6 100644 --- a/uv.lock +++ b/uv.lock @@ -798,7 +798,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.8" }, - { name = "ty", specifier = ">=0.0.7" }, + { name = "ty", specifier = ">=0.0.15" }, ] [[package]] @@ -2679,26 +2679,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.14" +version = "0.0.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/25/257602d316b9333089b688a7a11b33ebc660b74e8dacf400dc3dfdea1594/ty-0.0.15.tar.gz", hash = "sha256:4f9a5b8df208c62dba56e91b93bed8b5bb714839691b8cff16d12c983bfa1174", size = 5101936, upload-time = "2026-02-05T01:06:34.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" }, - { url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" }, - { url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" }, - { url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" }, - { url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" }, - { url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" }, - { url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" }, - { url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" }, - { url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c5/35626e732b79bf0e6213de9f79aff59b5f247c0a1e3ce0d93e675ab9b728/ty-0.0.15-py3-none-linux_armv6l.whl", hash = "sha256:68e092458516c61512dac541cde0a5e4e5842df00b4e81881ead8f745ddec794", size = 10138374, upload-time = "2026-02-05T01:07:03.804Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8a/48fd81664604848f79d03879b3ca3633762d457a069b07e09fb1b87edd6e/ty-0.0.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79f2e75289eae3cece94c51118b730211af4ba5762906f52a878041b67e54959", size = 9947858, upload-time = "2026-02-05T01:06:47.453Z" }, + { url = "https://files.pythonhosted.org/packages/b6/85/c1ac8e97bcd930946f4c94db85b675561d590b4e72703bf3733419fc3973/ty-0.0.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112a7b26e63e48cc72c8c5b03227d1db280cfa57a45f2df0e264c3a016aa8c3c", size = 9443220, upload-time = "2026-02-05T01:06:44.98Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f62a2644972975a657d9dc867bf901235cde51e8d24c20311067e7afd44a56", size = 9949976, upload-time = "2026-02-05T01:07:01.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ab/3a0daad66798c91a33867a3ececf17d314ac65d4ae2bbbd28cbfde94da63/ty-0.0.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e48b42be2d257317c85b78559233273b655dd636fc61e7e1d69abd90fd3cba4", size = 9965918, upload-time = "2026-02-05T01:06:54.283Z" }, + { url = "https://files.pythonhosted.org/packages/39/4e/e62b01338f653059a7c0cd09d1a326e9a9eedc351a0f0de9db0601658c3d/ty-0.0.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27dd5b52a421e6871c5bfe9841160331b60866ed2040250cb161886478ab3e4f", size = 10424943, upload-time = "2026-02-05T01:07:08.777Z" }, + { url = "https://files.pythonhosted.org/packages/65/b5/7aa06655ce69c0d4f3e845d2d85e79c12994b6d84c71699cfb437e0bc8cf/ty-0.0.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76b85c9ec2219e11c358a7db8e21b7e5c6674a1fb9b6f633836949de98d12286", size = 10964692, upload-time = "2026-02-05T01:06:37.103Z" }, + { url = "https://files.pythonhosted.org/packages/13/04/36fdfe1f3c908b471e246e37ce3d011175584c26d3853e6c5d9a0364564c/ty-0.0.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e8204c61d8ede4f21f2975dce74efdb80fafb2fae1915c666cceb33ea3c90b", size = 10692225, upload-time = "2026-02-05T01:06:49.714Z" }, + { url = "https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af87c3be7c944bb4d6609d6c63e4594944b0028c7bd490a525a82b88fe010d6d", size = 10516776, upload-time = "2026-02-05T01:06:52.047Z" }, + { url = "https://files.pythonhosted.org/packages/56/75/66852d7e004f859839c17ffe1d16513c1e7cc04bcc810edb80ca022a9124/ty-0.0.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50dccf7398505e5966847d366c9e4c650b8c225411c2a68c32040a63b9521eea", size = 9928828, upload-time = "2026-02-05T01:06:56.647Z" }, + { url = "https://files.pythonhosted.org/packages/65/72/96bc16c7b337a3ef358fd227b3c8ef0c77405f3bfbbfb59ee5915f0d9d71/ty-0.0.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bd797b8f231a4f4715110259ad1ad5340a87b802307f3e06d92bfb37b858a8f3", size = 9978960, upload-time = "2026-02-05T01:06:29.567Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/d2e316a35b626de2227f832cd36d21205e4f5d96fd036a8af84c72ecec1b/ty-0.0.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9deb7f20e18b25440a9aa4884f934ba5628ef456dbde91819d5af1a73da48af3", size = 10135903, upload-time = "2026-02-05T01:06:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/02/d3/b617a79c9dad10c888d7c15cd78859e0160b8772273637b9c4241a049491/ty-0.0.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7b31b3de031255b90a5f4d9cb3d050feae246067c87130e5a6861a8061c71754", size = 10615879, upload-time = "2026-02-05T01:07:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b0/2652a73c71c77296a6343217063f05745da60c67b7e8a8e25f2064167fce/ty-0.0.15-py3-none-win32.whl", hash = "sha256:9362c528ceb62c89d65c216336d28d500bc9f4c10418413f63ebc16886e16cc1", size = 9578058, upload-time = "2026-02-05T01:06:42.928Z" }, + { url = "https://files.pythonhosted.org/packages/84/6e/08a4aedebd2a6ce2784b5bc3760e43d1861f1a184734a78215c2d397c1df/ty-0.0.15-py3-none-win_amd64.whl", hash = "sha256:4db040695ae67c5524f59cb8179a8fa277112e69042d7dfdac862caa7e3b0d9c", size = 10457112, upload-time = "2026-02-05T01:06:39.885Z" }, + { url = "https://files.pythonhosted.org/packages/b3/be/1991f2bc12847ae2d4f1e3ac5dcff8bb7bc1261390645c0755bb55616355/ty-0.0.15-py3-none-win_arm64.whl", hash = "sha256:e5a98d4119e77d6136461e16ae505f8f8069002874ab073de03fbcb1a5e8bf25", size = 9937490, upload-time = "2026-02-05T01:06:32.388Z" }, ] [[package]] From 880d835cccd5e8b381aab1678685cb33ee64c0f8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:44:52 -0500 Subject: [PATCH 20/63] Add CIMD (Client ID Metadata Document) support for OAuth (#2871) --- docs/clients/auth/cimd.mdx | 137 +++ docs/clients/auth/oauth.mdx | 39 +- docs/development/v3-notes/v3-features.mdx | 47 + docs/docs.json | 1 + docs/patterns/cli.mdx | 82 ++ docs/servers/auth/oauth-proxy.mdx | 68 ++ docs/servers/auth/oidc-proxy.mdx | 16 + examples/auth/github_oauth/client.py | 12 +- loq.toml | 2 +- src/fastmcp/cli/auth.py | 13 + src/fastmcp/cli/cimd.py | 218 ++++ src/fastmcp/cli/cli.py | 4 + src/fastmcp/client/auth/oauth.py | 67 +- src/fastmcp/client/transports/http.py | 12 +- src/fastmcp/client/transports/sse.py | 12 +- src/fastmcp/server/auth/auth.py | 100 +- src/fastmcp/server/auth/cimd.py | 651 ++++++++++++ .../server/auth/oauth_proxy/consent.py | 12 +- src/fastmcp/server/auth/oauth_proxy/models.py | 79 +- src/fastmcp/server/auth/oauth_proxy/proxy.py | 117 ++- src/fastmcp/server/auth/oauth_proxy/ui.py | 32 + src/fastmcp/server/auth/oidc_proxy.py | 6 + src/fastmcp/server/auth/providers/jwt.py | 51 +- .../server/auth/redirect_validation.py | 159 ++- src/fastmcp/server/auth/ssrf.py | 307 ++++++ tests/cli/test_cimd_cli.py | 208 ++++ tests/client/auth/test_oauth_cimd.py | 164 +++ .../auth/oauth_proxy/test_oauth_proxy.py | 28 + tests/server/auth/test_cimd.py | 971 ++++++++++++++++++ tests/server/auth/test_jwt_provider.py | 61 +- .../test_oauth_proxy_redirect_validation.py | 127 ++- tests/server/auth/test_oauth_proxy_storage.py | 8 +- tests/server/auth/test_oidc_proxy.py | 16 +- tests/server/auth/test_redirect_validation.py | 59 ++ tests/server/auth/test_ssrf_protection.py | 447 ++++++++ tests/utilities/openapi/test_models.py | 6 +- 36 files changed, 4221 insertions(+), 118 deletions(-) create mode 100644 docs/clients/auth/cimd.mdx create mode 100644 src/fastmcp/cli/auth.py create mode 100644 src/fastmcp/cli/cimd.py create mode 100644 src/fastmcp/server/auth/cimd.py create mode 100644 src/fastmcp/server/auth/ssrf.py create mode 100644 tests/cli/test_cimd_cli.py create mode 100644 tests/client/auth/test_oauth_cimd.py create mode 100644 tests/server/auth/test_cimd.py create mode 100644 tests/server/auth/test_ssrf_protection.py diff --git a/docs/clients/auth/cimd.mdx b/docs/clients/auth/cimd.mdx new file mode 100644 index 000000000..6980c66f2 --- /dev/null +++ b/docs/clients/auth/cimd.mdx @@ -0,0 +1,137 @@ +--- +title: CIMD Authentication +sidebarTitle: CIMD +description: Use Client ID Metadata Documents for verifiable, domain-based client identity. +icon: id-badge +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + + +CIMD authentication is only relevant for HTTP-based transports and requires a server that advertises CIMD support. + + +With standard OAuth, your client registers dynamically with every server it connects to, receiving a fresh `client_id` each time. This works, but the server has no way to verify *who* your client actually is — any client can claim any name during registration. + +CIMD (Client ID Metadata Documents) flips this around. You host a small JSON document at an HTTPS URL you control, and that URL becomes your `client_id`. When your client connects to a server, the server fetches your metadata document and can verify your identity through your domain ownership. Users see a verified domain badge in the consent screen instead of an unverified client name. + +## Client Usage + +Pass your CIMD document URL to the `client_metadata_url` parameter of `OAuth`: + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_metadata_url="https://myapp.example.com/oauth/client.json", + ), +) as client: + await client.ping() +``` + +When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow. + + +You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. + + +## Creating a CIMD Document + +A CIMD document is a JSON file that describes your client. The most important field is `client_id`, which must exactly match the URL where you host the document. + +Use the FastMCP CLI to generate one: + +```bash +fastmcp auth cimd create \ + --name "My Application" \ + --redirect-uri "http://localhost:*/callback" \ + --client-id "https://myapp.example.com/oauth/client.json" +``` + +This produces: + +```json +{ + "client_id": "https://myapp.example.com/oauth/client.json", + "client_name": "My Application", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code"], + "response_types": ["code"] +} +``` + +If you omit `--client-id`, the CLI generates a placeholder value and reminds you to update it before hosting. + +### CLI Options + +The `create` command accepts these flags: + +| Flag | Description | +|------|-------------| +| `--name` | Human-readable client name (required) | +| `--redirect-uri`, `-r` | Allowed redirect URIs — can be specified multiple times (required) | +| `--client-id` | The URL where you'll host this document (sets `client_id` directly) | +| `--output`, `-o` | Write to a file instead of stdout | +| `--scope` | Space-separated list of scopes the client may request | +| `--client-uri` | URL of the client's home page | +| `--logo-uri` | URL of the client's logo image | +| `--no-pretty` | Output compact JSON | + +### Redirect URIs + +The `redirect_uris` field supports wildcard port matching for localhost. The pattern `http://localhost:*/callback` matches any port, which is useful for development clients that bind to random available ports (which is what FastMCP's `OAuth` helper does by default). + +## Hosting Requirements + +CIMD documents must be hosted at a publicly accessible HTTPS URL with a non-root path: + +- **HTTPS required** — HTTP URLs are rejected for security +- **Non-root path** — The URL must have a path component (e.g., `/oauth/client.json`, not just `/`) +- **Public accessibility** — The server must be able to fetch the document over the internet +- **Matching `client_id`** — The `client_id` field in the document must exactly match the hosting URL + +Common hosting options include static file hosting services like GitHub Pages, Cloudflare Pages, Vercel, or S3 — anywhere you can serve a JSON file over HTTPS. + +## Validating Your Document + +Before deploying, verify your hosted document passes validation: + +```bash +fastmcp auth cimd validate https://myapp.example.com/oauth/client.json +``` + +The validator fetches the document and checks that: +- The URL is valid (HTTPS, non-root path) +- The document is well-formed JSON conforming to the CIMD schema +- The `client_id` in the document matches the URL it was fetched from + +## How It Works + +When your client connects to a CIMD-enabled server, the flow works like this: + + + +Your client sends its `client_metadata_url` as the `client_id` in the OAuth authorization request. + + +The server sees that the `client_id` is an HTTPS URL with a path — the signature of a CIMD client — and skips Dynamic Client Registration. + + +The server fetches your JSON document from the URL, validates that `client_id` matches the URL, and extracts your client metadata (name, redirect URIs, scopes). + + +The standard OAuth flow continues: browser opens for user consent, authorization code exchange, token issuance. The consent screen shows your verified domain. + + + +The server caches your CIMD document according to HTTP cache headers, so subsequent requests don't require re-fetching. + +## Server Configuration + +CIMD is a server-side feature that your MCP server must support. FastMCP's OAuth proxy providers (GitHub, Google, Auth0, etc.) support CIMD by default. See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for server-side configuration, including private key JWT authentication and security details. diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 6a60fb0f8..25804adc3 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -41,20 +41,25 @@ To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `au from fastmcp import Client from fastmcp.client.auth import OAuth -oauth = OAuth(mcp_url="https://your-server.fastmcp.app/mcp") +oauth = OAuth(scopes=["user"]) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: await client.ping() ``` + +You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. + + #### `OAuth` Parameters -- **`mcp_url`** (`str`): The full URL of the target MCP server endpoint. Used to discover OAuth server metadata - **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings - **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"` +- **`client_metadata_url`** (`str`, optional): URL-based client identity (CIMD). See [CIMD Authentication](/clients/auth/cimd) for details - **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options - **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration - **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port +- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients ## OAuth Flow @@ -68,8 +73,8 @@ The client first checks the configured `token_storage` backend for existing, val If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`. - -If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. + +If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. Alternatively, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity instead of registering. A temporary local HTTP server is started on an available port (or the port specified via `callback_port`). This server's address (e.g., `http://127.0.0.1:/callback`) acts as the `redirect_uri` for the OAuth flow. @@ -115,10 +120,7 @@ encrypted_storage = FernetEncryptionWrapper( fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"]) ) -oauth = OAuth( - mcp_url="https://your-server.fastmcp.app/mcp", - token_storage=encrypted_storage -) +oauth = OAuth(token_storage=encrypted_storage) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: await client.ping() @@ -129,3 +131,24 @@ You can use any `AsyncKeyValue`-compatible backend from the [key-value library]( When selecting a storage backend, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have constraints that affect production suitability. + +## CIMD Authentication + + + +Client ID Metadata Documents (CIMD) provide an alternative to Dynamic Client Registration. Instead of registering with each server, your client hosts a static JSON document at an HTTPS URL. That URL becomes your client's identity, and servers can verify who you are through your domain ownership. + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_metadata_url="https://myapp.example.com/oauth/client.json", + ), +) as client: + await client.ping() +``` + +See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents. diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 2fa032e6e..05399fce6 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -73,6 +73,53 @@ fastmcp install stdio server.py The command automatically detects the project directory and generates the appropriate `uv run` invocation, making it easy to integrate FastMCP servers with MCP clients. +### CIMD (Client ID Metadata Documents) + +CIMD provides an alternative to Dynamic Client Registration for OAuth-authenticated MCP servers. Instead of registering with each server dynamically, clients host a static JSON document at an HTTPS URL. That URL becomes the client's `client_id`, and servers verify identity through domain ownership. + +**Client usage:** + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_metadata_url="https://myapp.example.com/oauth/client.json", + ), +) as client: + await client.ping() +``` + +The `OAuth` helper now supports deferred binding — `mcp_url` is optional when using `OAuth` with `Client(auth=...)`, since the transport provides the server URL automatically. + +**CLI tools for document management:** + +```bash +# Generate a CIMD document +fastmcp auth cimd create --name "My App" \ + --redirect-uri "http://localhost:*/callback" \ + --client-id "https://myapp.example.com/oauth/client.json" \ + --output client.json + +# Validate a hosted document +fastmcp auth cimd validate https://myapp.example.com/oauth/client.json +``` + +**Server-side support:** + +CIMD is enabled by default on `OAuthProxy` and its provider subclasses (GitHub, Google, etc.). The server-side implementation includes SSRF-hardened document fetching with DNS pinning, dual redirect URI validation (both CIMD document patterns and proxy patterns must match), HTTP cache-aware revalidation, and `private_key_jwt` assertion validation for clients that need stronger authentication than public client auth. + +Key details: +- CIMD URLs must be HTTPS with a non-root path +- `token_endpoint_auth_method` limited to `none` or `private_key_jwt` (no shared secrets) +- `redirect_uris` in CIMD documents support wildcard port patterns (`http://localhost:*/callback`) +- Servers fetch and cache documents with standard HTTP caching (ETag, Last-Modified, Cache-Control) +- CIMD is a protocol-level feature — any auth provider implementing the spec can support it + +Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) + ### MCP Apps (SDK Compatibility) Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. diff --git a/docs/docs.json b/docs/docs.json index f20343ca1..b9c1bc4dd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -198,6 +198,7 @@ "icon": "key", "pages": [ "clients/auth/oauth", + "clients/auth/cimd", "clients/auth/bearer" ] } diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 72badb870..8ccf9d01f 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -25,6 +25,7 @@ fastmcp --help | `install` | Install a server in MCP client applications | **Supports:** Local files and fastmcp.json configs. **Deps:** Creates an isolated environment; dependencies must be explicitly specified with `--with` and/or `--with-editable`. With fastmcp.json: Uses configured dependencies | | `inspect` | Generate a JSON report about a FastMCP server | **Supports:** Local files and fastmcp.json configs. **Deps:** Uses your current environment; you are responsible for ensuring all dependencies are available | | `project prepare` | Create a persistent uv project from fastmcp.json environment config | **Supports:** fastmcp.json configs only. **Deps:** Creates a uv project directory with all dependencies pre-installed for reuse with `--project` flag | +| `auth cimd` | Create and validate CIMD documents for OAuth authentication | N/A | | `version` | Display version information | N/A | ## `fastmcp list` @@ -750,6 +751,87 @@ The prepare command creates a uv project with: This is useful when you want to separate environment setup from server execution, such as in deployment scenarios where dependencies are installed once and the server is run multiple times. +## `fastmcp auth` + + + +Authentication-related utilities and configuration commands. + +### `fastmcp auth cimd create` + +Generate a CIMD (Client ID Metadata Document) for hosting. This creates a JSON document that you can host at an HTTPS URL to use as your OAuth client identity. + +```bash +fastmcp auth cimd create --name "My App" --redirect-uri "http://localhost:*/callback" +``` + +#### Options + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| Name | `--name` | **Required.** Human-readable name of the client application | +| Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (can specify multiple) | +| Client URI | `--client-uri` | URL of the client's home page | +| Logo URI | `--logo-uri` | URL of the client's logo image | +| Scope | `--scope` | Space-separated list of scopes the client may request | +| Output | `--output`, `-o` | Output file path (default: stdout) | +| Pretty | `--pretty` | Pretty-print JSON output (default: true) | + +#### Example + +```bash +# Generate document to stdout +fastmcp auth cimd create \ + --name "My Production App" \ + --redirect-uri "http://localhost:*/callback" \ + --redirect-uri "https://myapp.example.com/callback" \ + --client-uri "https://myapp.example.com" \ + --scope "read write" + +# Save to file +fastmcp auth cimd create \ + --name "My App" \ + --redirect-uri "http://localhost:*/callback" \ + --output client.json +``` + +The generated document includes a placeholder `client_id` that you must update to match the URL where you'll host the document before deploying. + +### `fastmcp auth cimd validate` + +Validate a hosted CIMD document by fetching it from its URL and checking that it conforms to the CIMD specification. + +```bash +fastmcp auth cimd validate https://myapp.example.com/oauth/client.json +``` + +#### Options + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| Timeout | `--timeout`, `-t` | HTTP request timeout in seconds (default: 10) | + +The validator checks: + +- The URL is a valid CIMD URL (HTTPS with non-root path) +- The document is valid JSON and conforms to the CIMD schema +- The `client_id` field in the document matches the URL +- No shared-secret authentication methods are used + +On success, it displays the document details: + +``` +→ Fetching https://myapp.example.com/oauth/client.json... +✓ Valid CIMD document + +Document details: + client_id: https://myapp.example.com/oauth/client.json + client_name: My App + token_endpoint_auth_method: none + redirect_uris: + • http://localhost:*/callback +``` + ## `fastmcp version` Display version information about FastMCP and related components. diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 3c5b328a7..86a8865f3 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -524,6 +524,74 @@ auth = OAuthProxy( Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use. +## CIMD Support + + + +The OAuth proxy supports **Client ID Metadata Documents (CIMD)**, an alternative to Dynamic Client Registration where clients host a static JSON document at an HTTPS URL. Instead of registering dynamically, clients simply provide their CIMD URL as their `client_id`, and the server fetches and validates the metadata. + +CIMD clients appear in the consent screen with a verified domain badge, giving users confidence about which application is requesting access. This provides stronger identity verification than DCR, where any client can claim any name. + +### How CIMD Works + +When a client presents an HTTPS URL as its `client_id` (for example, `https://myapp.example.com/oauth/client.json`), the OAuth proxy recognizes it as a CIMD client and: + +1. Fetches the JSON document from that URL +2. Validates that the document's `client_id` field matches the URL +3. Extracts client metadata (name, redirect URIs, scopes, etc.) +4. Stores the client persistently alongside DCR clients +5. Shows the verified domain in the consent screen + +This flow happens transparently. MCP clients that support CIMD simply provide their metadata URL instead of registering, and the OAuth proxy handles the rest. + +### CIMD Configuration + +CIMD support is enabled by default for `OAuthProxy`. + + + + Whether to accept CIMD URLs as client identifiers. When enabled, clients can use HTTPS URLs pointing to metadata documents as their `client_id` instead of registering via DCR. + + + +### Private Key JWT Authentication + +CIMD clients can authenticate using `private_key_jwt` instead of the default `none` authentication method. This provides cryptographic proof of client identity by signing JWT assertions with a private key, while the server verifies using the client's public key from their CIMD document. + +To use `private_key_jwt`, the CIMD document must include either a `jwks_uri` (URL to fetch the public key set) or inline `jwks` (the key set directly in the document): + +```json +{ + "client_id": "https://myapp.example.com/oauth/client.json", + "client_name": "My Secure App", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "private_key_jwt", + "jwks_uri": "https://myapp.example.com/.well-known/jwks.json" +} +``` + +The OAuth proxy validates JWT assertions according to RFC 7523, checking the signature, issuer, audience, subject claims, and preventing replay attacks via JTI tracking. + +### Security Considerations + +CIMD provides several security advantages over DCR: + +- **Verified identity**: The domain in the `client_id` URL is verified by HTTPS, so users know which organization is requesting access +- **No registration required**: Clients don't need to store or manage dynamically-issued credentials +- **Redirect URI enforcement**: CIMD documents must declare `redirect_uris`, which are enforced by the proxy (wildcard patterns supported) +- **SSRF protection**: The OAuth proxy blocks fetches to localhost, private IPs, and reserved addresses +- **Replay prevention**: For `private_key_jwt` clients, JTI claims are tracked to prevent assertion replay +- **Cache-aware fetching**: CIMD documents are cached according to HTTP cache headers and revalidated when required + +To disable CIMD support entirely (for example, to require all clients to register via DCR): + +```python +auth = OAuthProxy( + ..., + enable_cimd=False, +) +``` + ## Security ### Key and Storage Management diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 6941c7f79..86661bc16 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -232,6 +232,22 @@ OAuth scopes are configured with `required_scopes` to automatically request the Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. +## CIMD Support + + + +The OIDC proxy inherits full CIMD (Client ID Metadata Document) support from `OAuthProxy`. Clients can use HTTPS URLs as their `client_id` instead of registering dynamically, and the proxy will fetch and validate their metadata document. + +See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for complete details on how CIMD works, including private key JWT authentication and security considerations. + +The CIMD-related parameters available on `OIDCProxy` are: + + + + Whether to accept CIMD URLs as client identifiers. + + + ## Production Configuration For production deployments, load sensitive credentials from environment variables: diff --git a/examples/auth/github_oauth/client.py b/examples/auth/github_oauth/client.py index 5f1f39bb2..a7ab5c47e 100644 --- a/examples/auth/github_oauth/client.py +++ b/examples/auth/github_oauth/client.py @@ -8,14 +8,20 @@ To run: import asyncio -from fastmcp.client import Client +from fastmcp.client import Client, OAuth -SERVER_URL = "http://127.0.0.1:8000/mcp" +SERVER_URL = "http://localhost:8000/mcp" async def main(): try: - async with Client(SERVER_URL, auth="oauth") as client: + async with Client( + SERVER_URL, + auth=OAuth( + # Replace with your own CIMD document URL + client_metadata_url="https://www.jlowin.dev/mcp-client.json", + ), + ) as client: assert await client.ping() print("✅ Successfully authenticated!") diff --git a/loq.toml b/loq.toml index 4c6d7e28b..bbfe81827 100644 --- a/loq.toml +++ b/loq.toml @@ -76,7 +76,7 @@ max_lines = 1584 [[rules]] path = "src/fastmcp/server/auth/oauth_proxy/proxy.py" -max_lines = 1600 +max_lines = 1740 [[rules]] path = "tests/server/test_dependencies.py" diff --git a/src/fastmcp/cli/auth.py b/src/fastmcp/cli/auth.py new file mode 100644 index 000000000..4ea401b04 --- /dev/null +++ b/src/fastmcp/cli/auth.py @@ -0,0 +1,13 @@ +"""Authentication-related CLI commands.""" + +import cyclopts + +from fastmcp.cli.cimd import cimd_app + +auth_app = cyclopts.App( + name="auth", + help="Authentication-related utilities and configuration.", +) + +# Nest CIMD commands under auth +auth_app.command(cimd_app) diff --git a/src/fastmcp/cli/cimd.py b/src/fastmcp/cli/cimd.py new file mode 100644 index 000000000..d2def490c --- /dev/null +++ b/src/fastmcp/cli/cimd.py @@ -0,0 +1,218 @@ +"""CIMD (Client ID Metadata Document) CLI commands.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Annotated + +import cyclopts +from rich.console import Console + +from fastmcp.server.auth.cimd import ( + CIMDFetcher, + CIMDFetchError, + CIMDValidationError, +) +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli.cimd") +console = Console() + + +cimd_app = cyclopts.App( + name="cimd", + help="CIMD (Client ID Metadata Document) utilities for OAuth authentication.", +) + + +@cimd_app.command(name="create") +def create_command( + *, + name: Annotated[ + str, + cyclopts.Parameter(help="Human-readable name of the client application"), + ], + redirect_uri: Annotated[ + list[str], + cyclopts.Parameter( + name=["--redirect-uri", "-r"], + help="Allowed redirect URIs (can specify multiple)", + ), + ], + client_id: Annotated[ + str | None, + cyclopts.Parameter( + name="--client-id", + help="The URL where this document will be hosted (sets client_id directly)", + ), + ] = None, + client_uri: Annotated[ + str | None, + cyclopts.Parameter( + name="--client-uri", + help="URL of the client's home page", + ), + ] = None, + logo_uri: Annotated[ + str | None, + cyclopts.Parameter( + name="--logo-uri", + help="URL of the client's logo image", + ), + ] = None, + scope: Annotated[ + str | None, + cyclopts.Parameter( + name="--scope", + help="Space-separated list of scopes the client may request", + ), + ] = None, + output: Annotated[ + str | None, + cyclopts.Parameter( + name=["--output", "-o"], + help="Output file path (default: stdout)", + ), + ] = None, + pretty: Annotated[ + bool, + cyclopts.Parameter( + help="Pretty-print JSON output", + ), + ] = True, +) -> None: + """Generate a CIMD document for hosting. + + Create a Client ID Metadata Document that you can host at an HTTPS URL. + The URL where you host this document becomes your client_id. + + Example: + fastmcp cimd create --name "My App" -r "http://localhost:*/callback" + + After creating the document, host it at an HTTPS URL with a non-root path, + for example: https://myapp.example.com/oauth/client.json + """ + # Build the document + doc = { + "client_id": client_id or "https://YOUR-DOMAIN.com/path/to/client.json", + "client_name": name, + "redirect_uris": redirect_uri, + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code"], + "response_types": ["code"], + } + + # Add optional fields + if client_uri: + doc["client_uri"] = client_uri + if logo_uri: + doc["logo_uri"] = logo_uri + if scope: + doc["scope"] = scope + + # Format output + json_output = json.dumps(doc, indent=2) if pretty else json.dumps(doc) + + # Write output + if output: + output_path = Path(output).expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + f.write(json_output) + f.write("\n") + console.print(f"[green]✓[/green] CIMD document written to {output}") + if not client_id: + console.print( + "\n[yellow]Important:[/yellow] client_id is a placeholder. Update it to the URL where you will host this document, or re-run with --client-id." + ) + else: + print(json_output) + if not client_id: + # Print instructions to stderr so they don't interfere with piping + stderr_console = Console(stderr=True) + stderr_console.print( + "\n[yellow]Important:[/yellow] client_id is a placeholder." + " Update it to the URL where you will host this document," + " or re-run with --client-id." + ) + + +@cimd_app.command(name="validate") +def validate_command( + url: Annotated[ + str, + cyclopts.Parameter(help="URL of the CIMD document to validate"), + ], + *, + timeout: Annotated[ + float, + cyclopts.Parameter( + name=["--timeout", "-t"], + help="HTTP request timeout in seconds", + ), + ] = 10.0, +) -> None: + """Validate a hosted CIMD document. + + Fetches the document from the given URL and validates: + - URL is valid CIMD URL (HTTPS, non-root path) + - Document is valid JSON + - Document conforms to CIMD schema + - client_id in document matches the URL + + Example: + fastmcp cimd validate https://myapp.example.com/oauth/client.json + """ + + async def _validate() -> bool: + fetcher = CIMDFetcher(timeout=timeout) + + # Check URL format first + if not fetcher.is_cimd_client_id(url): + console.print(f"[red]✗[/red] Invalid CIMD URL: {url}") + console.print() + console.print("CIMD URLs must:") + console.print(" • Use HTTPS (not HTTP)") + console.print(" • Have a non-root path (e.g., /client.json, not just /)") + return False + + console.print(f"[blue]→[/blue] Fetching {url}...") + + try: + doc = await fetcher.fetch(url) + except CIMDFetchError as e: + console.print(f"[red]✗[/red] Failed to fetch document: {e}") + return False + except CIMDValidationError as e: + console.print(f"[red]✗[/red] Validation error: {e}") + return False + + # Success - show document details + console.print("[green]✓[/green] Valid CIMD document") + console.print() + console.print("[bold]Document details:[/bold]") + console.print(f" client_id: {doc.client_id}") + console.print(f" client_name: {doc.client_name or '(not set)'}") + console.print(f" token_endpoint_auth_method: {doc.token_endpoint_auth_method}") + + if doc.redirect_uris: + console.print(" redirect_uris:") + for uri in doc.redirect_uris: + console.print(f" • {uri}") + else: + console.print(" redirect_uris: (none)") + + if doc.scope: + console.print(f" scope: {doc.scope}") + + if doc.client_uri: + console.print(f" client_uri: {doc.client_uri}") + + return True + + success = asyncio.run(_validate()) + if not success: + sys.exit(1) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 147d9dc2d..5b9c24e4a 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -19,6 +19,7 @@ from rich.table import Table import fastmcp from fastmcp.cli import run as run_module +from fastmcp.cli.auth import auth_app from fastmcp.cli.client import call_command, discover_command, list_command from fastmcp.cli.generate import generate_cli_command from fastmcp.cli.install import install_app @@ -960,6 +961,9 @@ app.command(call_command, name="call") app.command(discover_command, name="discover") app.command(generate_cli_command, name="generate-cli") +# Add auth subcommand group (includes CIMD commands) +app.command(auth_app) + if __name__ == "__main__": app() diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 393844d07..9fc90b4e8 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -143,56 +143,82 @@ class OAuth(OAuthClientProvider): a browser for user authorization and running a local callback server. """ + _bound: bool + def __init__( self, - mcp_url: str, + mcp_url: str | None = None, scopes: str | list[str] | None = None, client_name: str = "FastMCP Client", token_storage: AsyncKeyValue | None = None, additional_client_metadata: dict[str, Any] | None = None, callback_port: int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, + client_metadata_url: str | None = None, ): """ Initialize OAuth client provider for an MCP server. Args: - mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/") + mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/"). + Optional when OAuth is passed to Client(auth=...), which provides + the URL automatically from the transport. scopes: OAuth scopes to request. Can be a space-separated string or a list of strings. client_name: Name for this client during registration token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided additional_client_metadata: Extra fields for OAuthClientMetadata callback_port: Fixed port for OAuth callback (default: random available port) + client_metadata_url: A CIMD (Client ID Metadata Document) URL. When + provided, this URL is used as the client_id instead of performing + Dynamic Client Registration. Must be an HTTPS URL with a non-root + path (e.g. "https://myapp.example.com/oauth/client.json"). """ - # Normalize the MCP URL (strip trailing slashes for consistency) + # Store config for deferred binding if mcp_url not yet known + self._scopes = scopes + self._client_name = client_name + self._token_storage = token_storage + self._additional_client_metadata = additional_client_metadata + self._callback_port = callback_port + self._client_metadata_url = client_metadata_url + self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient + self._bound = False + + if mcp_url is not None: + self._bind(mcp_url) + + def _bind(self, mcp_url: str) -> None: + """Bind this OAuth provider to a specific MCP server URL. + + Called automatically when mcp_url is provided to __init__, or by the + transport when OAuth is used without an explicit URL. + """ + if self._bound: + return + mcp_url = mcp_url.rstrip("/") - # Setup OAuth client - self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient - self.redirect_port = callback_port or find_available_port() + self.redirect_port = self._callback_port or find_available_port() redirect_uri = f"http://localhost:{self.redirect_port}/callback" scopes_str: str - if isinstance(scopes, list): - scopes_str = " ".join(scopes) - elif scopes is not None: - scopes_str = str(scopes) + if isinstance(self._scopes, list): + scopes_str = " ".join(self._scopes) + elif self._scopes is not None: + scopes_str = str(self._scopes) else: scopes_str = "" client_metadata = OAuthClientMetadata( - client_name=client_name, + client_name=self._client_name, redirect_uris=[AnyHttpUrl(redirect_uri)], grant_types=["authorization_code", "refresh_token"], response_types=["code"], - # token_endpoint_auth_method="client_secret_post", scope=scopes_str, - **(additional_client_metadata or {}), + **(self._additional_client_metadata or {}), ) - # Create server-specific token storage - token_storage = token_storage or MemoryStore() + token_storage = self._token_storage or MemoryStore() if isinstance(token_storage, MemoryStore): from warnings import warn @@ -204,23 +230,23 @@ class OAuth(OAuthClientProvider): stacklevel=2, ) - # Use full URL for token storage to properly separate tokens per MCP endpoint self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( async_key_value=token_storage, server_url=mcp_url ) - # Store full MCP URL for use in callback_handler display self.mcp_url = mcp_url - # Initialize parent class with full URL for proper OAuth metadata discovery super().__init__( server_url=mcp_url, client_metadata=client_metadata, storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, callback_handler=self.callback_handler, + client_metadata_url=self._client_metadata_url, ) + self._bound = True + async def _initialize(self) -> None: """Load stored tokens and client info, properly setting token expiry.""" # Call parent's _initialize to load tokens and client info @@ -298,6 +324,11 @@ class OAuth(OAuthClientProvider): If the OAuth flow fails due to invalid/stale client credentials, clears the cache and retries once with fresh registration. """ + if not self._bound: + raise RuntimeError( + "OAuth provider has no server URL. Either pass mcp_url to OAuth() " + "or use it with Client(auth=...) which provides the URL automatically." + ) try: # First attempt with potentially cached credentials async with aclosing(super().async_auth_flow(request)) as gen: diff --git a/src/fastmcp/client/transports/http.py b/src/fastmcp/client/transports/http.py index 89ad8fc62..83dbb7cc8 100644 --- a/src/fastmcp/client/transports/http.py +++ b/src/fastmcp/client/transports/http.py @@ -76,11 +76,17 @@ class StreamableHttpTransport(ClientTransport): self._get_session_id_cb: Callable[[], str | None] | None = None def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): + resolved: httpx.Auth | None if auth == "oauth": - auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + elif isinstance(auth, OAuth): + auth._bind(self.url) + resolved = auth elif isinstance(auth, str): - auth = BearerAuth(auth) - self.auth = auth + resolved = BearerAuth(auth) + else: + resolved = auth + self.auth: httpx.Auth | None = resolved @contextlib.asynccontextmanager async def connect_session( diff --git a/src/fastmcp/client/transports/sse.py b/src/fastmcp/client/transports/sse.py index ec932e6d2..45db01bee 100644 --- a/src/fastmcp/client/transports/sse.py +++ b/src/fastmcp/client/transports/sse.py @@ -48,11 +48,17 @@ class SSETransport(ClientTransport): self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): + resolved: httpx.Auth | None if auth == "oauth": - auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + elif isinstance(auth, OAuth): + auth._bind(self.url) + resolved = auth elif isinstance(auth, str): - auth = BearerAuth(auth) - self.auth = auth + resolved = BearerAuth(auth) + else: + resolved = auth + self.auth: httpx.Auth | None = resolved @contextlib.asynccontextmanager async def connect_session( diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index 9a804f05d..b8b8b1f8c 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse from mcp.server.auth.handlers.token import TokenErrorResponse @@ -9,7 +9,13 @@ from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend -from mcp.server.auth.middleware.client_auth import ClientAuthenticator +from mcp.server.auth.middleware.client_auth import ( + AuthenticationError, + ClientAuthenticator, +) +from mcp.server.auth.middleware.client_auth import ( + ClientAuthenticator as _SDKClientAuthenticator, +) from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) @@ -30,13 +36,18 @@ from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) +from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyHttpUrl, Field from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.requests import Request from starlette.routing import Route from fastmcp.utilities.logging import get_logger +if TYPE_CHECKING: + from fastmcp.server.auth.cimd import CIMDClientManager + logger = get_logger(__name__) @@ -108,6 +119,91 @@ class TokenHandler(_SDKTokenHandler): return response +# Expected assertion type for private_key_jwt +JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + +class PrivateKeyJWTClientAuthenticator(_SDKClientAuthenticator): + """Client authenticator with private_key_jwt support for CIMD clients. + + Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt` + authentication method per RFC 7523. This is required for CIMD (Client ID Metadata + Document) clients that use asymmetric keys for authentication. + + The authenticator: + 1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none) + 2. Adds private_key_jwt handling for CIMD clients + 3. Validates JWT assertions against client's JWKS + """ + + def __init__( + self, + provider: OAuthAuthorizationServerProvider[Any, Any, Any], + cimd_manager: CIMDClientManager, + token_endpoint_url: str, + ): + """Initialize the authenticator. + + Args: + provider: OAuth provider for client lookups + cimd_manager: CIMD manager for private_key_jwt validation + token_endpoint_url: Token endpoint URL for audience validation + """ + super().__init__(provider) + self._cimd_manager = cimd_manager + self._token_endpoint_url = token_endpoint_url + + async def authenticate_request( + self, request: Request + ) -> OAuthClientInformationFull: + """Authenticate a client from an HTTP request. + + Extends SDK authentication to support private_key_jwt for CIMD clients. + Delegates to SDK for client_secret_basic (Authorization header) and + client_secret_post (form body) authentication. + """ + form_data = await request.form() + client_id = form_data.get("client_id") + + # If client_id is not in form data, delegate to SDK + # This handles client_secret_basic which sends credentials in Authorization header + if not client_id: + return await super().authenticate_request(request) + + client = await self.provider.get_client(str(client_id)) + if not client: + raise AuthenticationError("Invalid client_id") + + # Handle private_key_jwt authentication for CIMD clients + if client.token_endpoint_auth_method == "private_key_jwt": + # Validate assertion parameters + assertion_type = form_data.get("client_assertion_type") + assertion = form_data.get("client_assertion") + + if assertion_type != JWT_BEARER_ASSERTION_TYPE: + raise AuthenticationError( + f"Invalid client_assertion_type: expected {JWT_BEARER_ASSERTION_TYPE}" + ) + + if not assertion or not isinstance(assertion, str): + raise AuthenticationError("Missing client_assertion") + + # Validate the JWT assertion using CIMD manager + try: + await self._cimd_manager.validate_private_key_jwt( + assertion=assertion, + client=client, + token_endpoint=self._token_endpoint_url, + ) + except ValueError as e: + raise AuthenticationError(f"Invalid client assertion: {e}") from e + + return client + + # Delegate to SDK for other authentication methods + return await super().authenticate_request(request) + + class AuthProvider(TokenVerifierProtocol): """Base class for all FastMCP authentication providers. diff --git a/src/fastmcp/server/auth/cimd.py b/src/fastmcp/server/auth/cimd.py new file mode 100644 index 000000000..49aa6687c --- /dev/null +++ b/src/fastmcp/server/auth/cimd.py @@ -0,0 +1,651 @@ +"""CIMD (Client ID Metadata Document) support for FastMCP. + +.. warning:: + **Beta Feature**: CIMD support is currently in beta. The API may change + in future releases. Please report any issues you encounter. + +CIMD is a simpler alternative to Dynamic Client Registration where clients +host a static JSON document at an HTTPS URL, and that URL becomes their +client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document + +This module provides: +- CIMDDocument: Pydantic model for CIMD document validation +- CIMDFetcher: Fetch and validate CIMD documents with SSRF protection +- CIMDClientManager: Manages CIMD client operations +""" + +from __future__ import annotations + +import fnmatch +import json +import time +from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import urlparse + +from pydantic import AnyHttpUrl, BaseModel, Field, field_validator + +from fastmcp.server.auth.ssrf import ( + SSRFError, + SSRFFetchError, + ssrf_safe_fetch, + validate_url, +) +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from fastmcp.server.auth.providers.jwt import JWTVerifier + +logger = get_logger(__name__) + + +class CIMDDocument(BaseModel): + """CIMD document per draft-parecki-oauth-client-id-metadata-document. + + The client metadata document is a JSON document containing OAuth client + metadata. The client_id property MUST match the URL where this document + is hosted. + + Key constraint: token_endpoint_auth_method MUST NOT use shared secrets + (client_secret_post, client_secret_basic, client_secret_jwt). + + redirect_uris is required and must contain at least one entry. + """ + + client_id: AnyHttpUrl = Field( + ..., + description="Must match the URL where this document is hosted", + ) + client_name: str | None = Field( + default=None, + description="Human-readable name of the client", + ) + client_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's home page", + ) + logo_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's logo image", + ) + redirect_uris: list[str] = Field( + ..., + description="Array of allowed redirect URIs (may include wildcards like http://localhost:*/callback)", + ) + token_endpoint_auth_method: Literal["none", "private_key_jwt"] = Field( + default="none", + description="Authentication method for token endpoint (no shared secrets allowed)", + ) + grant_types: list[str] = Field( + default_factory=lambda: ["authorization_code"], + description="OAuth grant types the client will use", + ) + response_types: list[str] = Field( + default_factory=lambda: ["code"], + description="OAuth response types the client will use", + ) + scope: str | None = Field( + default=None, + description="Space-separated list of scopes the client may request", + ) + contacts: list[str] | None = Field( + default=None, + description="Contact information for the client developer", + ) + tos_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's terms of service", + ) + policy_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's privacy policy", + ) + jwks_uri: AnyHttpUrl | None = Field( + default=None, + description="URL of the client's JSON Web Key Set (for private_key_jwt)", + ) + jwks: dict[str, Any] | None = Field( + default=None, + description="Client's JSON Web Key Set (for private_key_jwt)", + ) + software_id: str | None = Field( + default=None, + description="Unique identifier for the client software", + ) + software_version: str | None = Field( + default=None, + description="Version of the client software", + ) + + @field_validator("token_endpoint_auth_method") + @classmethod + def validate_auth_method(cls, v: str) -> str: + """Ensure no shared-secret auth methods are used.""" + forbidden = {"client_secret_post", "client_secret_basic", "client_secret_jwt"} + if v in forbidden: + raise ValueError( + f"CIMD documents cannot use shared-secret auth methods: {v}. " + "Use 'none' or 'private_key_jwt' instead." + ) + return v + + @field_validator("redirect_uris") + @classmethod + def validate_redirect_uris(cls, v: list[str]) -> list[str]: + """Ensure redirect_uris is non-empty and each entry is a valid URI.""" + if not v: + raise ValueError("CIMD documents must include at least one redirect_uri") + for uri in v: + if not uri or not uri.strip(): + raise ValueError("CIMD redirect_uris must be non-empty strings") + parsed = urlparse(uri) + if not parsed.scheme: + raise ValueError( + f"CIMD redirect_uri must have a scheme (e.g. http:// or https://): {uri!r}" + ) + if not parsed.netloc and not uri.startswith("urn:"): + raise ValueError(f"CIMD redirect_uri must have a host: {uri!r}") + return v + + +class CIMDValidationError(Exception): + """Raised when CIMD document validation fails.""" + + +class CIMDFetchError(Exception): + """Raised when CIMD document fetching fails.""" + + +class CIMDFetcher: + """Fetch and validate CIMD documents with SSRF protection. + + Delegates HTTP fetching to ssrf_safe_fetch which provides DNS pinning, + IP validation, size limits, and timeout enforcement. Documents are cached + with a simple TTL. + """ + + # Maximum response size (bytes) + MAX_RESPONSE_SIZE = 5120 # 5KB + # Default cache TTL (seconds) + DEFAULT_CACHE_TTL_SECONDS = 3600 + + def __init__( + self, + timeout: float = 10.0, + ): + """Initialize the CIMD fetcher. + + Args: + timeout: HTTP request timeout in seconds (default 10.0) + """ + self.timeout = timeout + self._cache: dict[str, tuple[CIMDDocument, float]] = {} + + def is_cimd_client_id(self, client_id: str) -> bool: + """Check if a client_id looks like a CIMD URL. + + CIMD URLs must be HTTPS with a host and non-root path. + """ + if not client_id: + return False + try: + parsed = urlparse(client_id) + return ( + parsed.scheme == "https" + and bool(parsed.netloc) + and parsed.path not in ("", "/") + ) + except (ValueError, AttributeError): + return False + + async def fetch(self, client_id_url: str) -> CIMDDocument: + """Fetch and validate a CIMD document with SSRF protection. + + Uses ssrf_safe_fetch for the HTTP layer, which provides: + - HTTPS only, DNS resolution with IP validation + - DNS pinning (connects to validated IP directly) + - Blocks private/loopback/link-local/multicast IPs + - Response size limit and timeout enforcement + - Redirects disabled + + Args: + client_id_url: The URL to fetch (also the expected client_id) + + Returns: + Validated CIMDDocument + + Raises: + CIMDValidationError: If document is invalid or URL blocked + CIMDFetchError: If document cannot be fetched + """ + cached = self._cache.get(client_id_url) + if cached is not None: + doc, expires_at = cached + if time.time() < expires_at: + return doc + + try: + content = await ssrf_safe_fetch( + client_id_url, + require_path=True, + max_size=self.MAX_RESPONSE_SIZE, + timeout=self.timeout, + overall_timeout=30.0, + ) + except SSRFError as e: + raise CIMDValidationError(str(e)) from e + except SSRFFetchError as e: + raise CIMDFetchError(str(e)) from e + + try: + data = json.loads(content) + except json.JSONDecodeError as e: + raise CIMDValidationError(f"CIMD document is not valid JSON: {e}") from e + + try: + doc = CIMDDocument.model_validate(data) + except Exception as e: + raise CIMDValidationError(f"Invalid CIMD document: {e}") from e + + if str(doc.client_id).rstrip("/") != client_id_url.rstrip("/"): + raise CIMDValidationError( + f"CIMD client_id mismatch: document says '{doc.client_id}' " + f"but was fetched from '{client_id_url}'" + ) + + # Validate jwks_uri if present (SSRF check for JWKS endpoint) + if doc.jwks_uri: + jwks_uri_str = str(doc.jwks_uri) + try: + await validate_url(jwks_uri_str) + except SSRFError as e: + raise CIMDValidationError( + f"CIMD jwks_uri failed SSRF validation: {e}" + ) from e + + logger.info( + "CIMD document fetched and validated: %s (client_name=%s)", + client_id_url, + doc.client_name, + ) + + self._cache[client_id_url] = (doc, time.time() + self.DEFAULT_CACHE_TTL_SECONDS) + return doc + + def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool: + """Validate that a redirect_uri is allowed by the CIMD document. + + Args: + doc: The CIMD document + redirect_uri: The redirect URI to validate + + Returns: + True if valid, False otherwise + """ + if not doc.redirect_uris: + # No redirect_uris specified - reject all + return False + + # Normalize for comparison + redirect_uri = redirect_uri.rstrip("/") + + for allowed in doc.redirect_uris: + allowed_str = allowed.rstrip("/") + if redirect_uri == allowed_str: + return True + + # Check for wildcard port matching (http://localhost:*/callback) + if "*" in allowed_str: + if fnmatch.fnmatch(redirect_uri, allowed_str): + return True + + return False + + +class CIMDAssertionValidator: + """Validates JWT assertions for private_key_jwt CIMD clients. + + Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client + Authentication and Authorization Grants) for CIMD client authentication. + + JTI replay protection uses TTL-based caching to ensure proper security: + - JTIs are cached with expiration matching the JWT's exp claim + - Expired JTIs are automatically cleaned up + - Maximum assertion lifetime is enforced (5 minutes) + """ + + # Maximum allowed assertion lifetime in seconds (RFC 7523 recommends short-lived) + MAX_ASSERTION_LIFETIME = 300 # 5 minutes + + def __init__(self): + # JTI cache: maps jti -> expiration timestamp + self._jti_cache: dict[str, float] = {} + self._jti_cache_max_size = 10000 + self._last_cleanup = time.monotonic() + self._cleanup_interval = 60 # Cleanup every 60 seconds + # Cache JWTVerifier per jwks_uri so JWKS keys are not re-fetched + # on every token exchange + self._verifier_cache: dict[str, JWTVerifier] = {} + self._verifier_cache_max_size = 100 + self.logger = get_logger(__name__) + + def _cleanup_expired_jtis(self) -> None: + """Remove expired JTIs from cache.""" + now = time.time() + expired = [jti for jti, exp in self._jti_cache.items() if exp < now] + for jti in expired: + del self._jti_cache[jti] + if expired: + self.logger.debug("Cleaned up %d expired JTIs from cache", len(expired)) + + def _maybe_cleanup(self) -> None: + """Periodically cleanup expired JTIs to prevent unbounded growth.""" + now = time.monotonic() + if now - self._last_cleanup > self._cleanup_interval: + self._cleanup_expired_jtis() + self._last_cleanup = now + + async def validate_assertion( + self, + assertion: str, + client_id: str, + token_endpoint: str, + cimd_doc: CIMDDocument, + ) -> bool: + """Validate JWT assertion from client. + + Args: + assertion: The JWT assertion string + client_id: Expected client_id (must match iss and sub claims) + token_endpoint: Token endpoint URL (must match aud claim) + cimd_doc: CIMD document containing JWKS for key verification + + Returns: + True if valid + + Raises: + ValueError: If validation fails + """ + from fastmcp.server.auth.providers.jwt import JWTVerifier as _JWTVerifier + + # Periodic cleanup of expired JTIs + self._maybe_cleanup() + + # 1. Validate CIMD document has key material and get/create verifier + if cimd_doc.jwks_uri: + jwks_uri_str = str(cimd_doc.jwks_uri) + cache_key = f"{jwks_uri_str}|{client_id}|{token_endpoint}" + verifier = self._verifier_cache.get(cache_key) + if verifier is None: + verifier = _JWTVerifier( + jwks_uri=jwks_uri_str, + issuer=client_id, + audience=token_endpoint, + ssrf_safe=True, + ) + if len(self._verifier_cache) >= self._verifier_cache_max_size: + oldest_key = next(iter(self._verifier_cache)) + del self._verifier_cache[oldest_key] + self._verifier_cache[cache_key] = verifier + elif cimd_doc.jwks: + # Inline JWKS — no caching since the key is embedded + public_key = self._extract_public_key_from_jwks(assertion, cimd_doc.jwks) + verifier = _JWTVerifier( + public_key=public_key, + issuer=client_id, + audience=token_endpoint, + ) + else: + raise ValueError( + "CIMD document must have jwks_uri or jwks for private_key_jwt" + ) + + # 2. Verify JWT using JWTVerifier (handles signature, exp, iss, aud) + access_token = await verifier.load_access_token(assertion) + if not access_token: + raise ValueError("Invalid JWT assertion") + + claims = access_token.claims + + # 3. Validate assertion lifetime (exp and iat) + now = time.time() + exp = claims.get("exp") + iat = claims.get("iat") + + if not exp: + raise ValueError("Assertion must include exp claim") + + # Validate exp is in the future (with small clock skew tolerance) + if exp < now - 30: # 30 second clock skew tolerance + raise ValueError("Assertion has expired") + + # If iat is present, validate it and check assertion lifetime + if iat: + if iat > now + 30: # 30 second clock skew tolerance + raise ValueError("Assertion iat is in the future") + if exp - iat > self.MAX_ASSERTION_LIFETIME: + raise ValueError( + f"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)" + ) + else: + # No iat, enforce max lifetime from now + if exp > now + self.MAX_ASSERTION_LIFETIME: + raise ValueError( + f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)" + ) + + # 4. Additional RFC 7523 validation: sub claim must equal client_id + if claims.get("sub") != client_id: + raise ValueError(f"Assertion sub claim must be {client_id}") + + # 5. Check jti for replay attacks (RFC 7523 requirement) + jti = claims.get("jti") + if not jti: + raise ValueError("Assertion must include jti claim") + + # Check if JTI was already used (and hasn't expired from cache) + if jti in self._jti_cache: + cached_exp = self._jti_cache[jti] + if cached_exp > now: # Still valid in cache + raise ValueError(f"Assertion replay detected: jti {jti} already used") + # Expired in cache, can be reused (clean it up) + del self._jti_cache[jti] + + # Add to cache with expiration time + # Use the assertion's exp claim so it stays cached until it would expire anyway + self._jti_cache[jti] = exp + + # Emergency size limit (shouldn't hit with proper TTL cleanup) + if len(self._jti_cache) > self._jti_cache_max_size: + self._cleanup_expired_jtis() + # If still over limit after cleanup, reject to prevent DoS + if len(self._jti_cache) > self._jti_cache_max_size: + self.logger.warning( + "JTI cache at max capacity (%d), possible attack", + self._jti_cache_max_size, + ) + raise ValueError("Server overloaded, please retry") + + self.logger.debug( + "JWT assertion validated successfully for client %s", client_id + ) + return True + + def _extract_public_key_from_jwks(self, token: str, jwks: dict) -> str: + """Extract public key from inline JWKS. + + Args: + token: JWT token to extract kid from + jwks: JWKS document containing keys + + Returns: + PEM-encoded public key + + Raises: + ValueError: If key cannot be found or extracted + """ + import base64 + import json + + from authlib.jose import JsonWebKey + + # Extract kid from token header + try: + header_b64 = token.split(".")[0] + header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding + header = json.loads(base64.urlsafe_b64decode(header_b64)) + kid = header.get("kid") + except Exception as e: + raise ValueError(f"Failed to extract key ID from token: {e}") from e + + # Find matching key in JWKS + keys = jwks.get("keys", []) + if not keys: + raise ValueError("JWKS document contains no keys") + + matching_key = None + for key in keys: + if kid and key.get("kid") == kid: + matching_key = key + break + + if not matching_key: + # If no kid match, try first key as fallback + if len(keys) == 1: + matching_key = keys[0] + self.logger.warning( + "No matching kid in JWKS, using single available key" + ) + else: + raise ValueError(f"No matching key found for kid={kid} in JWKS") + + # Convert JWK to PEM + try: + jwk = JsonWebKey.import_key(matching_key) + return jwk.as_pem().decode("utf-8") + except Exception as e: + raise ValueError(f"Failed to convert JWK to PEM: {e}") from e + + +class CIMDClientManager: + """Manages all CIMD client operations for OAuth proxy. + + This class encapsulates: + - CIMD client detection + - Document fetching and validation + - Synthetic OAuth client creation + - Private key JWT assertion validation + + This allows the OAuth proxy to delegate all CIMD-specific logic to a + single, focused manager class. + """ + + def __init__( + self, + enable_cimd: bool = True, + default_scope: str = "", + allowed_redirect_uri_patterns: list[str] | None = None, + ): + """Initialize CIMD client manager. + + Args: + enable_cimd: Whether CIMD support is enabled + default_scope: Default scope for CIMD clients if not specified in document + allowed_redirect_uri_patterns: Allowed redirect URI patterns (proxy's config) + """ + self.enabled = enable_cimd + self.default_scope = default_scope + self.allowed_redirect_uri_patterns = allowed_redirect_uri_patterns + + self._fetcher = CIMDFetcher() + self._assertion_validator = CIMDAssertionValidator() + self.logger = get_logger(__name__) + + def is_cimd_client_id(self, client_id: str) -> bool: + """Check if client_id is a CIMD URL. + + Args: + client_id: Client ID to check + + Returns: + True if client_id is an HTTPS URL (CIMD format) + """ + return self.enabled and self._fetcher.is_cimd_client_id(client_id) + + async def get_client(self, client_id_url: str): + """Fetch CIMD document and create synthetic OAuth client. + + Args: + client_id_url: HTTPS URL pointing to CIMD document + + Returns: + OAuthProxyClient with CIMD document attached, or None if fetch fails + + Note: + Return type is left untyped to avoid circular import with oauth_proxy. + Returns OAuthProxyClient instance or None. + """ + if not self.enabled: + return None + + try: + cimd_doc = await self._fetcher.fetch(client_id_url) + except (CIMDFetchError, CIMDValidationError) as e: + self.logger.warning("CIMD fetch failed for %s: %s", client_id_url, e) + return None + + # Import here to avoid circular dependency + from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient + + # Create synthetic client from CIMD document. + # Keep CIMD redirect_uris as strings on the document itself so wildcard + # patterns like http://localhost:*/callback remain valid. + redirect_uris = None + client = ProxyDCRClient( + client_id=client_id_url, + client_secret=None, + redirect_uris=redirect_uris, + grant_types=cimd_doc.grant_types, + scope=cimd_doc.scope or self.default_scope, + token_endpoint_auth_method=cimd_doc.token_endpoint_auth_method, + allowed_redirect_uri_patterns=self.allowed_redirect_uri_patterns, + client_name=cimd_doc.client_name, + cimd_document=cimd_doc, + cimd_fetched_at=time.time(), + ) + + self.logger.debug( + "CIMD client resolved: %s (name=%s)", + client_id_url, + cimd_doc.client_name, + ) + return client + + async def validate_private_key_jwt( + self, + assertion: str, + client, # OAuthProxyClient, untyped to avoid circular import + token_endpoint: str, + ) -> bool: + """Validate JWT assertion for private_key_jwt auth. + + Args: + assertion: JWT assertion string from client + client: OAuth proxy client (must have cimd_document) + token_endpoint: Token endpoint URL for aud validation + + Returns: + True if assertion is valid + + Raises: + ValueError: If client doesn't have CIMD document or validation fails + """ + if not hasattr(client, "cimd_document") or not client.cimd_document: + raise ValueError("Client must have CIMD document for private_key_jwt") + + cimd_doc = client.cimd_document + if cimd_doc.token_endpoint_auth_method != "private_key_jwt": + raise ValueError("CIMD document must specify private_key_jwt auth method") + + return await self._assertion_validator.validate_assertion( + assertion, client.client_id, token_endpoint, cimd_doc + ) diff --git a/src/fastmcp/server/auth/oauth_proxy/consent.py b/src/fastmcp/server/auth/oauth_proxy/consent.py index 6f47a5da7..87b63d88f 100644 --- a/src/fastmcp/server/auth/oauth_proxy/consent.py +++ b/src/fastmcp/server/auth/oauth_proxy/consent.py @@ -21,6 +21,7 @@ from pydantic import AnyUrl from starlette.requests import Request from starlette.responses import HTMLResponse, RedirectResponse +from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient from fastmcp.server.auth.oauth_proxy.ui import create_consent_html from fastmcp.utilities.logging import get_logger from fastmcp.utilities.ui import create_secure_html_response @@ -245,10 +246,17 @@ class ConsentMixin: txn["csrf_token"] = csrf_token txn["csrf_expires_at"] = csrf_expires_at - # Load client to get client_name if available + # Load client to get client_name and CIMD info if available client = await self.get_client(txn["client_id"]) client_name = getattr(client, "client_name", None) if client else None + # Detect CIMD clients for verified domain badge + is_cimd_client = False + cimd_domain: str | None = None + if isinstance(client, ProxyDCRClient) and client.cimd_document is not None: + is_cimd_client = True + cimd_domain = urlparse(txn["client_id"]).hostname + # Extract server metadata from app state fastmcp = getattr(request.app.state, "fastmcp_server", None) @@ -273,6 +281,8 @@ class ConsentMixin: server_icon_url=server_icon_url, server_website_url=server_website_url, csp_policy=self._consent_csp_policy, + is_cimd_client=is_cimd_client, + cimd_domain=cimd_domain, ) response = create_secure_html_response(html) # Store CSRF in cookie with short lifetime diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py index 53c939f2a..575c846ba 100644 --- a/src/fastmcp/server/auth/oauth_proxy/models.py +++ b/src/fastmcp/server/auth/oauth_proxy/models.py @@ -11,7 +11,11 @@ from typing import Any, Final from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull from pydantic import AnyUrl, BaseModel, Field -from fastmcp.server.auth.redirect_validation import validate_redirect_uri +from fastmcp.server.auth.cimd import CIMDDocument +from fastmcp.server.auth.redirect_validation import ( + matches_allowed_pattern, + validate_redirect_uri, +) # ------------------------------------------------------------------------- # Constants @@ -156,28 +160,77 @@ class ProxyDCRClient(OAuthClientInformationFull): allowed_redirect_uri_patterns: list[str] | None = Field(default=None) client_name: str | None = Field(default=None) + cimd_document: CIMDDocument | None = Field(default=None) + cimd_fetched_at: float | None = Field(default=None) def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: - """Validate redirect URI against allowed patterns. + """Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. - Since we're acting as a proxy and clients register dynamically, - we validate their redirect URIs against configurable patterns. - This is essential for cached token scenarios where the client may - reconnect with a different port. + For CIMD clients: validates against BOTH the CIMD document's redirect_uris + AND the proxy's allowed patterns (if configured). Both must pass. + + For DCR clients: validates against proxy patterns first, falling back to + base validation (registered redirect_uris) if patterns don't match. """ + if redirect_uri is None and self.cimd_document is not None: + cimd_redirect_uris = self.cimd_document.redirect_uris + if len(cimd_redirect_uris) == 1: + candidate = cimd_redirect_uris[0] + if "*" in candidate: + raise InvalidRedirectUriError( + "redirect_uri must be specified when CIMD redirect_uris uses wildcards." + ) + try: + return AnyUrl(candidate) + except Exception as e: + raise InvalidRedirectUriError( + f"Invalid CIMD redirect_uri: {e}" + ) from e + + raise InvalidRedirectUriError( + "redirect_uri must be specified when CIMD lists multiple redirect_uris." + ) + if redirect_uri is not None: - # Validate against allowed patterns - if validate_redirect_uri( - redirect_uri=redirect_uri, - allowed_patterns=self.allowed_redirect_uri_patterns, - ): + cimd_redirect_uris = ( + self.cimd_document.redirect_uris if self.cimd_document else None + ) + + if cimd_redirect_uris: + uri_str = str(redirect_uri) + cimd_match = any( + matches_allowed_pattern(uri_str, pattern) + for pattern in cimd_redirect_uris + ) + if not cimd_match: + raise InvalidRedirectUriError( + f"Redirect URI '{redirect_uri}' does not match CIMD redirect_uris." + ) + + if self.allowed_redirect_uri_patterns: + if not validate_redirect_uri( + redirect_uri=redirect_uri, + allowed_patterns=self.allowed_redirect_uri_patterns, + ): + raise InvalidRedirectUriError( + f"Redirect URI '{redirect_uri}' does not match allowed patterns." + ) + return redirect_uri - # If patterns are explicitly configured then reject non-matching URIs + pattern_matches = validate_redirect_uri( + redirect_uri=redirect_uri, + allowed_patterns=self.allowed_redirect_uri_patterns, + ) + + if pattern_matches: + return redirect_uri + + # Patterns configured but didn't match if self.allowed_redirect_uri_patterns: raise InvalidRedirectUriError( f"Redirect URI '{redirect_uri}' does not match allowed patterns." ) - # If no redirect_uri provided, use default behavior + # No redirect_uri provided or no patterns configured — use base validation return super().validate_redirect_uri(redirect_uri) diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index e1a24720f..27773ef25 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -32,6 +32,7 @@ from cryptography.fernet import Fernet from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue from key_value.aio.wrappers.encryption import FernetEncryptionWrapper +from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, @@ -40,6 +41,7 @@ from mcp.server.auth.provider import ( RefreshToken, TokenError, ) +from mcp.server.auth.routes import build_metadata, cors_middleware from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, @@ -52,7 +54,13 @@ from starlette.routing import Route from typing_extensions import override from fastmcp import settings -from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier +from fastmcp.server.auth.auth import ( + OAuthProvider, + PrivateKeyJWTClientAuthenticator, + TokenHandler, + TokenVerifier, +) +from fastmcp.server.auth.cimd import CIMDClientManager from fastmcp.server.auth.handlers.authorize import AuthorizationHandler from fastmcp.server.auth.jwt_issuer import ( JWTIssuer, @@ -248,6 +256,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): consent_csp_policy: str | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, + # CIMD (Client ID Metadata Document) support + enable_cimd: bool = True, ): """Initialize the OAuth proxy provider. @@ -302,6 +312,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): defaults: 1 hour if a refresh token is available (since we can refresh), or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). Set explicitly to override these defaults. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs. When True, clients can authenticate using HTTPS URLs as client + IDs, with metadata fetched from the URL. Supports private_key_jwt auth. """ # Always enable DCR since we implement it locally for MCP clients @@ -484,6 +497,15 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Use the provided token validator self._token_validator: TokenVerifier = token_verifier + # CIMD (Client ID Metadata Document) support + self._cimd_manager: CIMDClientManager | None = None + if enable_cimd: + self._cimd_manager = CIMDClientManager( + enable_cimd=True, + default_scope=self._default_scope_str, + allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, + ) + logger.debug( "Initialized OAuth proxy provider with upstream server %s", self._upstream_authorization_endpoint, @@ -559,15 +581,43 @@ class OAuthProxy(OAuthProvider, ConsentMixin): provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). + CIMD clients (URL-based client IDs) are looked up and cached automatically. """ # Load from storage - if not (client := await self._client_store.get(key=client_id)): - return None + client = await self._client_store.get(key=client_id) - if client.allowed_redirect_uri_patterns is None: - client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris + if client is not None: + if client.allowed_redirect_uri_patterns is None: + client.allowed_redirect_uri_patterns = ( + self._allowed_client_redirect_uris + ) - return client + # Refresh CIMD clients using HTTP cache-aware fetcher. + if self._cimd_manager is not None and client.cimd_document is not None: + try: + refreshed = await self._cimd_manager.get_client(client_id) + if refreshed is not None: + await self._client_store.put(key=client_id, value=refreshed) + return refreshed + except Exception as e: + logger.debug( + "CIMD refresh failed for %s, using cached client: %s", + client_id, + e, + ) + + return client + + # Client not in storage — try CIMD lookup for URL-based client IDs + if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id( + client_id + ): + cimd_client = await self._cimd_manager.get_client(client_id) + if cimd_client is not None: + await self._client_store.put(key=client_id, value=cimd_client) + return cimd_client + + return None @override async def register_client(self, client_info: OAuthClientInformationFull) -> None: @@ -1437,6 +1487,61 @@ class OAuthProxy(OAuthProvider, ConsentMixin): methods=["GET", "POST"], ) ) + elif ( + self._cimd_manager is not None + and isinstance(route, Route) + and route.path == "/token" + and route.methods is not None + and "POST" in route.methods + ): + # Replace the token endpoint authenticator with one that supports + # private_key_jwt for CIMD clients + token_endpoint_url = f"{self.base_url}/token" + cimd_authenticator = PrivateKeyJWTClientAuthenticator( + provider=self, + cimd_manager=self._cimd_manager, + token_endpoint_url=token_endpoint_url, + ) + token_handler = TokenHandler( + provider=self, client_authenticator=cimd_authenticator + ) + custom_routes.append( + Route( + path="/token", + endpoint=cors_middleware( + token_handler.handle, ["POST", "OPTIONS"] + ), + methods=["POST", "OPTIONS"], + ) + ) + elif ( + self._cimd_manager is not None + and isinstance(route, Route) + and route.path.startswith("/.well-known/oauth-authorization-server") + ): + client_registration_options = ( + self.client_registration_options or ClientRegistrationOptions() + ) + revocation_options = self.revocation_options or RevocationOptions() + metadata = build_metadata( + self.base_url, # ty: ignore[invalid-argument-type] + self.service_documentation_url, + client_registration_options, + revocation_options, + ) + metadata.client_id_metadata_document_supported = True + handler = MetadataHandler(metadata) + methods = route.methods or ["GET", "OPTIONS"] + + custom_routes.append( + Route( + path=route.path, + endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), + methods=methods, + name=route.name, + include_in_schema=route.include_in_schema, + ) + ) else: # Keep all other standard OAuth routes unchanged custom_routes.append(route) diff --git a/src/fastmcp/server/auth/oauth_proxy/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py index 3bae1a11c..4cbb3ec2c 100644 --- a/src/fastmcp/server/auth/oauth_proxy/ui.py +++ b/src/fastmcp/server/auth/oauth_proxy/ui.py @@ -32,6 +32,8 @@ def create_consent_html( server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None, + is_cimd_client: bool = False, + cimd_domain: str | None = None, ) -> str: """Create a styled HTML consent page for OAuth authorization requests. @@ -60,6 +62,17 @@ def create_consent_html( """ + # Build CIMD verified domain badge if applicable + cimd_badge = "" + if is_cimd_client and cimd_domain: + cimd_domain_escaped = html_module.escape(cimd_domain) + cimd_badge = f""" +
+ + Verified domain: {cimd_domain_escaped} +
+ """ + # Build redirect URI section (yellow box, centered) redirect_uri_escaped = html_module.escape(redirect_uri) redirect_section = f""" @@ -144,6 +157,7 @@ def create_consent_html( {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}

Application Access Request

{intro_box} + {cimd_badge} {redirect_section} {advanced_details} {form} @@ -152,6 +166,23 @@ def create_consent_html( """ # Additional styles needed for this page + cimd_badge_styles = """ + .cimd-badge { + background: #ecfdf5; + border: 1px solid #6ee7b7; + border-radius: 8px; + padding: 8px 16px; + margin-bottom: 16px; + font-size: 14px; + color: #065f46; + text-align: center; + } + .cimd-check { + color: #059669; + font-weight: bold; + margin-right: 4px; + } + """ additional_styles = ( INFO_BOX_STYLES + REDIRECT_SECTION_STYLES @@ -159,6 +190,7 @@ def create_consent_html( + DETAIL_BOX_STYLES + BUTTON_STYLES + TOOLTIP_STYLES + + cimd_badge_styles ) # Determine CSP policy to use diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 1bcdef4e4..d89ac0756 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -228,6 +228,8 @@ class OIDCProxy(OAuthProxy): extra_token_params: dict[str, str] | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, + # CIMD configuration + enable_cimd: bool = True, ) -> None: """Initialize the OIDC proxy provider. @@ -278,6 +280,9 @@ class OIDCProxy(OAuthProxy): doesn't return `expires_in` in the token response. If not set, uses smart defaults: 1 hour if a refresh token is available (since we can refresh), or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). + enable_cimd: Whether to enable CIMD (Client ID Metadata Document) client support. + When True, clients can use their metadata document URL as client_id instead of + Dynamic Client Registration. Default is True. """ if not config_url: raise ValueError("Missing required config URL") @@ -351,6 +356,7 @@ class OIDCProxy(OAuthProxy): "require_authorization_consent": require_authorization_consent, "consent_csp_policy": consent_csp_policy, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, + "enable_cimd": enable_cimd, } if redirect_path: diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index fd01c2f2c..828b9238f 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import time from dataclasses import dataclass from typing import Any, cast @@ -15,6 +16,7 @@ from pydantic import AnyHttpUrl, SecretStr from typing_extensions import TypedDict from fastmcp.server.auth import AccessToken, TokenVerifier +from fastmcp.server.auth.ssrf import SSRFError, SSRFFetchError, ssrf_safe_fetch from fastmcp.utilities.auth import decode_jwt_header, parse_scopes from fastmcp.utilities.logging import get_logger @@ -165,6 +167,7 @@ class JWTVerifier(TokenVerifier): algorithm: str | None = None, required_scopes: list[str] | None = None, base_url: AnyHttpUrl | str | None = None, + ssrf_safe: bool = False, ): """ Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint. @@ -177,6 +180,10 @@ class JWTVerifier(TokenVerifier): algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. required_scopes: Scopes that must be present in validated tokens. base_url: Base URL passed to the parent TokenVerifier. + ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only, + public IPs, DNS pinning). Enable when the JWKS URI comes from + untrusted input (e.g. CIMD documents). Defaults to False so + operator-configured JWKS URIs (including localhost) work normally. Raises: ValueError: If neither or both of `public_key` and `jwks_uri` are provided, or if `algorithm` is unsupported. @@ -220,6 +227,7 @@ class JWTVerifier(TokenVerifier): self.audience = audience self.public_key = public_key self.jwks_uri = jwks_uri + self.ssrf_safe = ssrf_safe self.jwt = JsonWebToken([self.algorithm]) self.logger = get_logger(__name__) @@ -239,11 +247,11 @@ class JWTVerifier(TokenVerifier): kid = header.get("kid") return await self._get_jwks_key(kid) - except Exception as e: + except (ValueError, KeyError, IndexError, json.JSONDecodeError) as e: raise ValueError(f"Failed to extract key ID from token: {e}") from e async def _get_jwks_key(self, kid: str | None) -> str: - """Fetch key from JWKS with simple caching.""" + """Fetch key from JWKS with simple caching and SSRF protection.""" if not self.jwks_uri: raise ValueError("JWKS URI not configured") @@ -257,12 +265,9 @@ class JWTVerifier(TokenVerifier): # If no kid but only one key cached, use it return next(iter(self._jwks_cache.values())) - # Fetch JWKS + # Fetch JWKS — with SSRF protection when enabled (untrusted URIs) try: - async with httpx.AsyncClient() as client: - response = await client.get(self.jwks_uri) - response.raise_for_status() - jwks_data = response.json() + jwks_data = await self._fetch_jwks() # Cache all keys self._jwks_cache = {} @@ -298,11 +303,35 @@ class JWTVerifier(TokenVerifier): else: raise ValueError("No keys found in JWKS") + except (SSRFError, SSRFFetchError) as e: + self.logger.debug("JWKS fetch blocked by SSRF protection: %s", e) + raise ValueError(f"Failed to fetch JWKS: {e}") from e except httpx.HTTPError as e: raise ValueError(f"Failed to fetch JWKS: {e}") from e - except Exception as e: - self.logger.debug(f"JWKS fetch failed: {e}") - raise ValueError(f"Failed to fetch JWKS: {e}") from e + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JWKS JSON: {e}") from e + except (JoseError, TypeError, KeyError) as e: + self.logger.debug("JWKS key processing failed: %s", e) + raise ValueError(f"Failed to process JWKS: {e}") from e + + async def _fetch_jwks(self) -> dict[str, Any]: + """Fetch JWKS data, using SSRF-safe or standard fetch based on config.""" + if not self.jwks_uri: + raise ValueError("JWKS URI not configured") + + if self.ssrf_safe: + content = await ssrf_safe_fetch( + self.jwks_uri, + max_size=65536, + timeout=10.0, + overall_timeout=30.0, + ) + return json.loads(content) + else: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: + response = await client.get(self.jwks_uri) + response.raise_for_status() + return response.json() def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: """ @@ -435,7 +464,7 @@ class JWTVerifier(TokenVerifier): except JoseError: self.logger.debug("Token validation failed: JWT signature/format invalid") return None - except Exception as e: + except (ValueError, TypeError, KeyError, AttributeError) as e: self.logger.debug("Token validation failed: %s", str(e)) return None diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py index f49958ad5..4d011416f 100644 --- a/src/fastmcp/server/auth/redirect_validation.py +++ b/src/fastmcp/server/auth/redirect_validation.py @@ -1,19 +1,138 @@ -"""Utilities for validating client redirect URIs in OAuth flows.""" +"""Utilities for validating client redirect URIs in OAuth flows. + +This module provides secure redirect URI validation with wildcard support, +protecting against userinfo-based bypass attacks like http://localhost@evil.com. +""" import fnmatch +from urllib.parse import urlparse from pydantic import AnyUrl -def matches_allowed_pattern(uri: str, pattern: str) -> bool: - """Check if a URI matches an allowed pattern with wildcard support. +def _parse_host_port(netloc: str) -> tuple[str | None, str | None]: + """Parse host and port from netloc, handling wildcards. - Patterns support * wildcard matching: + Args: + netloc: The netloc component (e.g., "localhost:8080" or "localhost:*") + + Returns: + Tuple of (host, port_str) where port_str may be "*" or a number string + """ + # Handle userinfo (remove it for parsing, but we check separately) + if "@" in netloc: + netloc = netloc.split("@")[-1] + + # Handle IPv6 addresses [::1]:port + if netloc.startswith("["): + bracket_end = netloc.find("]") + if bracket_end == -1: + return netloc, None + host = netloc[1:bracket_end] + rest = netloc[bracket_end + 1 :] + if rest.startswith(":"): + return host, rest[1:] + return host, None + + # Handle regular host:port + if ":" in netloc: + host, port = netloc.rsplit(":", 1) + return host, port + + return netloc, None + + +def _match_host(uri_host: str | None, pattern_host: str | None) -> bool: + """Match host component, supporting *.example.com wildcard patterns. + + Args: + uri_host: The host from the URI being validated + pattern_host: The host pattern (may start with *.) + + Returns: + True if the host matches + """ + if not uri_host or not pattern_host: + return uri_host == pattern_host + + # Normalize to lowercase for comparison + uri_host = uri_host.lower() + pattern_host = pattern_host.lower() + + # Handle *.example.com wildcard subdomain patterns + if pattern_host.startswith("*."): + suffix = pattern_host[1:] # .example.com + # Only match actual subdomains (foo.example.com), NOT the base domain + return uri_host.endswith(suffix) and uri_host != pattern_host[2:] + + return uri_host == pattern_host + + +def _match_port( + uri_port: str | None, + pattern_port: str | None, + uri_scheme: str, +) -> bool: + """Match port component, supporting * wildcard for any port. + + Args: + uri_port: The port from the URI (None if default, string otherwise) + pattern_port: The port from the pattern (None if default, "*" for wildcard) + uri_scheme: The URI scheme (http/https) for default port handling + + Returns: + True if the port matches + """ + # Wildcard matches any port + if pattern_port == "*": + return True + + # Normalize None to default ports + default_port = "443" if uri_scheme == "https" else "80" + uri_effective = uri_port if uri_port else default_port + pattern_effective = pattern_port if pattern_port else default_port + + return uri_effective == pattern_effective + + +def _match_path(uri_path: str, pattern_path: str) -> bool: + """Match path component using fnmatch for wildcard support. + + Args: + uri_path: The path from the URI + pattern_path: The path pattern (may contain * wildcards) + + Returns: + True if the path matches + """ + # Normalize empty paths to / + uri_path = uri_path or "/" + pattern_path = pattern_path or "/" + + # Empty or root pattern path matches any path + # This makes http://localhost:* match http://localhost:3000/callback + if pattern_path == "/": + return True + + # Use fnmatch for path wildcards (e.g., /auth/*) + return fnmatch.fnmatch(uri_path, pattern_path) + + +def matches_allowed_pattern(uri: str, pattern: str) -> bool: + """Securely check if a URI matches an allowed pattern with wildcard support. + + This function parses both the URI and pattern as URLs, comparing each + component separately to prevent bypass attacks like userinfo injection. + + Patterns support wildcards: - http://localhost:* matches any localhost port - http://127.0.0.1:* matches any 127.0.0.1 port - https://*.example.com/* matches any subdomain of example.com - https://app.example.com/auth/* matches any path under /auth/ + Security: Rejects URIs with userinfo (user:pass@host) which could bypass + naive string matching (e.g., http://localhost@evil.com). + Args: uri: The redirect URI to validate pattern: The allowed pattern (may contain wildcards) @@ -21,8 +140,36 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool: Returns: True if the URI matches the pattern """ - # Use fnmatch for wildcard matching - return fnmatch.fnmatch(uri, pattern) + try: + uri_parsed = urlparse(uri) + pattern_parsed = urlparse(pattern) + except ValueError: + return False + + # SECURITY: Reject URIs with userinfo (user:pass@host) + # This prevents bypass attacks like http://localhost@evil.com/callback + # which would match http://localhost:* with naive fnmatch + if uri_parsed.username is not None or uri_parsed.password is not None: + return False + + # Scheme must match exactly + if uri_parsed.scheme.lower() != pattern_parsed.scheme.lower(): + return False + + # Parse host and port manually to handle wildcards + uri_host, uri_port = _parse_host_port(uri_parsed.netloc) + pattern_host, pattern_port = _parse_host_port(pattern_parsed.netloc) + + # Host must match (with subdomain wildcard support) + if not _match_host(uri_host, pattern_host): + return False + + # Port must match (with * wildcard support) + if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): + return False + + # Path must match (with fnmatch wildcards) + return _match_path(uri_parsed.path, pattern_parsed.path) def validate_redirect_uri( diff --git a/src/fastmcp/server/auth/ssrf.py b/src/fastmcp/server/auth/ssrf.py new file mode 100644 index 000000000..8009269c6 --- /dev/null +++ b/src/fastmcp/server/auth/ssrf.py @@ -0,0 +1,307 @@ +"""SSRF-safe HTTP utilities for FastMCP. + +This module provides SSRF-protected HTTP fetching with: +- DNS resolution and IP validation before requests +- DNS pinning to prevent rebinding TOCTOU attacks +- Support for both CIMD and JWKS fetches +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import socket +import time +from dataclasses import dataclass +from urllib.parse import urlparse + +import httpx + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +def format_ip_for_url(ip_str: str) -> str: + """Format IP address for use in URL (bracket IPv6 addresses). + + IPv6 addresses must be bracketed in URLs to distinguish the address from + the port separator. For example: https://[2001:db8::1]:443/path + + Args: + ip_str: IP address string + + Returns: + IP string suitable for URL (IPv6 addresses are bracketed) + """ + try: + ip = ipaddress.ip_address(ip_str) + if isinstance(ip, ipaddress.IPv6Address): + return f"[{ip_str}]" + return ip_str + except ValueError: + return ip_str + + +class SSRFError(Exception): + """Raised when an SSRF protection check fails.""" + + +class SSRFFetchError(Exception): + """Raised when SSRF-safe fetch fails.""" + + +def is_ip_allowed(ip_str: str) -> bool: + """Check if an IP address is allowed (must be globally routable unicast). + + Uses ip.is_global which catches: + - Private (10.x, 172.16-31.x, 192.168.x) + - Loopback (127.x, ::1) + - Link-local (169.254.x, fe80::) - includes AWS metadata! + - Reserved, unspecified + - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks + + Additionally blocks multicast addresses (not caught by is_global). + + Args: + ip_str: IP address string to check + + Returns: + True if the IP is allowed (public unicast internet), False if blocked + """ + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return False + + if not ip.is_global: + return False + + # Block multicast (not caught by is_global for some ranges) + if ip.is_multicast: + return False + + # IPv6-specific checks for embedded IPv4 addresses + if isinstance(ip, ipaddress.IPv6Address): + if ip.ipv4_mapped: + return is_ip_allowed(str(ip.ipv4_mapped)) + if ip.sixtofour: + return is_ip_allowed(str(ip.sixtofour)) + if ip.teredo: + server, client = ip.teredo + return is_ip_allowed(str(server)) and is_ip_allowed(str(client)) + + return True + + +async def resolve_hostname(hostname: str, port: int = 443) -> list[str]: + """Resolve hostname to IP addresses using DNS. + + Args: + hostname: Hostname to resolve + port: Port number (used for getaddrinfo) + + Returns: + List of resolved IP addresses + + Raises: + SSRFError: If resolution fails + """ + loop = asyncio.get_running_loop() + try: + infos = await loop.run_in_executor( + None, + lambda: socket.getaddrinfo( + hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM + ), + ) + ips = list({info[4][0] for info in infos}) + if not ips: + raise SSRFError(f"DNS resolution returned no addresses for {hostname}") + return ips + except socket.gaierror as e: + raise SSRFError(f"DNS resolution failed for {hostname}: {e}") from e + + +@dataclass +class ValidatedURL: + """A URL that has been validated for SSRF with resolved IPs.""" + + original_url: str + hostname: str + port: int + path: str + resolved_ips: list[str] + + +async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: + """Validate URL for SSRF and resolve to IPs. + + Args: + url: URL to validate + require_path: If True, require non-root path (for CIMD) + + Returns: + ValidatedURL with resolved IPs + + Raises: + SSRFError: If URL is invalid or resolves to blocked IPs + """ + try: + parsed = urlparse(url) + except (ValueError, AttributeError) as e: + raise SSRFError(f"Invalid URL: {e}") from e + + if parsed.scheme != "https": + raise SSRFError(f"URL must use HTTPS, got: {parsed.scheme}") + + if not parsed.netloc: + raise SSRFError("URL must have a host") + + if require_path and parsed.path in ("", "/"): + raise SSRFError("URL must have a non-root path") + + hostname = parsed.hostname or parsed.netloc + port = parsed.port or 443 + + # Resolve and validate IPs + resolved_ips = await resolve_hostname(hostname, port) + + blocked = [ip for ip in resolved_ips if not is_ip_allowed(ip)] + if blocked: + raise SSRFError( + f"URL resolves to blocked IP address(es): {blocked}. " + f"Private, loopback, link-local, and reserved IPs are not allowed." + ) + + return ValidatedURL( + original_url=url, + hostname=hostname, + port=port, + path=parsed.path + ("?" + parsed.query if parsed.query else ""), + resolved_ips=resolved_ips, + ) + + +async def ssrf_safe_fetch( + url: str, + *, + require_path: bool = False, + max_size: int = 5120, + timeout: float = 10.0, + overall_timeout: float = 30.0, +) -> bytes: + """Fetch URL with comprehensive SSRF protection and DNS pinning. + + Security measures: + 1. HTTPS only + 2. DNS resolution with IP validation + 3. Connects to validated IP directly (DNS pinning prevents rebinding) + 4. Response size limit + 5. Redirects disabled + 6. Overall timeout + + Args: + url: URL to fetch + require_path: If True, require non-root path + max_size: Maximum response size in bytes (default 5KB) + timeout: Per-operation timeout in seconds + overall_timeout: Overall timeout for entire operation + + Returns: + Response body as bytes + + Raises: + SSRFError: If SSRF validation fails + SSRFFetchError: If fetch fails + """ + start_time = time.monotonic() + + # Validate URL and resolve DNS + validated = await validate_url(url, require_path=require_path) + + last_error: Exception | None = None + + for pinned_ip in validated.resolved_ips: + elapsed = time.monotonic() - start_time + if elapsed > overall_timeout: + raise SSRFFetchError(f"Overall timeout exceeded: {url}") + remaining = max(1.0, overall_timeout - elapsed) + + pinned_url = ( + f"https://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}" + ) + + logger.debug( + "SSRF-safe fetch: %s -> %s (pinned to %s)", + url, + pinned_url, + pinned_ip, + ) + + try: + # Use httpx with streaming to enforce size limit during download + async with ( + httpx.AsyncClient( + timeout=httpx.Timeout( + connect=min(timeout, remaining), + read=min(timeout, remaining), + write=min(timeout, remaining), + pool=min(timeout, remaining), + ), + follow_redirects=False, + verify=True, + ) as client, + client.stream( + "GET", + pinned_url, + headers={"Host": validated.hostname}, + extensions={"sni_hostname": validated.hostname}, + ) as response, + ): + if time.monotonic() - start_time > overall_timeout: + raise SSRFFetchError(f"Overall timeout exceeded: {url}") + + if response.status_code != 200: + raise SSRFFetchError(f"HTTP {response.status_code} fetching {url}") + + # Check Content-Length header first if available + content_length = response.headers.get("content-length") + if content_length: + try: + size = int(content_length) + if size > max_size: + raise SSRFFetchError( + f"Response too large: {size} bytes (max {max_size})" + ) + except ValueError: + pass + + # Stream the response and enforce size limit during download + chunks = [] + total = 0 + async for chunk in response.aiter_bytes(): + if time.monotonic() - start_time > overall_timeout: + raise SSRFFetchError(f"Overall timeout exceeded: {url}") + total += len(chunk) + if total > max_size: + raise SSRFFetchError( + f"Response too large: exceeded {max_size} bytes" + ) + chunks.append(chunk) + + return b"".join(chunks) + + except httpx.TimeoutException as e: + last_error = e + continue + except httpx.RequestError as e: + last_error = e + continue + + if last_error is not None: + if isinstance(last_error, httpx.TimeoutException): + raise SSRFFetchError(f"Timeout fetching {url}") from last_error + raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error + + raise SSRFFetchError(f"Error fetching {url}: no resolved IPs succeeded") diff --git a/tests/cli/test_cimd_cli.py b/tests/cli/test_cimd_cli.py new file mode 100644 index 000000000..301c440ed --- /dev/null +++ b/tests/cli/test_cimd_cli.py @@ -0,0 +1,208 @@ +"""Tests for the CIMD CLI commands (create and validate).""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import AnyHttpUrl + +from fastmcp.cli.cimd import create_command, validate_command +from fastmcp.server.auth.cimd import CIMDDocument, CIMDFetchError, CIMDValidationError + + +class TestCIMDCreateCommand: + """Tests for `fastmcp auth cimd create`.""" + + def test_minimal_output(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + ) + doc = json.loads(capsys.readouterr().out) + assert doc["client_name"] == "Test App" + assert doc["redirect_uris"] == ["http://localhost:*/callback"] + assert doc["token_endpoint_auth_method"] == "none" + assert doc["grant_types"] == ["authorization_code"] + assert doc["response_types"] == ["code"] + # Placeholder client_id + assert "YOUR-DOMAIN" in doc["client_id"] + + def test_with_client_id(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_id="https://myapp.example.com/client.json", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["client_id"] == "https://myapp.example.com/client.json" + + def test_with_output_file(self, tmp_path): + output_file = tmp_path / "client.json" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_id="https://example.com/client.json", + output=str(output_file), + ) + doc = json.loads(output_file.read_text()) + assert doc["client_id"] == "https://example.com/client.json" + assert doc["client_name"] == "Test App" + + def test_relative_path_resolved(self, tmp_path, monkeypatch): + """Relative paths should be resolved against cwd.""" + monkeypatch.chdir(tmp_path) + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + output="./subdir/client.json", + ) + resolved = tmp_path / "subdir" / "client.json" + assert resolved.exists() + doc = json.loads(resolved.read_text()) + assert doc["client_name"] == "Test App" + + def test_with_scope(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + scope="read write", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["scope"] == "read write" + + def test_with_client_uri(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_uri="https://example.com", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["client_uri"] == "https://example.com" + + def test_with_logo_uri(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + logo_uri="https://example.com/logo.png", + ) + doc = json.loads(capsys.readouterr().out) + assert doc["logo_uri"] == "https://example.com/logo.png" + + def test_multiple_redirect_uris(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=[ + "http://localhost:*/callback", + "https://myapp.example.com/callback", + ], + ) + doc = json.loads(capsys.readouterr().out) + assert len(doc["redirect_uris"]) == 2 + + def test_no_pretty(self, capsys: pytest.CaptureFixture[str]): + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + pretty=False, + ) + output = capsys.readouterr().out.strip() + # Compact JSON has no newlines within the object + assert "\n" not in output + doc = json.loads(output) + assert doc["client_name"] == "Test App" + + def test_placeholder_warning_on_stderr(self, capsys: pytest.CaptureFixture[str]): + """When outputting to stdout with no --client-id, warning goes to stderr.""" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + ) + captured = capsys.readouterr() + # stdout has valid JSON + json.loads(captured.out) + # stderr has the warning (Rich Console writes to stderr) + assert "placeholder" in captured.err + + def test_no_warning_with_client_id(self, capsys: pytest.CaptureFixture[str]): + """No placeholder warning when --client-id is provided.""" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + client_id="https://example.com/client.json", + ) + captured = capsys.readouterr() + assert "placeholder" not in captured.err + + def test_optional_fields_omitted_when_none( + self, capsys: pytest.CaptureFixture[str] + ): + """Optional fields like scope, client_uri, logo_uri are omitted if not given.""" + create_command( + name="Test App", + redirect_uri=["http://localhost:*/callback"], + ) + doc = json.loads(capsys.readouterr().out) + assert "scope" not in doc + assert "client_uri" not in doc + assert "logo_uri" not in doc + + +class TestCIMDValidateCommand: + """Tests for `fastmcp auth cimd validate`.""" + + def test_invalid_url_format(self, capsys: pytest.CaptureFixture[str]): + with pytest.raises(SystemExit, match="1"): + validate_command("http://insecure.com/client.json") + captured = capsys.readouterr() + assert "Invalid CIMD URL" in captured.out + + def test_root_path_rejected(self, capsys: pytest.CaptureFixture[str]): + with pytest.raises(SystemExit, match="1"): + validate_command("https://example.com/") + captured = capsys.readouterr() + assert "Invalid CIMD URL" in captured.out + + def test_success(self, capsys: pytest.CaptureFixture[str]): + mock_doc = CIMDDocument( + client_id=AnyHttpUrl("https://myapp.example.com/client.json"), + client_name="Test App", + redirect_uris=["http://localhost:*/callback"], + token_endpoint_auth_method="none", + grant_types=["authorization_code"], + response_types=["code"], + ) + with patch.object(CIMDDocument, "__init__", return_value=None): + pass + mock_fetch = AsyncMock(return_value=mock_doc) + with patch( + "fastmcp.cli.cimd.CIMDFetcher.fetch", + mock_fetch, + ): + validate_command("https://myapp.example.com/client.json") + captured = capsys.readouterr() + assert "Valid CIMD document" in captured.out + assert "Test App" in captured.out + + def test_fetch_error(self, capsys: pytest.CaptureFixture[str]): + mock_fetch = AsyncMock(side_effect=CIMDFetchError("Connection refused")) + with patch( + "fastmcp.cli.cimd.CIMDFetcher.fetch", + mock_fetch, + ): + with pytest.raises(SystemExit, match="1"): + validate_command("https://myapp.example.com/client.json") + captured = capsys.readouterr() + assert "Failed to fetch" in captured.out + + def test_validation_error(self, capsys: pytest.CaptureFixture[str]): + mock_fetch = AsyncMock(side_effect=CIMDValidationError("client_id mismatch")) + with patch( + "fastmcp.cli.cimd.CIMDFetcher.fetch", + mock_fetch, + ): + with pytest.raises(SystemExit, match="1"): + validate_command("https://myapp.example.com/client.json") + captured = capsys.readouterr() + assert "Validation error" in captured.out diff --git a/tests/client/auth/test_oauth_cimd.py b/tests/client/auth/test_oauth_cimd.py new file mode 100644 index 000000000..04818af60 --- /dev/null +++ b/tests/client/auth/test_oauth_cimd.py @@ -0,0 +1,164 @@ +"""Tests for CIMD (Client ID Metadata Document) support in the OAuth client.""" + +from __future__ import annotations + +import warnings + +import httpx +import pytest + +from fastmcp.client.auth import OAuth +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.client.transports.sse import SSETransport + +VALID_CIMD_URL = "https://myapp.example.com/oauth/client.json" +MCP_SERVER_URL = "https://mcp-server.example.com/mcp" + + +class TestOAuthClientMetadataURL: + """Tests for the client_metadata_url parameter on OAuth.""" + + def test_stored_on_instance(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._client_metadata_url == VALID_CIMD_URL + + def test_none_by_default(self): + oauth = OAuth() + assert oauth._client_metadata_url is None + + def test_passed_to_parent_on_bind(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + oauth._bind(MCP_SERVER_URL) + assert oauth.context.client_metadata_url == VALID_CIMD_URL + + def test_none_metadata_url_on_parent(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth(mcp_url=MCP_SERVER_URL) + assert oauth.context.client_metadata_url is None + + def test_unbound_when_no_mcp_url(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + + def test_bound_when_mcp_url_provided(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth( + mcp_url=MCP_SERVER_URL, + client_metadata_url=VALID_CIMD_URL, + ) + assert oauth._bound is True + + def test_invalid_cimd_url_rejected(self): + """CIMD URLs must be HTTPS with a non-root path.""" + with pytest.raises(ValueError, match="valid HTTPS URL"): + OAuth( + mcp_url=MCP_SERVER_URL, + client_metadata_url="http://insecure.com/client.json", + ) + + def test_root_path_cimd_url_rejected(self): + with pytest.raises(ValueError, match="valid HTTPS URL"): + OAuth( + mcp_url=MCP_SERVER_URL, + client_metadata_url="https://example.com/", + ) + + +class TestOAuthBind: + """Tests for the _bind() deferred initialization.""" + + def test_bind_sets_bound_true(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL) + assert oauth._bound is True + + def test_bind_idempotent(self): + """Second call to _bind is a no-op.""" + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL) + oauth._bind("https://other-server.example.com/mcp") + # First binding wins + assert oauth.mcp_url == MCP_SERVER_URL + + def test_bind_sets_mcp_url(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL + "/") + # Trailing slash stripped + assert oauth.mcp_url == MCP_SERVER_URL + + def test_bind_creates_token_storage(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert not hasattr(oauth, "token_storage_adapter") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth._bind(MCP_SERVER_URL) + assert hasattr(oauth, "token_storage_adapter") + + async def test_unbound_raises_runtime_error(self): + """async_auth_flow should fail clearly when OAuth is not bound.""" + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + request = httpx.Request("GET", MCP_SERVER_URL) + with pytest.raises(RuntimeError, match="no server URL"): + async for _ in oauth.async_auth_flow(request): + pass + + def test_scopes_forwarded_as_list(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth( + client_metadata_url=VALID_CIMD_URL, + scopes=["read", "write"], + ) + oauth._bind(MCP_SERVER_URL) + assert oauth.context.client_metadata.scope == "read write" + + def test_scopes_forwarded_as_string(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + oauth = OAuth( + client_metadata_url=VALID_CIMD_URL, + scopes="read write", + ) + oauth._bind(MCP_SERVER_URL) + assert oauth.context.client_metadata.scope == "read write" + + +class TestOAuthBindFromTransport: + """Tests that transports call _bind() on OAuth instances.""" + + def test_http_transport_binds_oauth(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + StreamableHttpTransport(MCP_SERVER_URL, auth=oauth) + assert oauth._bound is True + assert oauth.mcp_url == MCP_SERVER_URL + + def test_sse_transport_binds_oauth(self): + oauth = OAuth(client_metadata_url=VALID_CIMD_URL) + assert oauth._bound is False + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + SSETransport(MCP_SERVER_URL, auth=oauth) + assert oauth._bound is True + assert oauth.mcp_url == MCP_SERVER_URL + + def test_http_transport_oauth_string_still_works(self): + """auth="oauth" should still create a new OAuth instance.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + transport = StreamableHttpTransport(MCP_SERVER_URL, auth="oauth") + assert isinstance(transport.auth, OAuth) + assert transport.auth._bound is True diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index 27c31e198..b605e50fd 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -1,6 +1,8 @@ """Tests for OAuth proxy initialization and configuration.""" +import httpx from key_value.aio.stores.memory import MemoryStore +from starlette.applications import Starlette from fastmcp.server.auth.oauth_proxy import OAuthProxy @@ -72,3 +74,29 @@ class TestOAuthProxyInitialization: client_storage=MemoryStore(), ) assert proxy._redirect_path == "/auth/callback" + + async def test_metadata_advertises_cimd_support(self, jwt_verifier): + """OAuth metadata should advertise CIMD support when enabled.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + upstream_client_secret="secret-456", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + enable_cimd=True, + ) + + app = Starlette(routes=proxy.get_routes()) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, base_url="https://api.example.com" + ) as client: + response = await client.get("/.well-known/oauth-authorization-server") + + assert response.status_code == 200 + metadata = response.json() + assert metadata.get("client_id_metadata_document_supported") is True diff --git a/tests/server/auth/test_cimd.py b/tests/server/auth/test_cimd.py new file mode 100644 index 000000000..d3c3e316e --- /dev/null +++ b/tests/server/auth/test_cimd.py @@ -0,0 +1,971 @@ +"""Unit tests for CIMD (Client ID Metadata Document) functionality.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import AnyHttpUrl, ValidationError + +from fastmcp.server.auth.cimd import ( + CIMDAssertionValidator, + CIMDClientManager, + CIMDDocument, + CIMDFetcher, + CIMDFetchError, + CIMDValidationError, +) +from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient + +# Standard public IP used for DNS mocking in tests +TEST_PUBLIC_IP = "93.184.216.34" + + +class TestCIMDDocument: + """Tests for CIMDDocument model validation.""" + + def test_valid_minimal_document(self): + """Test that minimal valid document passes validation.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + assert str(doc.client_id) == "https://example.com/client.json" + assert doc.token_endpoint_auth_method == "none" + assert doc.grant_types == ["authorization_code"] + assert doc.response_types == ["code"] + + def test_valid_full_document(self): + """Test that full document passes validation.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + client_name="My App", + client_uri=AnyHttpUrl("https://example.com"), + logo_uri=AnyHttpUrl("https://example.com/logo.png"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="none", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="read write", + ) + assert doc.client_name == "My App" + assert doc.scope == "read write" + + def test_private_key_jwt_auth_method_allowed(self): + """Test that private_key_jwt is allowed for CIMD.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"), + ) + assert doc.token_endpoint_auth_method == "private_key_jwt" + + def test_client_secret_basic_rejected(self): + """Test that client_secret_basic is rejected for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="client_secret_basic", # type: ignore[arg-type] - testing invalid value + ) + # Literal type rejects invalid values before custom validator + assert "token_endpoint_auth_method" in str(exc_info.value) + + def test_client_secret_post_rejected(self): + """Test that client_secret_post is rejected for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="client_secret_post", # type: ignore[arg-type] - testing invalid value + ) + assert "token_endpoint_auth_method" in str(exc_info.value) + + def test_client_secret_jwt_rejected(self): + """Test that client_secret_jwt is rejected for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="client_secret_jwt", # type: ignore[arg-type] - testing invalid value + ) + assert "token_endpoint_auth_method" in str(exc_info.value) + + def test_missing_redirect_uris_rejected(self): + """Test that redirect_uris is required for CIMD.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument(client_id=AnyHttpUrl("https://example.com/client.json")) + assert "redirect_uris" in str(exc_info.value) + + def test_empty_redirect_uris_rejected(self): + """Test that empty redirect_uris is rejected.""" + with pytest.raises(ValidationError) as exc_info: + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=[], + ) + assert "redirect_uris" in str(exc_info.value) + + def test_redirect_uri_without_scheme_rejected(self): + """Test that redirect_uris without a scheme are rejected.""" + with pytest.raises(ValidationError, match="must have a scheme"): + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["/just/a/path"], + ) + + def test_redirect_uri_without_host_rejected(self): + """Test that redirect_uris without a host are rejected.""" + with pytest.raises(ValidationError, match="must have a host"): + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://"], + ) + + def test_redirect_uri_whitespace_only_rejected(self): + """Test that whitespace-only redirect_uris are rejected.""" + with pytest.raises(ValidationError, match="non-empty"): + CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=[" "], + ) + + +class TestCIMDFetcher: + """Tests for CIMDFetcher.""" + + @pytest.fixture + def fetcher(self): + """Create a CIMDFetcher for testing.""" + return CIMDFetcher() + + def test_is_cimd_client_id_valid_urls(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id accepts valid CIMD URLs.""" + assert fetcher.is_cimd_client_id("https://example.com/client.json") + assert fetcher.is_cimd_client_id("https://example.com/path/to/client") + assert fetcher.is_cimd_client_id("https://sub.example.com/cimd.json") + + def test_is_cimd_client_id_rejects_http(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id rejects HTTP URLs.""" + assert not fetcher.is_cimd_client_id("http://example.com/client.json") + + def test_is_cimd_client_id_rejects_root_path(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id rejects URLs with no path.""" + assert not fetcher.is_cimd_client_id("https://example.com/") + assert not fetcher.is_cimd_client_id("https://example.com") + + def test_is_cimd_client_id_rejects_non_url(self, fetcher: CIMDFetcher): + """Test is_cimd_client_id rejects non-URL strings.""" + assert not fetcher.is_cimd_client_id("client-123") + assert not fetcher.is_cimd_client_id("my-client") + assert not fetcher.is_cimd_client_id("") + assert not fetcher.is_cimd_client_id("not a url") + + def test_validate_redirect_uri_exact_match(self, fetcher: CIMDFetcher): + """Test redirect_uri validation with exact match.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback") + assert not fetcher.validate_redirect_uri(doc, "http://localhost:4000/callback") + + def test_validate_redirect_uri_wildcard_match(self, fetcher: CIMDFetcher): + """Test redirect_uri validation with wildcard port.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:*/callback"], + ) + assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback") + assert fetcher.validate_redirect_uri(doc, "http://localhost:8080/callback") + assert not fetcher.validate_redirect_uri(doc, "http://localhost:3000/other") + + +class TestCIMDFetcherHTTP: + """Tests for CIMDFetcher HTTP fetching (using httpx mock). + + Note: With SSRF protection and DNS pinning, HTTP requests go to the resolved IP + instead of the hostname. These tests mock DNS resolution to return a public IP + and configure httpx_mock to expect the IP-based URL. + """ + + @pytest.fixture + def fetcher(self): + """Create a CIMDFetcher for testing.""" + return CIMDFetcher() + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_fetch_success(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test successful CIMD document fetch.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + + # With DNS pinning, request goes to IP. Match any URL. + httpx_mock.add_response( + json=doc_data, + headers={ + "content-type": "application/json", + "content-length": "200", + }, + ) + + doc = await fetcher.fetch(url) + assert str(doc.client_id) == url + assert doc.client_name == "Test App" + + async def test_fetch_ttl_cache(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test that fetched documents are cached and served from cache within TTL.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + + assert first.client_id == second.client_id + assert len(httpx_mock.get_requests()) == 1 + + async def test_fetch_client_id_mismatch( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Test that client_id mismatch is rejected.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": "https://other.com/client.json", # Different URL + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "100"}, + ) + + with pytest.raises(CIMDValidationError) as exc_info: + await fetcher.fetch(url) + assert "mismatch" in str(exc_info.value).lower() + + async def test_fetch_http_error(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test handling of HTTP errors.""" + url = "https://example.com/client.json" + httpx_mock.add_response(status_code=404) + + with pytest.raises(CIMDFetchError) as exc_info: + await fetcher.fetch(url) + assert "404" in str(exc_info.value) + + async def test_fetch_invalid_json(self, fetcher: CIMDFetcher, httpx_mock, mock_dns): + """Test handling of invalid JSON response.""" + url = "https://example.com/client.json" + httpx_mock.add_response( + content=b"not json", + headers={"content-length": "10"}, + ) + + with pytest.raises(CIMDValidationError) as exc_info: + await fetcher.fetch(url) + assert "JSON" in str(exc_info.value) + + async def test_fetch_invalid_document( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Test handling of invalid CIMD document.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "client_secret_basic", # Not allowed + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "100"}, + ) + + with pytest.raises(CIMDValidationError) as exc_info: + await fetcher.fetch(url) + assert "Invalid CIMD document" in str(exc_info.value) + + +class TestCIMDAssertionValidator: + """Tests for CIMDAssertionValidator (private_key_jwt support).""" + + @pytest.fixture + def validator(self): + """Create a CIMDAssertionValidator for testing.""" + return CIMDAssertionValidator() + + @pytest.fixture + def key_pair(self): + """Generate RSA key pair for testing.""" + from fastmcp.server.auth.providers.jwt import RSAKeyPair + + return RSAKeyPair.generate() + + @pytest.fixture + def jwks(self, key_pair): + """Create JWKS from key pair.""" + import base64 + + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + + # Load public key + public_key = serialization.load_pem_public_key( + key_pair.public_key.encode(), backend=default_backend() + ) + + # Get RSA public numbers + from cryptography.hazmat.primitives.asymmetric import rsa + + if isinstance(public_key, rsa.RSAPublicKey): + numbers = public_key.public_numbers() + + # Convert to JWK format + return { + "keys": [ + { + "kty": "RSA", + "kid": "test-key-1", + "use": "sig", + "alg": "RS256", + "n": base64.urlsafe_b64encode( + numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + "e": base64.urlsafe_b64encode( + numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + } + ] + } + + @pytest.fixture + def cimd_doc_with_jwks_uri(self): + """Create CIMD document with jwks_uri.""" + return CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"), + ) + + @pytest.fixture + def cimd_doc_with_inline_jwks(self, jwks): + """Create CIMD document with inline JWKS.""" + return CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks=jwks, + ) + + async def test_valid_assertion_with_jwks_uri( + self, validator, key_pair, cimd_doc_with_jwks_uri, httpx_mock + ): + """Test that valid JWT assertion passes validation (jwks_uri).""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Mock JWKS endpoint + import base64 + + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + + public_key = serialization.load_pem_public_key( + key_pair.public_key.encode(), backend=default_backend() + ) + from cryptography.hazmat.primitives.asymmetric import rsa + + assert isinstance(public_key, rsa.RSAPublicKey) + numbers = public_key.public_numbers() + + jwks = { + "keys": [ + { + "kty": "RSA", + "kid": "test-key-1", + "use": "sig", + "alg": "RS256", + "n": base64.urlsafe_b64encode( + numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + "e": base64.urlsafe_b64encode( + numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode(), + } + ] + } + + # Mock DNS resolution for SSRF-safe fetch + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + httpx_mock.add_response(json=jwks) + + # Create valid assertion (use short lifetime for security compliance) + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "unique-jti-123"}, + expires_in_seconds=60, # 1 minute (max allowed is 300s) + kid="test-key-1", + ) + + # Should validate successfully + assert await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_jwks_uri + ) + + async def test_valid_assertion_with_inline_jwks( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that valid JWT assertion passes validation (inline JWKS).""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create valid assertion (use short lifetime for security compliance) + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "unique-jti-456"}, + expires_in_seconds=60, # 1 minute (max allowed is 300s) + kid="test-key-1", + ) + + # Should validate successfully + assert await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + + async def test_rejects_wrong_issuer( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that wrong issuer is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion with wrong issuer + assertion = key_pair.create_token( + subject=client_id, + issuer="https://attacker.com", # Wrong! + audience=token_endpoint, + additional_claims={"jti": "unique-jti-789"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "Invalid JWT assertion" in str(exc_info.value) + + async def test_rejects_wrong_audience( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that wrong audience is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion with wrong audience + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience="https://wrong-endpoint.com/token", # Wrong! + additional_claims={"jti": "unique-jti-abc"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "Invalid JWT assertion" in str(exc_info.value) + + async def test_rejects_wrong_subject( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that wrong subject claim is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion with wrong subject + assertion = key_pair.create_token( + subject="https://different-client.com", # Wrong! + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "unique-jti-def"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "sub claim must be" in str(exc_info.value) + + async def test_rejects_missing_jti( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that missing jti claim is rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion without jti + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + # No jti! + expires_in_seconds=60, + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "jti claim" in str(exc_info.value) + + async def test_rejects_replayed_jti( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that replayed JTI is detected and rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create assertion + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "replayed-jti"}, + expires_in_seconds=60, + kid="test-key-1", + ) + + # First use should succeed + assert await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + + # Second use with same jti should fail (replay attack) + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "replay" in str(exc_info.value).lower() + + async def test_rejects_expired_token( + self, validator, key_pair, cimd_doc_with_inline_jwks + ): + """Test that expired tokens are rejected.""" + client_id = "https://example.com/client.json" + token_endpoint = "https://oauth.example.com/token" + + # Create expired assertion (expired 1 hour ago) + assertion = key_pair.create_token( + subject=client_id, + issuer=client_id, + audience=token_endpoint, + additional_claims={"jti": "expired-jti"}, + expires_in_seconds=-3600, # Negative = expired + kid="test-key-1", + ) + + with pytest.raises(ValueError) as exc_info: + await validator.validate_assertion( + assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks + ) + assert "Invalid JWT assertion" in str(exc_info.value) + + +class TestCIMDClientManager: + """Tests for CIMDClientManager.""" + + @pytest.fixture + def manager(self): + """Create a CIMDClientManager for testing.""" + return CIMDClientManager(enable_cimd=True) + + @pytest.fixture + def disabled_manager(self): + """Create a disabled CIMDClientManager for testing.""" + return CIMDClientManager(enable_cimd=False) + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + def test_is_cimd_client_id_enabled(self, manager): + """Test CIMD URL detection when enabled.""" + assert manager.is_cimd_client_id("https://example.com/client.json") + assert not manager.is_cimd_client_id("regular-client-id") + + def test_is_cimd_client_id_disabled(self, disabled_manager): + """Test CIMD URL detection when disabled.""" + assert not disabled_manager.is_cimd_client_id("https://example.com/client.json") + assert not disabled_manager.is_cimd_client_id("regular-client-id") + + async def test_get_client_success(self, manager, httpx_mock, mock_dns): + """Test successful CIMD client creation.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + client = await manager.get_client(url) + assert client is not None + assert client.client_id == url + assert client.client_name == "Test App" + # Verify it uses proxy's patterns (None by default), not document's redirect_uris + assert client.allowed_redirect_uri_patterns is None + + async def test_get_client_disabled(self, disabled_manager): + """Test that get_client returns None when disabled.""" + client = await disabled_manager.get_client("https://example.com/client.json") + assert client is None + + async def test_get_client_fetch_failure(self, manager, httpx_mock, mock_dns): + """Test that get_client returns None on fetch failure.""" + url = "https://example.com/client.json" + httpx_mock.add_response(status_code=404) + + client = await manager.get_client(url) + assert client is None + + # Trust policy and consent bypass tests removed - functionality removed from CIMD + + +class TestCIMDClientManagerGetClientOptions: + """Tests for CIMDClientManager.get_client with default_scope and allowed patterns.""" + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_default_scope_applied_when_doc_has_no_scope( + self, httpx_mock, mock_dns + ): + """When the CIMD document omits scope, the manager's default_scope is used.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + # No scope field + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + manager = CIMDClientManager( + enable_cimd=True, + default_scope="read write admin", + ) + client = await manager.get_client(url) + assert client is not None + assert client.scope == "read write admin" + + async def test_doc_scope_takes_precedence_over_default(self, httpx_mock, mock_dns): + """When the CIMD document specifies scope, it wins over the default.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + "scope": "custom-scope", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + manager = CIMDClientManager( + enable_cimd=True, + default_scope="default-scope", + ) + client = await manager.get_client(url) + assert client is not None + assert client.scope == "custom-scope" + + async def test_allowed_redirect_uri_patterns_stored_on_client( + self, httpx_mock, mock_dns + ): + """Proxy's allowed_redirect_uri_patterns are forwarded to the created client.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + patterns = ["http://localhost:*", "https://app.example.com/*"] + manager = CIMDClientManager( + enable_cimd=True, + allowed_redirect_uri_patterns=patterns, + ) + client = await manager.get_client(url) + assert client is not None + assert client.allowed_redirect_uri_patterns == patterns + + async def test_cimd_document_attached_to_client(self, httpx_mock, mock_dns): + """The fetched CIMDDocument is attached to the created client.""" + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Attached Doc App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + manager = CIMDClientManager(enable_cimd=True) + client = await manager.get_client(url) + assert client is not None + assert client.cimd_document is not None + assert client.cimd_document.client_name == "Attached Doc App" + assert str(client.cimd_document.client_id) == url + + +class TestCIMDClientManagerValidatePrivateKeyJwt: + """Tests for CIMDClientManager.validate_private_key_jwt wrapper.""" + + @pytest.fixture + def manager(self): + return CIMDClientManager(enable_cimd=True) + + async def test_missing_cimd_document_raises(self, manager): + """validate_private_key_jwt raises ValueError if client has no cimd_document.""" + + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=None, + ) + with pytest.raises(ValueError, match="must have CIMD document"): + await manager.validate_private_key_jwt( + "fake.jwt.token", + client, + "https://oauth.example.com/token", + ) + + async def test_wrong_auth_method_raises(self, manager): + """validate_private_key_jwt raises ValueError if auth method is not private_key_jwt.""" + + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="none", # Not private_key_jwt + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + with pytest.raises(ValueError, match="private_key_jwt"): + await manager.validate_private_key_jwt( + "fake.jwt.token", + client, + "https://oauth.example.com/token", + ) + + async def test_success_delegates_to_assertion_validator(self, manager): + """On success, validate_private_key_jwt delegates to the assertion validator.""" + + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + token_endpoint_auth_method="private_key_jwt", + jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"), + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + + manager._assertion_validator.validate_assertion = AsyncMock(return_value=True) + + result = await manager.validate_private_key_jwt( + "test.jwt.assertion", + client, + "https://oauth.example.com/token", + ) + assert result is True + manager._assertion_validator.validate_assertion.assert_awaited_once_with( + "test.jwt.assertion", + "https://example.com/client.json", + "https://oauth.example.com/token", + cimd_doc, + ) + + +class TestCIMDRedirectUriEnforcement: + """Tests for CIMD redirect_uri validation security. + + Verifies that CIMD clients enforce BOTH: + 1. CIMD document's redirect_uris + 2. Proxy's allowed_redirect_uri_patterns + """ + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_cimd_redirect_uris_enforced(self, httpx_mock, mock_dns): + """Test that CIMD document redirect_uris are enforced. + + Even if proxy patterns allow http://localhost:*, a CIMD client + should only accept URIs declared in its document. + """ + from mcp.shared.auth import InvalidRedirectUriError + from pydantic import AnyUrl + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + # CIMD only declares port 3000 + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + # Proxy allows any localhost port + manager = CIMDClientManager( + enable_cimd=True, + allowed_redirect_uri_patterns=["http://localhost:*"], + ) + client = await manager.get_client(url) + assert client is not None + + # Declared URI should work + validated = client.validate_redirect_uri( + AnyUrl("http://localhost:3000/callback") + ) + assert str(validated) == "http://localhost:3000/callback" + + # Different port should fail (not in CIMD redirect_uris) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:4000/callback")) + + async def test_proxy_patterns_also_checked(self, httpx_mock, mock_dns): + """Test that proxy patterns are checked even for CIMD clients. + + A CIMD client should not be able to use a redirect_uri that's + in its document but not allowed by proxy patterns. + """ + from mcp.shared.auth import InvalidRedirectUriError + from pydantic import AnyUrl + + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Test App", + # CIMD declares both localhost and external URI + "redirect_uris": [ + "http://localhost:3000/callback", + "https://evil.com/callback", + ], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + # Proxy only allows localhost + manager = CIMDClientManager( + enable_cimd=True, + allowed_redirect_uri_patterns=["http://localhost:*"], + ) + client = await manager.get_client(url) + assert client is not None + + # Localhost should work (in CIMD and matches pattern) + validated = client.validate_redirect_uri( + AnyUrl("http://localhost:3000/callback") + ) + assert str(validated) == "http://localhost:3000/callback" + + # Evil.com should fail (in CIMD but doesn't match proxy patterns) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("https://evil.com/callback")) diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 14a81299e..bced42a1f 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -1,5 +1,6 @@ from collections.abc import AsyncGenerator from typing import Any +from unittest.mock import patch import httpx import pytest @@ -10,6 +11,9 @@ from fastmcp.client.auth.bearer import BearerAuth from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair from fastmcp.utilities.tests import run_server_async +# Standard public IP used for DNS mocking in tests +TEST_PUBLIC_IP = "93.184.216.34" + class SymmetricKeyHelper: """Helper class for generating symmetric key JWT tokens for testing.""" @@ -378,7 +382,11 @@ class TestSymmetricKeyJWT: class TestBearerTokenJWKS: - """Tests for JWKS URI functionality.""" + """Tests for JWKS URI functionality. + + Note: With SSRF protection, JWKS fetches validate DNS and connect to the + resolved IP. Tests mock DNS resolution to return a public IP. + """ @pytest.fixture def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier: @@ -402,18 +410,25 @@ class TestBearerTokenJWKS: return {"keys": [jwk_data]} + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + async def test_jwks_token_validation( self, rsa_key_pair: RSAKeyPair, jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): """Test token validation using JWKS URI.""" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) username = "test-user" issuer = "https://test.example.com" @@ -440,11 +455,9 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = RSAKeyPair.generate().create_token( subject="test-user", issuer="https://test.example.com", @@ -460,12 +473,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -483,12 +494,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -505,12 +514,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -527,12 +534,10 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"][0]["kid"] = "test-key-1" - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -549,6 +554,7 @@ class TestBearerTokenJWKS: jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, + mock_dns, ): mock_jwks_data["keys"] = [ # type: ignore[typeddict-item] { @@ -561,10 +567,7 @@ class TestBearerTokenJWKS: }, ] - httpx_mock.add_response( - url="https://test.example.com/.well-known/jwks.json", - json=mock_jwks_data, - ) + httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 20d4afdd7..391977b88 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -1,14 +1,20 @@ """Tests for OAuth proxy redirect URI validation.""" +from unittest.mock import patch + import pytest from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import InvalidRedirectUriError -from pydantic import AnyUrl +from pydantic import AnyHttpUrl, AnyUrl from fastmcp.server.auth.auth import TokenVerifier +from fastmcp.server.auth.cimd import CIMDDocument from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient +# Standard public IP used for DNS mocking in tests +TEST_PUBLIC_IP = "93.184.216.34" + class MockTokenVerifier(TokenVerifier): """Mock token verifier for testing.""" @@ -133,6 +139,38 @@ class TestProxyDCRClient: result = client.validate_redirect_uri(None) assert result == AnyUrl("http://localhost:3000") + def test_cimd_none_redirect_uri_single_exact(self): + """CIMD clients may omit redirect_uri only when a single exact URI exists.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + + result = client.validate_redirect_uri(None) + assert result == AnyUrl("http://localhost:3000/callback") + + def test_cimd_none_redirect_uri_wildcard_rejected(self): + """CIMD clients must specify redirect_uri when only wildcard patterns exist.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:*/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(None) + class TestOAuthProxyRedirectValidation: """Test OAuth proxy with redirect URI validation.""" @@ -240,3 +278,90 @@ class TestOAuthProxyRedirectValidation: # Get an unregistered client client = await proxy.get_client("unknown-client") assert client is None + + +class TestOAuthProxyCIMDClient: + """Test that CIMD clients obtained via proxy carry their document and apply dual validation.""" + + @pytest.fixture + def mock_dns(self): + """Mock DNS resolution to return test public IP.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[TEST_PUBLIC_IP], + ): + yield + + async def test_proxy_get_client_returns_cimd_client(self, httpx_mock, mock_dns): + """CIMD client obtained via proxy's get_client has cimd_document attached.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "CIMD App", + "redirect_uris": ["http://localhost:*/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + client = await proxy.get_client(url) + assert isinstance(client, ProxyDCRClient) + assert client.cimd_document is not None + assert client.cimd_document.client_name == "CIMD App" + assert client.client_id == url + + async def test_proxy_cimd_dual_redirect_validation(self, httpx_mock, mock_dns): + """CIMD client from proxy enforces both CIMD redirect_uris and proxy patterns.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Dual Validation App", + "redirect_uris": [ + "http://localhost:3000/callback", + "https://evil.com/callback", + ], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"content-length": "200"}, + ) + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + allowed_client_redirect_uris=["http://localhost:*"], + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + client = await proxy.get_client(url) + assert client is not None + + # In CIMD AND matches proxy pattern → accepted + assert client.validate_redirect_uri(AnyUrl("http://localhost:3000/callback")) + + # In CIMD but NOT in proxy pattern → rejected + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("https://evil.com/callback")) + + # NOT in CIMD but matches proxy pattern → rejected + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:9999/other")) diff --git a/tests/server/auth/test_oauth_proxy_storage.py b/tests/server/auth/test_oauth_proxy_storage.py index cc1808c3f..7c898823e 100644 --- a/tests/server/auth/test_oauth_proxy_storage.py +++ b/tests/server/auth/test_oauth_proxy_storage.py @@ -112,7 +112,7 @@ class TestOAuthProxyStorage: async def test_proxy_dcr_client_redirect_validation( self, jwt_verifier: TokenVerifier, temp_storage: AsyncKeyValue ): - """Test that ProxyDCRClient is created with redirect URI patterns.""" + """Test that OAuthProxyClient is created with redirect URI patterns.""" proxy = OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", @@ -132,11 +132,11 @@ class TestOAuthProxyStorage: ) await proxy.register_client(client_info) - # Get client back - should be ProxyDCRClient + # Get client back - should be OAuthProxyClient client = await proxy.get_client("test-proxy-client") assert client is not None - # ProxyDCRClient should validate dynamic localhost ports + # OAuthProxyClient should validate dynamic localhost ports validated = client.validate_redirect_uri( AnyUrl("http://localhost:12345/callback") ) @@ -205,5 +205,7 @@ class TestOAuthProxyStorage: "client_id_issued_at": None, "client_secret_expires_at": None, "allowed_redirect_uri_patterns": None, + "cimd_document": None, + "cimd_fetched_at": None, } ) diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index b8e373e40..e3dd95e1c 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -15,10 +15,10 @@ TEST_ISSUER = "https://example.com" TEST_AUTHORIZATION_ENDPOINT = "https://example.com/authorize" TEST_TOKEN_ENDPOINT = "https://example.com/oauth/token" -TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration" +TEST_CONFIG_URL = AnyHttpUrl("https://example.com/.well-known/openid-configuration") TEST_CLIENT_ID = "test-client-id" TEST_CLIENT_SECRET = "test-client-secret" -TEST_BASE_URL = "https://example.com:8000/" +TEST_BASE_URL = AnyHttpUrl("https://example.com:8000/") # ============================================================================= @@ -366,7 +366,7 @@ def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds) mock_get.return_value = mock_response config = OIDCConfiguration.get_oidc_configuration( - config_url=AnyHttpUrl(TEST_CONFIG_URL), + config_url=TEST_CONFIG_URL, strict=strict, timeout_seconds=timeout_seconds, ) @@ -376,7 +376,7 @@ def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds) mock_get.assert_called_once() call_args = mock_get.call_args - assert call_args[0][0] == TEST_CONFIG_URL + assert str(call_args[0][0]) == str(TEST_CONFIG_URL) return call_args @@ -415,7 +415,7 @@ class TestGetOIDCConfiguration: mock_get.return_value = mock_response OIDCConfiguration.get_oidc_configuration( - config_url=AnyHttpUrl(TEST_CONFIG_URL), + config_url=TEST_CONFIG_URL, strict=False, timeout_seconds=10, ) @@ -423,7 +423,7 @@ class TestGetOIDCConfiguration: mock_get.assert_called_once() call_args = mock_get.call_args - assert call_args[0][0] == TEST_CONFIG_URL + assert str(call_args[0][0]) == str(TEST_CONFIG_URL) def validate_proxy(mock_get, proxy, oidc_config): @@ -431,13 +431,13 @@ def validate_proxy(mock_get, proxy, oidc_config): mock_get.assert_called_once() call_args = mock_get.call_args - assert str(call_args[0][0]) == TEST_CONFIG_URL + assert str(call_args[0][0]) == str(TEST_CONFIG_URL) assert proxy._upstream_authorization_endpoint == TEST_AUTHORIZATION_ENDPOINT assert proxy._upstream_token_endpoint == TEST_TOKEN_ENDPOINT assert proxy._upstream_client_id == TEST_CLIENT_ID assert proxy._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET - assert str(proxy.base_url) == TEST_BASE_URL + assert str(proxy.base_url) == str(TEST_BASE_URL) assert proxy.oidc_config == oidc_config diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py index 87071a91f..10945d2fb 100644 --- a/tests/server/auth/test_redirect_validation.py +++ b/tests/server/auth/test_redirect_validation.py @@ -109,6 +109,65 @@ class TestValidateRedirectUri: assert not validate_redirect_uri(uri, patterns) +class TestSecurityBypass: + """Test protection against redirect URI security bypass attacks.""" + + def test_userinfo_bypass_blocked(self): + """Test that userinfo-style bypasses are blocked. + + Attack: http://localhost@evil.com/callback would match http://localhost:* + with naive string matching, but actually points to evil.com. + """ + pattern = "http://localhost:*" + + # These should be blocked - the "host" is actually in the userinfo + assert not matches_allowed_pattern( + "http://localhost@evil.com/callback", pattern + ) + assert not matches_allowed_pattern( + "http://localhost:3000@malicious.io/callback", pattern + ) + assert not matches_allowed_pattern( + "http://user:pass@localhost:3000/callback", pattern + ) + + def test_userinfo_bypass_with_subdomain_pattern(self): + """Test userinfo bypass with subdomain wildcard patterns.""" + pattern = "https://*.example.com/callback" + + # Blocked: userinfo tricks + assert not matches_allowed_pattern( + "https://app.example.com@attacker.com/callback", pattern + ) + assert not matches_allowed_pattern( + "https://user:pass@app.example.com/callback", pattern + ) + + def test_legitimate_uris_still_work(self): + """Test that legitimate URIs work after security hardening.""" + pattern = "http://localhost:*" + assert matches_allowed_pattern("http://localhost:3000/callback", pattern) + assert matches_allowed_pattern("http://localhost:8080/auth", pattern) + + pattern = "https://*.example.com/callback" + assert matches_allowed_pattern("https://app.example.com/callback", pattern) + + def test_scheme_mismatch_blocked(self): + """Test that scheme mismatches are blocked.""" + assert not matches_allowed_pattern( + "http://localhost:3000/callback", "https://localhost:*" + ) + assert not matches_allowed_pattern( + "https://localhost:3000/callback", "http://localhost:*" + ) + + def test_host_mismatch_blocked(self): + """Test that host mismatches are blocked even with wildcards.""" + pattern = "http://localhost:*" + assert not matches_allowed_pattern("http://127.0.0.1:3000/callback", pattern) + assert not matches_allowed_pattern("http://example.com:3000/callback", pattern) + + class TestDefaultPatterns: """Test the default localhost patterns constant.""" diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py new file mode 100644 index 000000000..79cf926a0 --- /dev/null +++ b/tests/server/auth/test_ssrf_protection.py @@ -0,0 +1,447 @@ +"""Tests for SSRF-safe HTTP utilities. + +This module tests the ssrf.py module which provides SSRF-protected HTTP fetching. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from fastmcp.server.auth.ssrf import ( + SSRFError, + SSRFFetchError, + is_ip_allowed, + ssrf_safe_fetch, + validate_url, +) + + +class TestIsIPAllowed: + """Tests for is_ip_allowed function.""" + + def test_public_ipv4_allowed(self): + """Public IPv4 addresses should be allowed.""" + assert is_ip_allowed("8.8.8.8") is True + assert is_ip_allowed("1.1.1.1") is True + assert is_ip_allowed("93.184.216.34") is True + + def test_private_ipv4_blocked(self): + """Private IPv4 addresses should be blocked.""" + assert is_ip_allowed("192.168.1.1") is False + assert is_ip_allowed("10.0.0.1") is False + assert is_ip_allowed("172.16.0.1") is False + + def test_loopback_blocked(self): + """Loopback addresses should be blocked.""" + assert is_ip_allowed("127.0.0.1") is False + assert is_ip_allowed("::1") is False + + def test_link_local_blocked(self): + """Link-local addresses (AWS metadata) should be blocked.""" + assert is_ip_allowed("169.254.169.254") is False + + def test_rfc6598_cgnat_blocked(self): + """RFC6598 Carrier-Grade NAT addresses should be blocked.""" + assert is_ip_allowed("100.64.0.1") is False + assert is_ip_allowed("100.100.100.100") is False + + def test_ipv4_mapped_ipv6_blocked_if_private(self): + """IPv4-mapped IPv6 addresses should check the embedded IPv4.""" + assert is_ip_allowed("::ffff:127.0.0.1") is False + assert is_ip_allowed("::ffff:192.168.1.1") is False + + +class TestValidateURL: + """Tests for validate_url function.""" + + async def test_http_rejected(self): + """HTTP URLs should be rejected (HTTPS required).""" + with pytest.raises(SSRFError, match="must use HTTPS"): + await validate_url("http://example.com/path") + + async def test_missing_host_rejected(self): + """URLs without host should be rejected.""" + with pytest.raises(SSRFError, match="must have a host"): + await validate_url("https:///path") + + async def test_root_path_rejected_when_required(self): + """Root paths should be rejected when require_path=True.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ): + with pytest.raises(SSRFError, match="non-root path"): + await validate_url("https://example.com/", require_path=True) + + async def test_private_ip_rejected(self): + """URLs resolving to private IPs should be rejected.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["192.168.1.1"], + ): + with pytest.raises(SSRFError, match="blocked IP"): + await validate_url("https://example.com/path") + + +class TestSSRFSafeFetch: + """Tests for ssrf_safe_fetch function.""" + + async def test_private_ip_blocked(self): + """Fetch to private IP should be blocked.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["192.168.1.1"], + ): + with pytest.raises(SSRFError, match="blocked IP"): + await ssrf_safe_fetch("https://internal.example.com/api") + + async def test_cgnat_blocked(self): + """Fetch to RFC6598 CGNAT IP should be blocked.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["100.64.0.1"], + ): + with pytest.raises(SSRFError, match="blocked IP"): + await ssrf_safe_fetch("https://cgnat.example.com/api") + + async def test_connects_to_pinned_ip(self): + """Verify connection uses pinned IP, not re-resolved DNS.""" + resolved_ip = "93.184.216.34" + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ip], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "15"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b'{"data": "test"}' + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + await ssrf_safe_fetch("https://example.com/api") + + # Verify URL contains pinned IP + call_args = mock_client.stream.call_args + url_called = call_args[0][1] + assert resolved_ip in url_called + + async def test_fallback_to_second_ip(self): + """If the first IP fails, the next resolved IP should be tried.""" + resolved_ips = ["2001:4860:4860::8888", "93.184.216.34"] + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=resolved_ips, + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + request = httpx.Request("GET", "https://example.com/api") + + first_client = AsyncMock() + first_client.stream = MagicMock( + side_effect=httpx.RequestError("boom", request=request) + ) + first_client.__aenter__.return_value = first_client + first_client.__aexit__ = AsyncMock(return_value=None) + + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "2"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b"ok" + + mock_stream.aiter_bytes = aiter_bytes + + second_client = AsyncMock() + second_client.stream = MagicMock(return_value=mock_stream) + second_client.__aenter__.return_value = second_client + second_client.__aexit__ = AsyncMock(return_value=None) + + mock_client_class.side_effect = [first_client, second_client] + + content = await ssrf_safe_fetch("https://example.com/api") + assert content == b"ok" + + call_args = second_client.stream.call_args + url_called = call_args[0][1] + assert resolved_ips[1] in url_called + + async def test_host_header_set(self): + """Verify Host header is set to original hostname.""" + resolved_ip = "93.184.216.34" + original_host = "example.com" + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ip], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "15"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b'{"data": "test"}' + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + await ssrf_safe_fetch(f"https://{original_host}/api") + + # Verify Host header + call_kwargs = mock_client.stream.call_args[1] + assert call_kwargs["headers"]["Host"] == original_host + + async def test_response_size_limit(self): + """Verify response size limit is enforced via streaming.""" + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + # Response larger than default 5KB (no Content-Length, so streaming enforces) + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {} # No Content-Length to force streaming check + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + # Yield 10KB total + for _ in range(10): + yield b"x" * 1024 + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SSRFFetchError, match="too large"): + await ssrf_safe_fetch("https://example.com/api") + + +class TestJWKSSSRFProtection: + """Tests for SSRF protection in JWTVerifier JWKS fetching.""" + + async def test_jwks_private_ip_blocked(self): + """JWKS fetch to private IP should be blocked.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier + + verifier = JWTVerifier( + jwks_uri="https://internal.example.com/.well-known/jwks.json", + issuer="https://issuer.example.com", + ssrf_safe=True, + ) + + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["192.168.1.1"], + ): + with pytest.raises(ValueError, match="Failed to fetch JWKS"): + # Create a dummy token to trigger JWKS fetch + await verifier._get_jwks_key("test-kid") + + async def test_jwks_cgnat_blocked(self): + """JWKS fetch to RFC6598 CGNAT IP should be blocked.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier + + verifier = JWTVerifier( + jwks_uri="https://cgnat.example.com/.well-known/jwks.json", + issuer="https://issuer.example.com", + ssrf_safe=True, + ) + + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["100.64.0.1"], + ): + with pytest.raises(ValueError, match="Failed to fetch JWKS"): + await verifier._get_jwks_key("test-kid") + + async def test_jwks_loopback_blocked(self): + """JWKS fetch to loopback should be blocked.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier + + verifier = JWTVerifier( + jwks_uri="https://localhost/.well-known/jwks.json", + issuer="https://issuer.example.com", + ssrf_safe=True, + ) + + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["127.0.0.1"], + ): + with pytest.raises(ValueError, match="Failed to fetch JWKS"): + await verifier._get_jwks_key("test-kid") + + +class TestIPv6URLFormatting: + """Tests for proper IPv6 address bracketing in URLs.""" + + def test_format_ip_for_url_ipv4(self): + """IPv4 addresses should not be bracketed.""" + from fastmcp.server.auth.ssrf import format_ip_for_url + + assert format_ip_for_url("8.8.8.8") == "8.8.8.8" + assert format_ip_for_url("192.168.1.1") == "192.168.1.1" + + def test_format_ip_for_url_ipv6(self): + """IPv6 addresses should be bracketed for URL use.""" + from fastmcp.server.auth.ssrf import format_ip_for_url + + assert format_ip_for_url("2001:db8::1") == "[2001:db8::1]" + assert format_ip_for_url("::1") == "[::1]" + assert format_ip_for_url("fe80::1") == "[fe80::1]" + + def test_format_ip_for_url_invalid(self): + """Invalid IP strings should be returned unchanged.""" + from fastmcp.server.auth.ssrf import format_ip_for_url + + assert format_ip_for_url("not-an-ip") == "not-an-ip" + assert format_ip_for_url("") == "" + + async def test_ipv6_pinned_url_is_valid(self): + """Verify IPv6 addresses are properly bracketed in pinned URLs.""" + resolved_ipv6 = "2001:4860:4860::8888" + + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ipv6], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "10"} + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + yield b'{"key": 1}' + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + await ssrf_safe_fetch("https://example.com/api") + + # Verify the URL contains bracketed IPv6 address + call_args = mock_client.stream.call_args + url_called = call_args[0][1] + + # IPv6 should be bracketed: https://[2001:4860:4860::8888]:443/path + assert f"[{resolved_ipv6}]" in url_called, ( + f"Expected bracketed IPv6 [{resolved_ipv6}] in URL, got {url_called}" + ) + + +class TestStreamingResponseSizeLimit: + """Tests for streaming-based response size enforcement.""" + + async def test_size_limit_enforced_during_streaming(self): + """Verify that size limit is enforced as chunks are received, not after.""" + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + chunks_yielded = [] + + async def aiter_bytes(): + # Yield chunks that exceed the limit + for i in range(10): + chunk = b"x" * 1024 # 1KB per chunk + chunks_yielded.append(chunk) + yield chunk + + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {} # No content-length to force streaming check + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SSRFFetchError, match="too large"): + await ssrf_safe_fetch("https://example.com/api", max_size=5120) + + # Verify we stopped after exceeding the limit (should be ~6 chunks for 5KB limit) + # This confirms we're enforcing during streaming, not after downloading all + assert len(chunks_yielded) <= 7, ( + f"Downloaded {len(chunks_yielded)} chunks (expected <=7 for streaming enforcement)" + ) + + async def test_content_length_header_checked_first(self): + """Verify Content-Length header is checked before streaming.""" + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["93.184.216.34"], + ), + patch("httpx.AsyncClient") as mock_client_class, + ): + mock_stream = MagicMock() + mock_stream.status_code = 200 + mock_stream.headers = {"content-length": "10240"} # 10KB + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + # aiter_bytes should never be called if Content-Length is checked + mock_stream.aiter_bytes = MagicMock( + side_effect=AssertionError("Should not stream") + ) + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SSRFFetchError, match="too large"): + await ssrf_safe_fetch("https://example.com/api", max_size=5120) diff --git a/tests/utilities/openapi/test_models.py b/tests/utilities/openapi/test_models.py index cc4baadb3..4361635c2 100644 --- a/tests/utilities/openapi/test_models.py +++ b/tests/utilities/openapi/test_models.py @@ -4,8 +4,10 @@ import pytest from inline_snapshot import snapshot from fastmcp.utilities.openapi.models import ( + HttpMethod, HTTPRoute, ParameterInfo, + ParameterLocation, RequestBodyInfo, ResponseInfo, ) @@ -51,7 +53,7 @@ class TestParameterInfo: assert param.style == "deepObject" @pytest.mark.parametrize("location", ["path", "query", "header", "cookie"]) - def test_valid_parameter_locations(self, location): + def test_valid_parameter_locations(self, location: ParameterLocation): """Test that all valid parameter locations are accepted.""" param = ParameterInfo( name="test", @@ -286,7 +288,7 @@ class TestHTTPRoute: @pytest.mark.parametrize( "method", ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] ) - def test_valid_http_methods(self, method): + def test_valid_http_methods(self, method: HttpMethod): """Test that all valid HTTP methods are accepted.""" route = HTTPRoute( path="/test", From 30832ced1cd17e679f60cb88dd86ae7b7d63f403 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 6 Feb 2026 23:13:26 +0000 Subject: [PATCH 21/63] Add ResponseLimitingMiddleware for tool response size control (#3072) --- docs/servers/middleware.mdx | 44 +++++ .../server/middleware/response_limiting.py | 125 ++++++++++++++ .../middleware/test_response_limiting.py | 155 ++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 src/fastmcp/server/middleware/response_limiting.py create mode 100644 tests/server/middleware/test_response_limiting.py diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index a70107816..ddf283fb9 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -555,6 +555,50 @@ my_tool = Tool.from_function(fn=my_tool_fn, name="my_tool") mcp.add_middleware(ToolInjectionMiddleware(tools=[my_tool])) ``` +### Response Limiting + + + +```python +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware +``` + +Large tool responses can overwhelm LLM context windows or cause memory issues. You can add response-limiting middleware to enforce size constraints on tool outputs. + +```python +from fastmcp import FastMCP +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware + +mcp = FastMCP("MyServer") + +# Limit all tool responses to 500KB +mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) + +@mcp.tool +def search(query: str) -> str: + # This could return a very large result + return "x" * 1_000_000 # 1MB response + +# When called, the response will be truncated to ~500KB with: +# "...\n\n[Response truncated due to size limit]" +``` + +When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source. + +```python +# Limit only specific tools +mcp.add_middleware(ResponseLimitingMiddleware( + max_size=100_000, + tools=["search", "fetch_data"], +)) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `max_size` | `int` | `1_000_000` | Maximum response size in bytes (1MB default) | +| `truncation_suffix` | `str` | `"\n\n[Response truncated due to size limit]"` | Suffix appended to truncated responses | +| `tools` | `list[str] \| None` | `None` | Limit only these tools (None = all tools) | + ### Combining Middleware Order matters. Place middleware that should run first (on the way in) earliest: diff --git a/src/fastmcp/server/middleware/response_limiting.py b/src/fastmcp/server/middleware/response_limiting.py new file mode 100644 index 000000000..df83e81a0 --- /dev/null +++ b/src/fastmcp/server/middleware/response_limiting.py @@ -0,0 +1,125 @@ +"""Response limiting middleware for controlling tool response sizes.""" + +from __future__ import annotations + +import logging + +import mcp.types as mt +import pydantic_core +from mcp.types import TextContent + +from fastmcp.tools.tool import ToolResult + +from .middleware import CallNext, Middleware, MiddlewareContext + +__all__ = ["ResponseLimitingMiddleware"] + +logger = logging.getLogger(__name__) + + +class ResponseLimitingMiddleware(Middleware): + """Middleware that limits the response size of tool calls. + + Intercepts tool call responses and enforces size limits. If a response + exceeds the limit, it extracts text content, truncates it, and returns + a single TextContent block. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.middleware.response_limiting import ( + ResponseLimitingMiddleware, + ) + + mcp = FastMCP("MyServer") + + # Limit all tool responses to 500KB + mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) + + # Limit only specific tools + mcp.add_middleware( + ResponseLimitingMiddleware( + max_size=100_000, + tools=["search", "fetch_data"], + ) + ) + ``` + """ + + def __init__( + self, + *, + max_size: int = 1_000_000, + truncation_suffix: str = "\n\n[Response truncated due to size limit]", + tools: list[str] | None = None, + ) -> None: + """Initialize response limiting middleware. + + Args: + max_size: Maximum response size in bytes. Defaults to 1MB (1,000,000). + truncation_suffix: Suffix to append when truncating responses. + Defaults to "\\n\\n[Response truncated due to size limit]". + tools: List of tool names to apply limiting to. If None, applies to all. + """ + if max_size <= 0: + raise ValueError(f"max_size must be positive, got {max_size}") + self.max_size = max_size + self.truncation_suffix = truncation_suffix + self.tools = set(tools) if tools is not None else None + + def _truncate_to_result(self, text: str) -> ToolResult: + """Truncate text to fit within max_size and wrap in ToolResult.""" + suffix_bytes = len(self.truncation_suffix.encode("utf-8")) + # Account for JSON wrapper overhead: {"content":[{"type":"text","text":"..."}]} + overhead = 50 + target_size = self.max_size - suffix_bytes - overhead + + if target_size <= 0: + # Edge case: max_size too small for even the suffix + truncated = self.truncation_suffix + else: + # Truncate to target size, preserving UTF-8 boundaries + encoded = text.encode("utf-8") + if len(encoded) <= target_size: + truncated = text + self.truncation_suffix + else: + truncated = ( + encoded[:target_size].decode("utf-8", errors="ignore") + + self.truncation_suffix + ) + + return ToolResult(content=[TextContent(type="text", text=truncated)]) + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + """Intercept tool calls and limit response size.""" + result = await call_next(context) + + # Check if we should limit this tool + if self.tools is not None and context.message.name not in self.tools: + return result + + # Measure serialized size + serialized = pydantic_core.to_json(result, fallback=str) + if len(serialized) <= self.max_size: + return result + + # Over limit: extract text, truncate, return single TextContent + logger.warning( + "Tool %r response exceeds size limit: %d bytes > %d bytes, truncating", + context.message.name, + len(serialized), + self.max_size, + ) + + texts = [b.text for b in result.content if isinstance(b, TextContent)] + text = ( + "\n\n".join(texts) + if texts + else serialized.decode("utf-8", errors="replace") + ) + + return self._truncate_to_result(text) diff --git a/tests/server/middleware/test_response_limiting.py b/tests/server/middleware/test_response_limiting.py new file mode 100644 index 000000000..4e89e05de --- /dev/null +++ b/tests/server/middleware/test_response_limiting.py @@ -0,0 +1,155 @@ +"""Tests for ResponseLimitingMiddleware.""" + +import pytest +from mcp.types import ImageContent, TextContent + +from fastmcp import Client, FastMCP +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware +from fastmcp.tools.tool import ToolResult + + +class TestResponseLimitingMiddleware: + """Tests for ResponseLimitingMiddleware.""" + + @pytest.fixture + def mcp_server(self) -> FastMCP: + """Create a basic MCP server for testing.""" + return FastMCP("test-server") + + async def test_response_under_limit_passes_unchanged(self, mcp_server: FastMCP): + """Test that responses under the limit pass through unchanged.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=1_000_000)) + + @mcp_server.tool() + def small_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="hello world")]) + + async with Client(mcp_server) as client: + result = await client.call_tool("small_tool", {}) + assert len(result.content) == 1 + assert result.content[0].text == "hello world" + + async def test_response_over_limit_is_truncated(self, mcp_server: FastMCP): + """Test that responses over the limit are truncated.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=500)) + + @mcp_server.tool() + def large_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + async with Client(mcp_server) as client: + result = await client.call_tool("large_tool", {}) + assert len(result.content) == 1 + assert "[Response truncated due to size limit]" in result.content[0].text + # Verify truncated result fits within limit + assert len(result.content[0].text.encode("utf-8")) < 500 + + async def test_tool_filtering(self, mcp_server: FastMCP): + """Test that tool filtering only applies to specified tools.""" + mcp_server.add_middleware( + ResponseLimitingMiddleware(max_size=100, tools=["limited_tool"]) + ) + + @mcp_server.tool() + def limited_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + @mcp_server.tool() + def unlimited_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="y" * 10_000)]) + + async with Client(mcp_server) as client: + # Limited tool should be truncated + result = await client.call_tool("limited_tool", {}) + assert "[Response truncated" in result.content[0].text + + # Unlimited tool should pass through + result = await client.call_tool("unlimited_tool", {}) + assert "y" * 100 in result.content[0].text + + async def test_empty_tools_list_limits_nothing(self, mcp_server: FastMCP): + """Test that empty tools list means no tools are limited.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=100, tools=[])) + + @mcp_server.tool() + def any_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + async with Client(mcp_server) as client: + result = await client.call_tool("any_tool", {}) + # Should NOT be truncated + assert "[Response truncated" not in result.content[0].text + + async def test_custom_truncation_suffix(self, mcp_server: FastMCP): + """Test that custom truncation suffix is applied.""" + mcp_server.add_middleware( + ResponseLimitingMiddleware(max_size=200, truncation_suffix="\n[CUT]") + ) + + @mcp_server.tool() + def large_tool() -> ToolResult: + return ToolResult(content=[TextContent(type="text", text="x" * 10_000)]) + + async with Client(mcp_server) as client: + result = await client.call_tool("large_tool", {}) + assert "[CUT]" in result.content[0].text + + async def test_multiple_text_blocks_combined(self, mcp_server: FastMCP): + """Test that multiple text blocks are combined when truncating.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=300)) + + @mcp_server.tool() + def multi_block() -> ToolResult: + return ToolResult( + content=[ + TextContent(type="text", text="First: " + "a" * 500), + TextContent(type="text", text="Second: " + "b" * 500), + ] + ) + + async with Client(mcp_server) as client: + result = await client.call_tool("multi_block", {}) + # Both blocks should be joined and truncated + assert len(result.content) == 1 + assert "[Response truncated" in result.content[0].text + + async def test_binary_only_content_serialized(self, mcp_server: FastMCP): + """Test that binary-only responses fall back to serialized content.""" + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=200)) + + @mcp_server.tool() + def binary_tool() -> ToolResult: + return ToolResult( + content=[ + ImageContent(type="image", data="x" * 10_000, mimeType="image/png") + ] + ) + + async with Client(mcp_server) as client: + result = await client.call_tool("binary_tool", {}) + # Should be truncated (using serialized fallback) + assert len(result.content) == 1 + assert "[Response truncated" in result.content[0].text + + async def test_default_max_size_is_1mb(self): + """Test that the default max size is 1MB.""" + middleware = ResponseLimitingMiddleware() + assert middleware.max_size == 1_000_000 + + def test_invalid_max_size_raises(self): + """Test that zero or negative max_size raises ValueError.""" + with pytest.raises(ValueError, match="max_size must be positive"): + ResponseLimitingMiddleware(max_size=0) + with pytest.raises(ValueError, match="max_size must be positive"): + ResponseLimitingMiddleware(max_size=-100) + + def test_utf8_truncation_preserves_characters(self): + """Test that UTF-8 truncation doesn't break multi-byte characters.""" + middleware = ResponseLimitingMiddleware(max_size=100) + # Text with multi-byte characters (emoji) + text = "Hello 🌍 World 🎉 Test " * 100 + result = middleware._truncate_to_result(text) + # Should not raise and should be valid UTF-8 + content = result.content[0] + assert isinstance(content, TextContent) + content.text.encode("utf-8") From 32c6826e13409db27ea340ba0c2f26bba65b80f5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:15:57 -0500 Subject: [PATCH 22/63] Add note about output_schema incongruity when responses are truncated (#3099) --- docs/servers/middleware.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index ddf283fb9..cea5d3c27 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -585,6 +585,10 @@ def search(query: str) -> str: When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source. + +If a tool defines an `output_schema`, truncated responses will no longer conform to that schema — the client will receive a plain `TextContent` block instead of the expected structured output. Keep this in mind when setting size limits for tools with structured responses. + + ```python # Limit only specific tools mcp.add_middleware(ResponseLimitingMiddleware( From b8d789c1b4dcba7b7606c7d06abcb7b7e163beec Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:20:17 -0500 Subject: [PATCH 23/63] Document token passthrough security in OAuth Proxy docs (#3100) --- docs/servers/auth/oauth-proxy.mdx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 86a8865f3..5b97f4452 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -416,7 +416,7 @@ sequenceDiagram Note over Client, Proxy: Token Exchange Client->>Proxy: 11. POST /token with code
code_verifier=CLIENT_VERIFIER - Proxy-->>Client: 12. Returns stored provider tokens + Proxy-->>Client: 12. Returns FastMCP JWT tokens ``` The flow diagram above illustrates the complete OAuth proxy pattern. Let's understand each phase: @@ -447,7 +447,7 @@ After user authorization, the provider redirects back to the proxy's fixed callb ### Token Exchange Phase -Finally, the client exchanges its authorization code with the proxy to receive the provider's tokens. The proxy validates the client's PKCE verifier before returning the stored tokens. +Finally, the client exchanges its authorization code with the proxy. The proxy validates the client's PKCE verifier, then issues its own FastMCP JWT tokens (rather than forwarding the upstream provider's tokens). See [Token Architecture](#token-architecture) for details on this design. This entire flow is transparent to the MCP client—it experiences a standard OAuth flow with dynamic registration, unaware that a proxy is managing the complexity behind the scenes. @@ -475,6 +475,8 @@ When a client makes an MCP request with its FastMCP token: This two-tier validation ensures that FastMCP tokens can only be used with this server (via audience validation) while maintaining full upstream token security. +This architecture also prevents [token passthrough](#token-passthrough) — see the [Security](#security) section for details. + **Token expiry alignment:** FastMCP token lifetimes match the upstream token lifetimes. When the upstream token expires, the FastMCP token also expires, maintaining consistent security boundaries. @@ -628,6 +630,20 @@ The consent page automatically displays your server's name, icon, and website UR - [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem) - Official specification guidance - [Confused Deputy Attacks Explained](https://den.dev/blog/mcp-confused-deputy-api-management/) - Detailed walkthrough by Den Delimarsky +### Token Passthrough + +[Token passthrough](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#token-passthrough) occurs when an intermediary exposes upstream tokens to downstream clients, allowing those clients to impersonate the intermediary or access services they shouldn't reach. + +#### Client-facing mitigation + +The OAuth proxy's [token factory architecture](#token-architecture) prevents this by design. MCP clients only ever receive FastMCP-issued JWTs — the upstream provider token is never sent to the client. A FastMCP JWT is scoped to your server and cannot be used to access the upstream provider directly, even if intercepted. + +#### Calling downstream services + +When your MCP server needs to call other APIs on behalf of the authenticated user, avoid forwarding the upstream token directly — this reintroduces the token passthrough problem in the other direction. Instead, use a token exchange flow like [OAuth 2.0 Token Exchange (RFC 8693)](https://datatracker.ietf.org/doc/html/rfc8693) or your provider's equivalent (such as Azure's [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)) to obtain a new token scoped to the downstream service. + +The upstream token is available in your tool functions via `get_access_token()` or the `CurrentAccessToken` dependency, which you can use as the assertion for a token exchange. The exchanged token will be scoped to the specific downstream service and identify your MCP server as the authorized intermediary, maintaining proper audience boundaries throughout the chain. + ## Production Configuration For production deployments, load sensitive credentials from environment variables: From 6a358902f282c001266a2d32ffecbd079a0e3ede Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:52:21 -0500 Subject: [PATCH 24/63] Fix compress_schema to preserve additionalProperties: false for MCP compatibility (#3102) Changes: - Changed default of prune_additional_properties from True to False in compress_schema - Added test demonstrating MCP client compatibility requirement - Updated existing tests to explicitly enable pruning when needed - Added additionalProperties: false to manually constructed schemas in tool_transform - Updated inline snapshots to reflect new behavior Fixes #3008 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/tools/tool_transform.py | 2 + src/fastmcp/utilities/json_schema.py | 6 ++- tests/tools/tool/test_tool.py | 8 +++ tests/tools/tool_transform/test_schemas.py | 5 ++ tests/utilities/test_json_schema.py | 58 ++++++++++++++++++++-- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 85ea9e02e..f22010750 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -685,6 +685,7 @@ class TransformedTool(Tool): "type": "object", "properties": new_props, "required": list(new_required), + "additionalProperties": False, } if parent_defs: @@ -868,6 +869,7 @@ class TransformedTool(Tool): "type": "object", "properties": merged_props, "required": list(final_required), + "additionalProperties": False, } if merged_defs: diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index 4ebf9d126..f302e1cd1 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -364,7 +364,7 @@ def _single_pass_optimize( def compress_schema( schema: dict[str, Any], prune_params: list[str] | None = None, - prune_additional_properties: bool = True, + prune_additional_properties: bool = False, prune_titles: bool = False, ) -> dict[str, Any]: """ @@ -378,7 +378,9 @@ def compress_schema( Args: schema: The schema to compress prune_params: List of parameter names to remove from properties - prune_additional_properties: Whether to remove additionalProperties: false + prune_additional_properties: Whether to remove additionalProperties: false. + Defaults to False to maintain MCP client compatibility, as some clients + (e.g., Claude) require additionalProperties: false for strict validation. prune_titles: Whether to remove title fields from the schema """ # Dereference $ref - this inlines all definitions and removes $defs diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py index 37499914e..dd75d7443 100644 --- a/tests/tools/tool/test_tool.py +++ b/tests/tools/tool/test_tool.py @@ -30,6 +30,7 @@ class TestToolFromFunction: "description": "Add two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "a": {"type": "integer"}, "b": {"type": "integer"}, @@ -83,6 +84,7 @@ class TestToolFromFunction: "description": "Fetch data from URL.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": {"url": {"type": "string"}}, "required": ["url"], "type": "object", @@ -117,6 +119,7 @@ class TestToolFromFunction: "description": "Adds two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "x": {"type": "integer"}, "y": {"type": "integer"}, @@ -153,6 +156,7 @@ class TestToolFromFunction: "description": "Adds two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "x": {"type": "integer"}, "y": {"type": "integer"}, @@ -192,6 +196,7 @@ class TestToolFromFunction: "description": "Create a new user.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "user": { "properties": { @@ -270,6 +275,7 @@ class TestToolFromFunction: "name": "my_tool", "tags": set(), "parameters": { + "additionalProperties": False, "properties": {"x": {"title": "X"}}, "required": ["x"], "type": "object", @@ -302,6 +308,7 @@ class TestToolFromFunction: "description": "Add two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "_a": {"type": "integer"}, "_b": {"type": "integer"}, @@ -353,6 +360,7 @@ class TestToolFromFunction: "description": "Add two numbers.", "tags": set(), "parameters": { + "additionalProperties": False, "properties": { "x": {"type": "integer"}, "y": {"type": "integer"}, diff --git a/tests/tools/tool_transform/test_schemas.py b/tests/tools/tool_transform/test_schemas.py index 8b3db954a..51cb89f76 100644 --- a/tests/tools/tool_transform/test_schemas.py +++ b/tests/tools/tool_transform/test_schemas.py @@ -383,6 +383,7 @@ class TestInputSchema: "field2": {"type": "boolean"}, }, "required": [], + "additionalProperties": False, } ) @@ -424,6 +425,7 @@ class TestInputSchema: } }, "required": ["used_param"], + "additionalProperties": False, } ) @@ -464,6 +466,7 @@ class TestInputSchema: } }, "required": ["renamed_input"], + "additionalProperties": False, } ) @@ -508,6 +511,7 @@ class TestInputSchema: }, }, "required": IsList("param_b", "param_a", check_order=False), + "additionalProperties": False, } ) @@ -530,5 +534,6 @@ class TestInputSchema: } }, "required": ["param_a"], + "additionalProperties": False, } ) diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 9c9e77775..436beb6a2 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -228,13 +228,14 @@ class TestCompressSchema: assert result["required"] == ["bar"] def test_pruning_additional_properties(self): - """Test pruning additionalProperties when False.""" + """Test pruning additionalProperties when explicitly enabled.""" schema = { "type": "object", "properties": {"foo": {"type": "string"}}, "additionalProperties": False, } - result = compress_schema(schema) + # Must explicitly enable pruning now (default changed for MCP compatibility) + result = compress_schema(schema, prune_additional_properties=True) assert "additionalProperties" not in result def test_disable_pruning_additional_properties(self): @@ -263,7 +264,9 @@ class TestCompressSchema: "unused_def": {"type": "number"}, }, } - result = compress_schema(schema, prune_params=["remove"]) + result = compress_schema( + schema, prune_params=["remove"], prune_additional_properties=True + ) # Check that parameter was removed assert "remove" not in result["properties"] # Check that required list was updated @@ -296,7 +299,7 @@ class TestCompressSchema: assert "title" not in result["properties"]["bar"]["properties"]["nested"] def test_prune_nested_additional_properties(self): - """Test pruning additionalProperties: false at all levels.""" + """Test pruning additionalProperties: false at all levels when explicitly enabled.""" schema = { "type": "object", "additionalProperties": False, @@ -313,7 +316,7 @@ class TestCompressSchema: }, }, } - result = compress_schema(schema) + result = compress_schema(schema, prune_additional_properties=True) assert "additionalProperties" not in result assert "additionalProperties" not in result["properties"]["foo"] assert ( @@ -393,6 +396,51 @@ class TestCompressSchema: ) assert "title" not in compressed["properties"]["normal_field"] + def test_mcp_client_compatibility_requires_additional_properties(self): + """Test that compress_schema preserves additionalProperties: false for MCP clients. + + MCP clients like Claude require strict JSON schemas with additionalProperties: false. + When tools use Pydantic models with extra="forbid", this constraint must be preserved. + + Without this, MCP clients return: + "Invalid schema for function 'X': In context=('properties', 'Y'), + 'additionalProperties' is required to be supplied and to be false" + + See: https://github.com/jlowin/fastmcp/issues/3008 + """ + # Schema representing a Pydantic model with extra="forbid" + schema = { + "type": "object", + "properties": { + "graph_table": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "columns": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["name"], + "additionalProperties": False, + } + }, + "required": ["graph_table"], + "additionalProperties": False, + } + + # By default, compress_schema should NOT strip additionalProperties: false + # This is the new expected behavior for MCP compatibility + result = compress_schema(schema) + + # Root level should preserve additionalProperties: false + assert result.get("additionalProperties") is False, ( + "Root additionalProperties: false was removed, breaking MCP compatibility" + ) + + # Nested object should also preserve additionalProperties: false + graph_table = result["properties"]["graph_table"] + assert graph_table.get("additionalProperties") is False, ( + "Nested additionalProperties: false was removed, breaking MCP compatibility" + ) + class TestResolveRootRef: """Tests for the resolve_root_ref function. From 85eff33b81248dbc7ff907822ec473f351367a2a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:08:08 -0500 Subject: [PATCH 25/63] Infer MIME types from OpenAPI response definitions (#3101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Infer mime_type from OpenAPI response content types for resources 🤖 Generated with Claude Code https://claude.ai/code/session_01FZD5ZT8WiQqfBu39ybuQis * Handle media types without schemas in MIME inference 🤖 Generated with Claude Code https://claude.ai/code/session_01FZD5ZT8WiQqfBu39ybuQis --------- Co-authored-by: Claude --- .../server/providers/openapi/components.py | 58 ++- .../server/providers/openapi/provider.py | 3 + src/fastmcp/utilities/openapi/parser.py | 4 + .../openapi/test_openapi_features.py | 357 ++++++++++++++++++ 4 files changed, 421 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 43c58b956..4e6d18f1e 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -33,10 +33,64 @@ __all__ = [ "OpenAPIResource", "OpenAPIResourceTemplate", "OpenAPITool", + "_extract_mime_type_from_route", ] logger = get_logger(__name__) +# Default MIME type when no response content type can be inferred +_DEFAULT_MIME_TYPE = "application/json" + + +def _extract_mime_type_from_route(route: HTTPRoute) -> str: + """Extract the primary MIME type from an HTTPRoute's response definitions. + + Looks for the first successful response (2xx) and returns its content type. + Prefers JSON-compatible types when multiple are available. + Falls back to "application/json" when no response content type is declared. + """ + if not route.responses: + return _DEFAULT_MIME_TYPE + + # Priority order for success status codes + success_codes = ["200", "201", "202", "204"] + + response_info = None + for status_code in success_codes: + if status_code in route.responses: + response_info = route.responses[status_code] + break + + # If no explicit success codes, try any 2xx response + if response_info is None: + for status_code, resp_info in route.responses.items(): + if status_code.startswith("2"): + response_info = resp_info + break + + if response_info is None or not response_info.content_schema: + return _DEFAULT_MIME_TYPE + + # If there's only one content type, use it directly + content_types = list(response_info.content_schema.keys()) + if len(content_types) == 1: + return content_types[0] + + # When multiple types exist, prefer JSON-compatible types + json_compatible_types = [ + "application/json", + "application/vnd.api+json", + "application/hal+json", + "application/ld+json", + "text/json", + ] + for ct in json_compatible_types: + if ct in response_info.content_schema: + return ct + + # Fall back to the first available content type + return content_types[0] + def _slugify(text: str) -> str: """Convert text to a URL-friendly slug format. @@ -294,6 +348,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): description: str, parameters: dict[str, Any], tags: set[str] | None = None, + mime_type: str = _DEFAULT_MIME_TYPE, ): super().__init__( uri_template=uri_template, @@ -301,6 +356,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): description=description, parameters=parameters, tags=tags or set(), + mime_type=mime_type, ) self._client = client self._route = route @@ -325,6 +381,6 @@ class OpenAPIResourceTemplate(ResourceTemplate): uri=uri, name=f"{self.name}-{'-'.join(uri_parts)}", description=self.description or f"Resource for {self._route.path}", - mime_type="application/json", + mime_type=self.mime_type, tags=set(self._route.tags or []), ) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index a93be1a41..ac79af400 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -17,6 +17,7 @@ from fastmcp.server.providers.openapi.components import ( OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, + _extract_mime_type_from_route, _slugify, ) from fastmcp.server.providers.openapi.routing import ( @@ -288,6 +289,7 @@ class OpenAPIProvider(Provider): uri=resource_uri, name=resource_name, description=enhanced_description, + mime_type=_extract_mime_type_from_route(route), tags=set(route.tags or []) | tags, ) @@ -356,6 +358,7 @@ class OpenAPIProvider(Provider): description=enhanced_description, parameters=template_params_schema, tags=set(route.tags or []) | tags, + mime_type=_extract_mime_type_from_route(route), ) if self._mcp_component_fn is not None: diff --git a/src/fastmcp/utilities/openapi/parser.py b/src/fastmcp/utilities/openapi/parser.py index e284295fa..40adf8d27 100644 --- a/src/fastmcp/utilities/openapi/parser.py +++ b/src/fastmcp/utilities/openapi/parser.py @@ -506,6 +506,10 @@ class OpenAPIParser( f"Failed to extract schema for media type '{media_type_str}' " f"in response {status_code}: {e}" ) + else: + # Record the media type even without a schema so MIME + # type inference can still use the declared content type. + resp_info.content_schema.setdefault(media_type_str, {}) extracted_responses[str(status_code)] = resp_info except ValueError as e: diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index b466268fe..f55a1e038 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -6,6 +6,9 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.providers.openapi.components import _extract_mime_type_from_route +from fastmcp.server.providers.openapi.routing import MCPType, RouteMap +from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo def create_openapi_server( @@ -412,3 +415,357 @@ class TestResponseSchemas: # Let's just check the tool exists and has basic properties assert get_user_tool.description is not None assert get_user_tool.name == "get_user" + + +class TestMimeTypeExtraction: + """Test MIME type extraction from route responses.""" + + def test_json_response(self): + """JSON content type is correctly extracted.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "200": ResponseInfo( + content_schema={"application/json": {"type": "object"}} + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_text_plain_response(self): + """Plain text content type is correctly extracted.""" + route = HTTPRoute( + path="/health", + method="GET", + responses={ + "200": ResponseInfo(content_schema={"text/plain": {"type": "string"}}) + }, + ) + assert _extract_mime_type_from_route(route) == "text/plain" + + def test_text_html_response(self): + """HTML content type is correctly extracted.""" + route = HTTPRoute( + path="/page", + method="GET", + responses={ + "200": ResponseInfo(content_schema={"text/html": {"type": "string"}}) + }, + ) + assert _extract_mime_type_from_route(route) == "text/html" + + def test_image_response(self): + """Image content type is correctly extracted.""" + route = HTTPRoute( + path="/avatar", + method="GET", + responses={ + "200": ResponseInfo( + content_schema={"image/png": {"type": "string", "format": "binary"}} + ) + }, + ) + assert _extract_mime_type_from_route(route) == "image/png" + + def test_no_responses_defaults_to_json(self): + """Empty responses default to application/json.""" + route = HTTPRoute(path="/items", method="GET", responses={}) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_no_content_schema_defaults_to_json(self): + """Response without content_schema defaults to application/json.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={"204": ResponseInfo(description="No content")}, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_prefers_json_when_multiple_types(self): + """When both JSON and other types exist, JSON is preferred.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "200": ResponseInfo( + content_schema={ + "text/html": {"type": "string"}, + "application/json": {"type": "object"}, + } + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_non_standard_2xx_code(self): + """Falls back to any 2xx status code when standard ones are missing.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "206": ResponseInfo( + content_schema={ + "application/octet-stream": { + "type": "string", + "format": "binary", + } + } + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/octet-stream" + + def test_ignores_error_responses(self): + """Only error responses (no 2xx) results in default.""" + route = HTTPRoute( + path="/items", + method="GET", + responses={ + "404": ResponseInfo( + content_schema={"application/json": {"type": "object"}} + ) + }, + ) + assert _extract_mime_type_from_route(route) == "application/json" + + def test_201_response(self): + """201 Created response content type is extracted.""" + route = HTTPRoute( + path="/items", + method="POST", + responses={ + "201": ResponseInfo(content_schema={"text/plain": {"type": "string"}}) + }, + ) + assert _extract_mime_type_from_route(route) == "text/plain" + + def test_media_type_without_schema(self): + """Media type declared without a schema still infers MIME type.""" + route = HTTPRoute( + path="/health", + method="GET", + responses={"200": ResponseInfo(content_schema={"text/plain": {}})}, + ) + assert _extract_mime_type_from_route(route) == "text/plain" + + +class TestResourceTemplateMimeType: + """Test that OpenAPIResourceTemplate uses inferred MIME types.""" + + @pytest.fixture + def text_plain_spec(self): + """OpenAPI spec with a text/plain resource template endpoint.""" + return { + "openapi": "3.0.0", + "info": {"title": "Text API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/documents/{id}": { + "get": { + "operationId": "get_document", + "summary": "Get document content", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": { + "200": { + "description": "Document content", + "content": { + "text/plain": {"schema": {"type": "string"}} + }, + } + }, + } + } + }, + } + + @pytest.fixture + def html_spec(self): + """OpenAPI spec with a text/html resource endpoint.""" + return { + "openapi": "3.0.0", + "info": {"title": "HTML API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/pages/{slug}": { + "get": { + "operationId": "get_page", + "summary": "Get HTML page", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": { + "200": { + "description": "HTML page", + "content": { + "text/html": {"schema": {"type": "string"}} + }, + } + }, + } + } + }, + } + + async def test_resource_template_text_plain_mime_type(self, text_plain_spec): + """Resource template should reflect text/plain from OpenAPI spec.""" + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=text_plain_spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + templates = await mcp_client.list_resource_templates() + assert len(templates) == 1 + assert templates[0].mimeType == "text/plain" + + async def test_resource_template_html_mime_type(self, html_spec): + """Resource template should reflect text/html from OpenAPI spec.""" + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=html_spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + templates = await mcp_client.list_resource_templates() + assert len(templates) == 1 + assert templates[0].mimeType == "text/html" + + async def test_resource_template_defaults_json_mime_type(self): + """Resource template defaults to application/json for JSON responses.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "JSON API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "summary": "Get user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ], + "responses": { + "200": { + "description": "User data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + } + }, + } + }, + } + } + }, + } + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + templates = await mcp_client.list_resource_templates() + assert len(templates) == 1 + assert templates[0].mimeType == "application/json" + + +class TestResourceMimeType: + """Test that OpenAPIResource uses inferred MIME types.""" + + async def test_resource_text_plain_mime_type(self): + """Static resource should reflect text/plain from OpenAPI spec.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Health API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/health": { + "get": { + "operationId": "healthcheck", + "summary": "Health check", + "responses": { + "200": { + "description": "Health status", + "content": { + "text/plain": {"schema": {"type": "string"}} + }, + } + }, + } + } + }, + } + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + resources = await mcp_client.list_resources() + assert len(resources) == 1 + assert resources[0].mimeType == "text/plain" + + async def test_resource_mime_type_without_schema(self): + """Resource with media type but no schema still infers MIME type.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Health API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/health": { + "get": { + "operationId": "healthcheck", + "summary": "Health check", + "responses": { + "200": { + "description": "Health status", + "content": {"text/plain": {}}, + } + }, + } + } + }, + } + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)] + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec, client=client, route_maps=route_maps + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + async with Client(mcp) as mcp_client: + resources = await mcp_client.list_resources() + assert len(resources) == 1 + assert resources[0].mimeType == "text/plain" From ad3b1b9d1b1584edcdcc111bf2b122041d028813 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:08:23 -0500 Subject: [PATCH 26/63] Fix CIMD redirect allowlist bypass and cache revalidation (#3098) * Harden CIMD redirect and cache handling * Preserve CIMD cache policy on 304 revalidation * Refresh 304 cache expiry from cached lifetime --- docs/servers/auth/oauth-proxy.mdx | 2 +- src/fastmcp/server/auth/cimd.py | 172 ++++++++++++- src/fastmcp/server/auth/oauth_proxy/models.py | 19 +- src/fastmcp/server/auth/ssrf.py | 55 +++- tests/server/auth/test_cimd.py | 238 ++++++++++++++++++ .../test_oauth_proxy_redirect_validation.py | 34 +++ 6 files changed, 502 insertions(+), 18 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 5b97f4452..b07092a1d 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -585,7 +585,7 @@ CIMD provides several security advantages over DCR: - **Replay prevention**: For `private_key_jwt` clients, JTI claims are tracked to prevent assertion replay - **Cache-aware fetching**: CIMD documents are cached according to HTTP cache headers and revalidated when required -To disable CIMD support entirely (for example, to require all clients to register via DCR): +CIMD is enabled by default. To disable it entirely (for example, to require all clients to register via DCR), set `enable_cimd=False` explicitly: ```python auth = OAuthProxy( diff --git a/src/fastmcp/server/auth/cimd.py b/src/fastmcp/server/auth/cimd.py index 49aa6687c..caef56f96 100644 --- a/src/fastmcp/server/auth/cimd.py +++ b/src/fastmcp/server/auth/cimd.py @@ -19,6 +19,10 @@ from __future__ import annotations import fnmatch import json import time +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timezone +from email.utils import parsedate_to_datetime from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urlparse @@ -27,7 +31,7 @@ from pydantic import AnyHttpUrl, BaseModel, Field, field_validator from fastmcp.server.auth.ssrf import ( SSRFError, SSRFFetchError, - ssrf_safe_fetch, + ssrf_safe_fetch_response, validate_url, ) from fastmcp.utilities.logging import get_logger @@ -155,12 +159,37 @@ class CIMDFetchError(Exception): """Raised when CIMD document fetching fails.""" +@dataclass +class _CIMDCacheEntry: + """Cached CIMD document and associated HTTP cache metadata.""" + + doc: CIMDDocument + etag: str | None + last_modified: str | None + expires_at: float + freshness_lifetime: float + must_revalidate: bool + + +@dataclass +class _CIMDCachePolicy: + """Normalized cache directives parsed from HTTP response headers.""" + + etag: str | None + last_modified: str | None + expires_at: float + freshness_lifetime: float + no_store: bool + must_revalidate: bool + + class CIMDFetcher: """Fetch and validate CIMD documents with SSRF protection. - Delegates HTTP fetching to ssrf_safe_fetch which provides DNS pinning, - IP validation, size limits, and timeout enforcement. Documents are cached - with a simple TTL. + Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS + pinning, IP validation, size limits, and timeout enforcement. Documents are + cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with + a TTL fallback when response headers do not define caching behavior. """ # Maximum response size (bytes) @@ -178,7 +207,65 @@ class CIMDFetcher: timeout: HTTP request timeout in seconds (default 10.0) """ self.timeout = timeout - self._cache: dict[str, tuple[CIMDDocument, float]] = {} + self._cache: dict[str, _CIMDCacheEntry] = {} + + def _parse_cache_policy( + self, headers: Mapping[str, str], now: float + ) -> _CIMDCachePolicy: + """Parse HTTP cache headers and derive cache behavior.""" + normalized = {k.lower(): v for k, v in headers.items()} + cache_control = normalized.get("cache-control", "") + directives = { + part.strip().lower() for part in cache_control.split(",") if part.strip() + } + + no_store = "no-store" in directives + must_revalidate = "no-cache" in directives + max_age: int | None = None + + for directive in directives: + if directive.startswith("max-age="): + value = directive.removeprefix("max-age=").strip() + try: + max_age = max(0, int(value)) + except ValueError: + logger.debug( + "Ignoring invalid Cache-Control max-age value: %s", value + ) + break + + expires_at: float | None = None + if max_age is not None: + expires_at = now + max_age + elif "expires" in normalized: + try: + dt = parsedate_to_datetime(normalized["expires"]) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + expires_at = dt.timestamp() + except (TypeError, ValueError): + logger.debug( + "Ignoring invalid Expires header on CIMD response: %s", + normalized["expires"], + ) + + if expires_at is None: + expires_at = now + self.DEFAULT_CACHE_TTL_SECONDS + freshness_lifetime = max(0.0, expires_at - now) + + return _CIMDCachePolicy( + etag=normalized.get("etag"), + last_modified=normalized.get("last-modified"), + expires_at=expires_at, + freshness_lifetime=freshness_lifetime, + no_store=no_store, + must_revalidate=must_revalidate, + ) + + def _has_freshness_headers(self, headers: Mapping[str, str]) -> bool: + """Return True when response includes cache freshness directives.""" + normalized = {k.lower() for k in headers} + return "cache-control" in normalized or "expires" in normalized def is_cimd_client_id(self, client_id: str) -> bool: """Check if a client_id looks like a CIMD URL. @@ -200,7 +287,7 @@ class CIMDFetcher: async def fetch(self, client_id_url: str) -> CIMDDocument: """Fetch and validate a CIMD document with SSRF protection. - Uses ssrf_safe_fetch for the HTTP layer, which provides: + Uses ssrf_safe_fetch_response for the HTTP layer, which provides: - HTTPS only, DNS resolution with IP validation - DNS pinning (connects to validated IP directly) - Blocks private/loopback/link-local/multicast IPs @@ -218,26 +305,76 @@ class CIMDFetcher: CIMDFetchError: If document cannot be fetched """ cached = self._cache.get(client_id_url) + now = time.time() + request_headers: dict[str, str] | None = None + allowed_status_codes = {200} + if cached is not None: - doc, expires_at = cached - if time.time() < expires_at: - return doc + if not cached.must_revalidate and now < cached.expires_at: + return cached.doc + + request_headers = {} + if cached.etag: + request_headers["If-None-Match"] = cached.etag + if cached.last_modified: + request_headers["If-Modified-Since"] = cached.last_modified + if request_headers: + allowed_status_codes = {200, 304} try: - content = await ssrf_safe_fetch( + response = await ssrf_safe_fetch_response( client_id_url, require_path=True, max_size=self.MAX_RESPONSE_SIZE, timeout=self.timeout, overall_timeout=30.0, + request_headers=request_headers, + allowed_status_codes=allowed_status_codes, ) except SSRFError as e: raise CIMDValidationError(str(e)) from e except SSRFFetchError as e: raise CIMDFetchError(str(e)) from e + if response.status_code == 304: + if cached is None: + raise CIMDFetchError( + "CIMD server returned 304 Not Modified without cached document" + ) + + now = time.time() + if self._has_freshness_headers(response.headers): + policy = self._parse_cache_policy(response.headers, now) + else: + # RFC allows 304 to omit unchanged headers. Preserve existing + # cache policy rather than resetting to fallback defaults. + policy = _CIMDCachePolicy( + etag=None, + last_modified=None, + expires_at=now + cached.freshness_lifetime, + freshness_lifetime=cached.freshness_lifetime, + no_store=False, + must_revalidate=cached.must_revalidate, + ) + + if not policy.no_store: + self._cache[client_id_url] = _CIMDCacheEntry( + doc=cached.doc, + etag=policy.etag or cached.etag, + last_modified=policy.last_modified or cached.last_modified, + expires_at=policy.expires_at, + freshness_lifetime=policy.freshness_lifetime, + must_revalidate=policy.must_revalidate, + ) + else: + self._cache.pop(client_id_url, None) + return cached.doc + + now = time.time() + policy = self._parse_cache_policy(response.headers, now) + try: - data = json.loads(content) + data = json.loads(response.content) except json.JSONDecodeError as e: raise CIMDValidationError(f"CIMD document is not valid JSON: {e}") from e @@ -268,7 +405,18 @@ class CIMDFetcher: doc.client_name, ) - self._cache[client_id_url] = (doc, time.time() + self.DEFAULT_CACHE_TTL_SECONDS) + if not policy.no_store: + self._cache[client_id_url] = _CIMDCacheEntry( + doc=doc, + etag=policy.etag, + last_modified=policy.last_modified, + expires_at=policy.expires_at, + freshness_lifetime=policy.freshness_lifetime, + must_revalidate=policy.must_revalidate, + ) + else: + self._cache.pop(client_id_url, None) + return doc def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool: diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py index 575c846ba..7525b6a0b 100644 --- a/src/fastmcp/server/auth/oauth_proxy/models.py +++ b/src/fastmcp/server/auth/oauth_proxy/models.py @@ -181,12 +181,27 @@ class ProxyDCRClient(OAuthClientInformationFull): "redirect_uri must be specified when CIMD redirect_uris uses wildcards." ) try: - return AnyUrl(candidate) + resolved = AnyUrl(candidate) except Exception as e: raise InvalidRedirectUriError( f"Invalid CIMD redirect_uri: {e}" ) from e + # Respect proxy-level redirect URI restrictions even when the + # client omits redirect_uri and we fall back to CIMD defaults. + if ( + self.allowed_redirect_uri_patterns is not None + and not validate_redirect_uri( + redirect_uri=resolved, + allowed_patterns=self.allowed_redirect_uri_patterns, + ) + ): + raise InvalidRedirectUriError( + f"Redirect URI '{resolved}' does not match allowed patterns." + ) + + return resolved + raise InvalidRedirectUriError( "redirect_uri must be specified when CIMD lists multiple redirect_uris." ) @@ -207,7 +222,7 @@ class ProxyDCRClient(OAuthClientInformationFull): f"Redirect URI '{redirect_uri}' does not match CIMD redirect_uris." ) - if self.allowed_redirect_uri_patterns: + if self.allowed_redirect_uri_patterns is not None: if not validate_redirect_uri( redirect_uri=redirect_uri, allowed_patterns=self.allowed_redirect_uri_patterns, diff --git a/src/fastmcp/server/auth/ssrf.py b/src/fastmcp/server/auth/ssrf.py index 8009269c6..39c28e959 100644 --- a/src/fastmcp/server/auth/ssrf.py +++ b/src/fastmcp/server/auth/ssrf.py @@ -12,6 +12,7 @@ import asyncio import ipaddress import socket import time +from collections.abc import Mapping from dataclasses import dataclass from urllib.parse import urlparse @@ -134,6 +135,15 @@ class ValidatedURL: resolved_ips: list[str] +@dataclass +class SSRFFetchResponse: + """Response payload from an SSRF-safe fetch.""" + + content: bytes + status_code: int + headers: dict[str, str] + + async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: """Validate URL for SSRF and resolve to IPs. @@ -215,12 +225,39 @@ async def ssrf_safe_fetch( SSRFError: If SSRF validation fails SSRFFetchError: If fetch fails """ + response = await ssrf_safe_fetch_response( + url, + require_path=require_path, + max_size=max_size, + timeout=timeout, + overall_timeout=overall_timeout, + allowed_status_codes={200}, + ) + return response.content + + +async def ssrf_safe_fetch_response( + url: str, + *, + require_path: bool = False, + max_size: int = 5120, + timeout: float = 10.0, + overall_timeout: float = 30.0, + request_headers: Mapping[str, str] | None = None, + allowed_status_codes: set[int] | None = None, +) -> SSRFFetchResponse: + """Fetch URL with SSRF protection and return response metadata. + + This is equivalent to :func:`ssrf_safe_fetch` but returns response headers + and status code, and supports conditional request headers. + """ start_time = time.monotonic() # Validate URL and resolve DNS validated = await validate_url(url, require_path=require_path) last_error: Exception | None = None + expected_statuses = allowed_status_codes or {200} for pinned_ip in validated.resolved_ips: elapsed = time.monotonic() - start_time @@ -239,6 +276,14 @@ async def ssrf_safe_fetch( pinned_ip, ) + headers = {"Host": validated.hostname} + if request_headers: + for key, value in request_headers.items(): + # Host must remain pinned to the validated hostname. + if key.lower() == "host": + continue + headers[key] = value + try: # Use httpx with streaming to enforce size limit during download async with ( @@ -255,14 +300,14 @@ async def ssrf_safe_fetch( client.stream( "GET", pinned_url, - headers={"Host": validated.hostname}, + headers=headers, extensions={"sni_hostname": validated.hostname}, ) as response, ): if time.monotonic() - start_time > overall_timeout: raise SSRFFetchError(f"Overall timeout exceeded: {url}") - if response.status_code != 200: + if response.status_code not in expected_statuses: raise SSRFFetchError(f"HTTP {response.status_code} fetching {url}") # Check Content-Length header first if available @@ -290,7 +335,11 @@ async def ssrf_safe_fetch( ) chunks.append(chunk) - return b"".join(chunks) + return SSRFFetchResponse( + content=b"".join(chunks), + status_code=response.status_code, + headers=dict(response.headers), + ) except httpx.TimeoutException as e: last_error = e diff --git a/tests/server/auth/test_cimd.py b/tests/server/auth/test_cimd.py index d3c3e316e..111d863c7 100644 --- a/tests/server/auth/test_cimd.py +++ b/tests/server/auth/test_cimd.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from unittest.mock import AsyncMock, patch import pytest @@ -247,6 +248,243 @@ class TestCIMDFetcherHTTP: assert first.client_id == second.client_id assert len(httpx_mock.get_requests()) == 1 + async def test_fetch_cache_control_max_age( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Cache-Control max-age should prevent refetch before expiry.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Max-Age App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"cache-control": "max-age=60", "content-length": "200"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + + assert first.client_name == second.client_name + assert len(httpx_mock.get_requests()) == 1 + + async def test_fetch_etag_revalidation_304( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Expired cache should revalidate with ETag and accept 304.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "ETag App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "max-age=0", + "etag": '"v1"', + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={ + "cache-control": "max-age=120", + "etag": '"v1"', + "content-length": "0", + }, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "ETag App" + assert second.client_name == "ETag App" + assert len(requests) == 2 + assert requests[1].headers.get("if-none-match") == '"v1"' + + async def test_fetch_last_modified_revalidation_304( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Expired cache should revalidate with Last-Modified and accept 304.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Last-Modified App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + last_modified = "Wed, 21 Oct 2015 07:28:00 GMT" + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "max-age=0", + "last-modified": last_modified, + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={"cache-control": "max-age=120", "content-length": "0"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "Last-Modified App" + assert second.client_name == "Last-Modified App" + assert len(requests) == 2 + assert requests[1].headers.get("if-modified-since") == last_modified + + async def test_fetch_cache_control_no_store( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Cache-Control no-store should prevent storing CIMD documents.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "No-Store App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={"cache-control": "no-store", "content-length": "200"}, + ) + httpx_mock.add_response( + json=doc_data, + headers={"cache-control": "no-store", "content-length": "200"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + + assert first.client_name == second.client_name + assert len(httpx_mock.get_requests()) == 2 + + async def test_fetch_cache_control_no_cache( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """Cache-Control no-cache should force revalidation on each fetch.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "No-Cache App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "no-cache", + "etag": '"v2"', + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={ + "cache-control": "no-cache", + "etag": '"v2"', + "content-length": "0", + }, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "No-Cache App" + assert second.client_name == "No-Cache App" + assert len(requests) == 2 + assert requests[1].headers.get("if-none-match") == '"v2"' + + async def test_fetch_304_without_cache_headers_preserves_policy( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """304 responses without cache headers should not reset cached policy.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "No-Header-304 App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "no-cache", + "etag": '"v3"', + "content-length": "200", + }, + ) + # Intentionally omit cache-control/expires on 304. + httpx_mock.add_response( + status_code=304, + headers={"content-length": "0"}, + ) + httpx_mock.add_response( + status_code=304, + headers={"content-length": "0"}, + ) + + first = await fetcher.fetch(url) + second = await fetcher.fetch(url) + third = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "No-Header-304 App" + assert second.client_name == "No-Header-304 App" + assert third.client_name == "No-Header-304 App" + assert len(requests) == 3 + assert requests[1].headers.get("if-none-match") == '"v3"' + assert requests[2].headers.get("if-none-match") == '"v3"' + + async def test_fetch_304_without_cache_headers_refreshes_cached_freshness( + self, fetcher: CIMDFetcher, httpx_mock, mock_dns + ): + """A header-less 304 should renew freshness using cached lifetime.""" + url = "https://example.com/client.json" + doc_data = { + "client_id": url, + "client_name": "Headerless 304 Freshness App", + "redirect_uris": ["http://localhost:3000/callback"], + "token_endpoint_auth_method": "none", + } + httpx_mock.add_response( + json=doc_data, + headers={ + "cache-control": "max-age=60", + "etag": '"v4"', + "content-length": "200", + }, + ) + httpx_mock.add_response( + status_code=304, + headers={"content-length": "0"}, + ) + + first = await fetcher.fetch(url) + + # Simulate cache expiry so the next request triggers revalidation. + cached_entry = fetcher._cache[url] + cached_entry.expires_at = time.time() - 1 + + second = await fetcher.fetch(url) + third = await fetcher.fetch(url) + requests = httpx_mock.get_requests() + + assert first.client_name == "Headerless 304 Freshness App" + assert second.client_name == "Headerless 304 Freshness App" + assert third.client_name == "Headerless 304 Freshness App" + assert len(requests) == 2 + assert requests[1].headers.get("if-none-match") == '"v4"' + async def test_fetch_client_id_mismatch( self, fetcher: CIMDFetcher, httpx_mock, mock_dns ): diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 391977b88..47ecfbe8d 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -155,6 +155,23 @@ class TestProxyDCRClient: result = client.validate_redirect_uri(None) assert result == AnyUrl("http://localhost:3000/callback") + def test_cimd_none_redirect_uri_respects_proxy_patterns(self): + """CIMD fallback redirect_uri must still satisfy proxy allowlist patterns.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["https://evil.com/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + allowed_redirect_uri_patterns=["http://localhost:*"], + ) + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(None) + def test_cimd_none_redirect_uri_wildcard_rejected(self): """CIMD clients must specify redirect_uri when only wildcard patterns exist.""" cimd_doc = CIMDDocument( @@ -171,6 +188,23 @@ class TestProxyDCRClient: with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(None) + def test_cimd_empty_proxy_allowlist_rejects_redirect_uri(self): + """An explicit empty proxy allowlist should reject all CIMD redirect URIs.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost:3000/callback"], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + allowed_redirect_uri_patterns=[], + ) + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:3000/callback")) + class TestOAuthProxyRedirectValidation: """Test OAuth proxy with redirect URI validation.""" From 931d6f878cf394d78ba5a3e7c7950885cdcd156b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:08:50 -0500 Subject: [PATCH 27/63] Remove require_auth; fix auth docs re: component-level enforcement (#3103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code https://claude.ai/code/session_01WWzwcBfLWnxoN9XNs5Fhxr Co-authored-by: Claude --- docs/development/v3-notes/v3-features.mdx | 11 ++- docs/servers/authorization.mdx | 90 ++++++++++--------- src/fastmcp/server/auth/__init__.py | 2 - src/fastmcp/server/auth/authorization.py | 20 +---- .../server/middleware/authorization.py | 15 ++-- tests/server/auth/test_authorization.py | 66 ++++++-------- 6 files changed, 88 insertions(+), 116 deletions(-) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 05399fce6..432b191bd 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -778,11 +778,11 @@ v3.0 introduces callable-based authorization for tools, resources, and prompts ( ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth, require_scopes +from fastmcp.server.auth import require_scopes mcp = FastMCP() -@mcp.tool(auth=require_auth) +@mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) @@ -796,10 +796,10 @@ def admin_prompt(): ... ```python from fastmcp.server.middleware import AuthMiddleware -from fastmcp.server.auth import require_auth, restrict_tag +from fastmcp.server.auth import require_scopes, restrict_tag -# Require auth for all components -mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) +# Require specific scope for all components +mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) # Tag-based restrictions mcp = FastMCP(middleware=[ @@ -808,7 +808,6 @@ mcp = FastMCP(middleware=[ ``` Built-in checks: -- `require_auth`: Requires any valid token - `require_scopes(*scopes)`: Requires specific OAuth scopes - `restrict_tag(tag, scopes)`: Requires scopes only for tagged components diff --git a/docs/servers/authorization.mdx b/docs/servers/authorization.mdx index ac65f21cb..0ad0d569e 100644 --- a/docs/servers/authorization.mdx +++ b/docs/servers/authorization.mdx @@ -18,6 +18,10 @@ The authorization model centers on a simple concept: callable functions that rec Authorization relies on OAuth tokens which are only available with HTTP transports (SSE, Streamable HTTP). In STDIO mode, there's no OAuth mechanism, so `get_access_token()` returns `None` and all auth checks are skipped. + +When an `AuthProvider` is configured, all requests to the MCP endpoint must carry a valid token—unauthenticated requests are rejected at the transport level before any auth checks run. Authorization checks therefore differentiate between authenticated users based on their scopes and claims, not between authenticated and unauthenticated users. + + ## Auth Checks An auth check is any callable that accepts an `AuthContext` and returns a boolean. The `AuthContext` provides access to the current token (if any) and the component being accessed. @@ -31,27 +35,11 @@ def my_custom_check(ctx: AuthContext) -> bool: return ctx.token is not None and "special" in ctx.token.scopes ``` -FastMCP provides three built-in auth checks that cover common authorization patterns. - -### require_auth - -The simplest check verifies that any valid authentication token is present. Unauthenticated requests are denied. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_auth - -mcp = FastMCP("Protected Server") - -@mcp.tool(auth=require_auth) -def protected_operation() -> str: - """Only accessible to authenticated users.""" - return "Success" -``` +FastMCP provides two built-in auth checks that cover common authorization patterns. ### require_scopes -For scope-based authorization, `require_scopes` checks that the token contains all specified OAuth scopes. When multiple scopes are provided, all must be present (AND logic). +Scope-based authorization checks that the token contains all specified OAuth scopes. When multiple scopes are provided, all must be present (AND logic). ```python from fastmcp import FastMCP @@ -103,13 +91,13 @@ Multiple auth checks can be combined by passing a list. All checks must pass for ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth, require_scopes +from fastmcp.server.auth import require_scopes mcp = FastMCP("Combined Auth Server") -@mcp.tool(auth=[require_auth, require_scopes("admin")]) +@mcp.tool(auth=[require_scopes("admin"), require_scopes("write")]) def secure_admin_action() -> str: - """Requires authentication AND the 'admin' scope.""" + """Requires both 'admin' AND 'write' scopes.""" return "Secure admin action" ``` @@ -169,18 +157,18 @@ def require_verified_email(ctx: AuthContext) -> bool: ## Component-Level Authorization -The `auth` parameter on decorators controls visibility of individual components. When auth checks fail for the current request, the component is hidden from list responses—it simply doesn't appear. +The `auth` parameter on decorators controls visibility and access for individual components. When auth checks fail for the current request, the component is hidden from list responses and direct access returns not-found. ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth, require_scopes +from fastmcp.server.auth import require_scopes mcp = FastMCP("Component Auth Server") -@mcp.tool(auth=require_auth) -def authenticated_tool() -> str: - """Only visible to authenticated users.""" - return "Authenticated" +@mcp.tool(auth=require_scopes("write")) +def write_tool() -> str: + """Only visible to users with 'write' scope.""" + return "Written" @mcp.resource("secret://data", auth=require_scopes("read")) def secret_resource() -> str: @@ -193,38 +181,59 @@ def admin_prompt() -> str: return "Admin prompt content" ``` - -Component-level `auth` only controls visibility in list operations. It does not block direct access. Use `AuthMiddleware` to enforce authorization on execution. - + +Component-level `auth` controls both visibility (list filtering) and access (direct lookups return not-found for unauthorized requests). Additionally use `AuthMiddleware` to apply server-wide authorization rules and get explicit `AuthorizationError` responses on unauthorized execution attempts. + ## Server-Level Authorization -For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution. +For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. ```python from fastmcp import FastMCP -from fastmcp.server.auth import require_auth +from fastmcp.server.auth import require_scopes from fastmcp.server.middleware import AuthMiddleware mcp = FastMCP( "Enforced Auth Server", - middleware=[AuthMiddleware(auth=require_auth)] + middleware=[AuthMiddleware(auth=require_scopes("api"))] ) @mcp.tool def any_tool() -> str: - """Requires authentication to see AND call.""" + """Requires 'api' scope to see AND call.""" return "Protected" ``` -### Filtering vs Enforcement +### Component Auth + Middleware -| Behavior | Component-level `auth` | `AuthMiddleware` | -|----------|------------------------|------------------| -| Filters list responses | Yes | Yes | -| Blocks execution | No | Yes (raises `AuthorizationError`) | +Component-level `auth` and `AuthMiddleware` work together as complementary layers. The middleware applies server-wide rules to all components, while component-level auth adds per-component requirements. Both layers are checked—all checks must pass. -Component-level auth is useful for hiding components from unauthorized users while still allowing advanced clients to access them directly. `AuthMiddleware` provides complete enforcement by raising `AuthorizationError` when unauthorized requests attempt execution. +```python +from fastmcp import FastMCP +from fastmcp.server.auth import require_scopes, restrict_tag +from fastmcp.server.middleware import AuthMiddleware + +mcp = FastMCP( + "Layered Auth Server", + middleware=[ + AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) + ] +) + +# Requires "write" scope (component-level) +# Also requires "admin" scope if tagged "admin" (middleware-level) +@mcp.tool(auth=require_scopes("write"), tags={"admin"}) +def admin_write() -> str: + """Requires both 'write' AND 'admin' scopes.""" + return "Admin write" + +# Requires "write" scope (component-level only) +@mcp.tool(auth=require_scopes("write")) +def user_write() -> str: + """Requires 'write' scope.""" + return "User write" +``` ### Tag-Based Global Authorization @@ -338,7 +347,6 @@ from fastmcp.server.auth import ( AccessToken, # Token with .token, .client_id, .scopes, .expires_at, .claims AuthContext, # Context with .token, .component AuthCheck, # Type alias: Callable[[AuthContext], bool] - require_auth, # Built-in: requires any valid token require_scopes, # Built-in: requires specific scopes restrict_tag, # Built-in: tag-based scope requirements run_auth_checks, # Utility: run checks with AND logic diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index d8d221a3d..94e23dca6 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -8,7 +8,6 @@ from .auth import ( from .authorization import ( AuthCheck, AuthContext, - require_auth, require_scopes, restrict_tag, run_auth_checks, @@ -32,7 +31,6 @@ __all__ = [ "RemoteAuthProvider", "StaticTokenVerifier", "TokenVerifier", - "require_auth", "require_scopes", "restrict_tag", "run_auth_checks", diff --git a/src/fastmcp/server/auth/authorization.py b/src/fastmcp/server/auth/authorization.py index ae9e64a5b..dd0e16cc1 100644 --- a/src/fastmcp/server/auth/authorization.py +++ b/src/fastmcp/server/auth/authorization.py @@ -11,17 +11,17 @@ Auth checks can also raise exceptions: Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes + from fastmcp.server.auth import require_scopes mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) def secret_data(): ... - @mcp.prompt(auth=require_auth) + @mcp.prompt(auth=require_scopes("admin")) def admin_prompt(): ... ``` """ @@ -74,20 +74,6 @@ class AuthContext: AuthCheck = Callable[[AuthContext], bool] -def require_auth(ctx: AuthContext) -> bool: - """Require any valid authentication. - - Returns True if the request has a valid token, False otherwise. - - Example: - ```python - @mcp.tool(auth=require_auth) - def protected_tool(): ... - ``` - """ - return ctx.token is not None - - def require_scopes(*scopes: str) -> AuthCheck: """Require specific OAuth scopes. diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py index 46038f6c9..6a50ed656 100644 --- a/src/fastmcp/server/middleware/authorization.py +++ b/src/fastmcp/server/middleware/authorization.py @@ -6,12 +6,12 @@ AuthMiddleware applies auth checks globally to all components on the server. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes, restrict_tag + from fastmcp.server.auth import require_scopes, restrict_tag from fastmcp.server.middleware import AuthMiddleware - # Require auth for all components + # Require specific scope for all components mcp = FastMCP(middleware=[ - AuthMiddleware(auth=require_auth) + AuthMiddleware(auth=require_scopes("api")) ]) # Tag-based: components tagged "admin" require "admin" scope @@ -67,17 +67,14 @@ class AuthMiddleware(Middleware): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes - - # Require any authentication for all components - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + from fastmcp.server.auth import require_scopes # Require specific scope for all components mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) - # Combined checks (AND logic) + # Multiple scopes (AND logic) mcp = FastMCP(middleware=[ - AuthMiddleware(auth=[require_auth, require_scopes("api")]) + AuthMiddleware(auth=require_scopes("read", "api")) ]) ``` """ diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index 6eaaede32..6bab4cecd 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -12,7 +12,6 @@ from fastmcp.client import Client from fastmcp.server.auth import ( AccessToken, AuthContext, - require_auth, require_scopes, restrict_tag, run_auth_checks, @@ -42,21 +41,6 @@ def make_tool() -> Mock: return tool -# ============================================================================= -# Tests for require_auth -# ============================================================================= - - -class TestRequireAuth: - def test_returns_true_with_token(self): - ctx = AuthContext(token=make_token(), component=make_tool()) - assert require_auth(ctx) is True - - def test_returns_false_without_token(self): - ctx = AuthContext(token=None, component=make_tool()) - assert require_auth(ctx) is False - - # ============================================================================= # Tests for require_scopes # ============================================================================= @@ -137,23 +121,23 @@ class TestRestrictTag: class TestRunAuthChecks: def test_single_check_passes(self): - ctx = AuthContext(token=make_token(), component=make_tool()) - assert run_auth_checks(require_auth, ctx) is True + ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool()) + assert run_auth_checks(require_scopes("test"), ctx) is True def test_single_check_fails(self): ctx = AuthContext(token=None, component=make_tool()) - assert run_auth_checks(require_auth, ctx) is False + assert run_auth_checks(require_scopes("test"), ctx) is False def test_multiple_checks_all_pass(self): - token = make_token(scopes=["admin"]) + token = make_token(scopes=["test", "admin"]) ctx = AuthContext(token=token, component=make_tool()) - checks = [require_auth, require_scopes("admin")] + checks = [require_scopes("test"), require_scopes("admin")] assert run_auth_checks(checks, ctx) is True def test_multiple_checks_one_fails(self): token = make_token(scopes=["read"]) ctx = AuthContext(token=token, component=make_tool()) - checks = [require_auth, require_scopes("admin")] + checks = [require_scopes("read"), require_scopes("admin")] assert run_auth_checks(checks, ctx) is False def test_empty_list_passes(self): @@ -244,7 +228,7 @@ class TestToolLevelAuth: async def test_tool_with_auth_hidden_without_token(self): mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" @@ -255,12 +239,12 @@ class TestToolLevelAuth: async def test_tool_with_auth_visible_with_token(self): mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" # Set token in context - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: tools = await mcp.list_tools() @@ -306,7 +290,7 @@ class TestToolLevelAuth: """get_tool() returns None for unauthorized tools (consistent with list filtering).""" mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" @@ -317,11 +301,11 @@ class TestToolLevelAuth: async def test_get_tool_returns_tool_with_auth(self): mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: tool = await mcp.get_tool("protected_tool") @@ -344,7 +328,7 @@ class TestAuthMiddleware: """ async def test_middleware_filters_tools_without_token(self): - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def public_tool() -> str: @@ -355,13 +339,13 @@ class TestAuthMiddleware: assert len(result.tools) == 0 async def test_middleware_allows_tools_with_token(self): - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def public_tool() -> str: return "public" - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) @@ -435,7 +419,7 @@ class TestAuthIntegration: def public_tool() -> str: return "public" - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" @@ -452,12 +436,12 @@ class TestAuthIntegration: def public_tool() -> str: return "public" - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool() -> str: return "protected" # Set token before creating client - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: async with Client(mcp) as client: @@ -482,7 +466,7 @@ class TestTransformedToolAuth: mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool(x: int) -> str: return str(x) @@ -507,7 +491,7 @@ class TestTransformedToolAuth: mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool(x: int) -> str: return str(x) @@ -526,7 +510,7 @@ class TestTransformedToolAuth: mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("test")) def protected_tool(x: int) -> str: return str(x) @@ -536,7 +520,7 @@ class TestTransformedToolAuth: ) # With token, transformed tool should be visible - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: tools = await mcp.list_tools() @@ -555,7 +539,7 @@ class TestAuthMiddlewareCallTool: async def test_middleware_blocks_call_without_auth(self): """AuthMiddleware should raise AuthorizationError on unauthorized call.""" - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def my_tool() -> str: @@ -573,14 +557,14 @@ class TestAuthMiddlewareCallTool: async def test_middleware_allows_call_with_auth(self): """AuthMiddleware should allow tool call with valid token.""" - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)]) + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))]) @mcp.tool def my_tool() -> str: return "result" # With token, calling the tool should succeed - token = make_token() + token = make_token(scopes=["test"]) tok = set_token(token) try: async with Client(mcp) as client: From d12d46b049762db4114dd266e1b5799981377875 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:19:36 -0500 Subject: [PATCH 28/63] Exclude content-type header from get_http_headers() to prevent HTTP 415 errors (#3104) Fixes #3097 When using FastMCP.from_openapi() with APIs that require specific Content-Type headers (e.g., application/vnd.api+json), the transport connection's content-type: application/json was being injected into downstream API requests, causing HTTP 415 (Unsupported Media Type) errors. This change adds content-type to the exclude_headers set in get_http_headers(), similar to how accept is already excluded. The MCP transport's content type has no relevance to downstream API calls and should not be forwarded. Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/server/dependencies.py | 1 + tests/server/http/test_http_dependencies.py | 40 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index ce964fd31..ffef2561b 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -441,6 +441,7 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: exclude_headers = { "host", "content-length", + "content-type", "connection", "transfer-encoding", "upgrade", diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index e379f5e83..e637af269 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -126,3 +126,43 @@ async def test_http_headers_prompt_sse(sse_server: str): json_result = json.loads(result.messages[0].content.text) assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" + + +async def test_get_http_headers_excludes_content_type(sse_server: str): + """Test that get_http_headers() excludes content-type header (issue #3097). + + This prevents HTTP 415 errors when forwarding headers to downstream APIs + that require specific Content-Type headers (e.g., application/vnd.api+json). + """ + from fastmcp.server.dependencies import get_http_headers + + server = FastMCP() + + @server.tool + def check_excluded_headers() -> dict[str, str]: + """Check that problematic headers are excluded from get_http_headers().""" + return get_http_headers() + + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport( + url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Custom-Header": "should-be-included", + }, + ) + ) as client: + result = await client.call_tool("check_excluded_headers") + headers = result.data + + # These headers should be excluded + assert "content-type" not in headers + assert "accept" not in headers + assert "host" not in headers + assert "content-length" not in headers + + # Custom headers should be included + assert "x-custom-header" in headers + assert headers["x-custom-header"] == "should-be-included" From 3e3ed76a8c1ced5d52e2209840a02dad77643b08 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:27:29 -0500 Subject: [PATCH 29/63] chore: Update SDK documentation (#3089) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/docs.json | 7 +- docs/python-sdk/fastmcp-cli-auth.mdx | 9 + docs/python-sdk/fastmcp-cli-cimd.mdx | 43 ++++ docs/python-sdk/fastmcp-cli-cli.mdx | 12 +- docs/python-sdk/fastmcp-client-auth-oauth.mdx | 6 +- ...mcp-client-sampling-handlers-anthropic.mdx | 2 +- .../fastmcp-client-transports-http.mdx | 6 +- .../fastmcp-client-transports-sse.mdx | 2 +- docs/python-sdk/fastmcp-server-auth-auth.mdx | 68 +++-- .../fastmcp-server-auth-authorization.mdx | 24 +- docs/python-sdk/fastmcp-server-auth-cimd.mdx | 242 ++++++++++++++++++ ...astmcp-server-auth-oauth_proxy-consent.mdx | 2 +- ...fastmcp-server-auth-oauth_proxy-models.mdx | 25 +- .../fastmcp-server-auth-oauth_proxy-proxy.mdx | 27 +- .../fastmcp-server-auth-oauth_proxy-ui.mdx | 4 +- .../fastmcp-server-auth-oidc_proxy.mdx | 4 +- .../fastmcp-server-auth-providers-jwt.mdx | 20 +- ...astmcp-server-auth-redirect_validation.mdx | 18 +- docs/python-sdk/fastmcp-server-auth-ssrf.mdx | 172 +++++++++++++ .../fastmcp-server-dependencies.mdx | 50 ++-- ...astmcp-server-middleware-authorization.mdx | 20 +- ...cp-server-middleware-response_limiting.mdx | 32 +++ ...cp-server-providers-openapi-components.mdx | 12 +- ...tmcp-server-providers-openapi-provider.mdx | 6 +- .../fastmcp-tools-tool_transform.mdx | 6 +- .../fastmcp-utilities-json_schema.mdx | 6 +- .../fastmcp-utilities-openapi-parser.mdx | 2 +- 27 files changed, 681 insertions(+), 146 deletions(-) create mode 100644 docs/python-sdk/fastmcp-cli-auth.mdx create mode 100644 docs/python-sdk/fastmcp-cli-cimd.mdx create mode 100644 docs/python-sdk/fastmcp-server-auth-cimd.mdx create mode 100644 docs/python-sdk/fastmcp-server-auth-ssrf.mdx create mode 100644 docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx diff --git a/docs/docs.json b/docs/docs.json index b9c1bc4dd..2864855fe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -302,6 +302,8 @@ "group": "fastmcp.cli", "pages": [ "python-sdk/fastmcp-cli-__init__", + "python-sdk/fastmcp-cli-auth", + "python-sdk/fastmcp-cli-cimd", "python-sdk/fastmcp-cli-cli", "python-sdk/fastmcp-cli-client", "python-sdk/fastmcp-cli-discovery", @@ -413,6 +415,7 @@ "python-sdk/fastmcp-server-auth-__init__", "python-sdk/fastmcp-server-auth-auth", "python-sdk/fastmcp-server-auth-authorization", + "python-sdk/fastmcp-server-auth-cimd", "python-sdk/fastmcp-server-auth-jwt_issuer", "python-sdk/fastmcp-server-auth-middleware", { @@ -447,7 +450,8 @@ "python-sdk/fastmcp-server-auth-providers-workos" ] }, - "python-sdk/fastmcp-server-auth-redirect_validation" + "python-sdk/fastmcp-server-auth-redirect_validation", + "python-sdk/fastmcp-server-auth-ssrf" ] }, "python-sdk/fastmcp-server-context", @@ -468,6 +472,7 @@ "python-sdk/fastmcp-server-middleware-middleware", "python-sdk/fastmcp-server-middleware-ping", "python-sdk/fastmcp-server-middleware-rate_limiting", + "python-sdk/fastmcp-server-middleware-response_limiting", "python-sdk/fastmcp-server-middleware-timing", "python-sdk/fastmcp-server-middleware-tool_injection" ] diff --git a/docs/python-sdk/fastmcp-cli-auth.mdx b/docs/python-sdk/fastmcp-cli-auth.mdx new file mode 100644 index 000000000..586a53505 --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-auth.mdx @@ -0,0 +1,9 @@ +--- +title: auth +sidebarTitle: auth +--- + +# `fastmcp.cli.auth` + + +Authentication-related CLI commands. diff --git a/docs/python-sdk/fastmcp-cli-cimd.mdx b/docs/python-sdk/fastmcp-cli-cimd.mdx new file mode 100644 index 000000000..8f69aae9d --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-cimd.mdx @@ -0,0 +1,43 @@ +--- +title: cimd +sidebarTitle: cimd +--- + +# `fastmcp.cli.cimd` + + +CIMD (Client ID Metadata Document) CLI commands. + +## Functions + +### `create_command` + +```python +create_command() -> None +``` + + +Generate a CIMD document for hosting. + +Create a Client ID Metadata Document that you can host at an HTTPS URL. +The URL where you host this document becomes your client_id. + +After creating the document, host it at an HTTPS URL with a non-root path, +for example: https://myapp.example.com/oauth/client.json + + +### `validate_command` + +```python +validate_command(url: Annotated[str, cyclopts.Parameter(help='URL of the CIMD document to validate')]) -> None +``` + + +Validate a hosted CIMD document. + +Fetches the document from the given URL and validates: +- URL is valid CIMD URL (HTTPS, non-root path) +- Document is valid JSON +- Document conforms to CIMD schema +- client_id in document matches the URL + diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 26e8f0621..fca179c65 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index 3c83f641e..b9d06beb7 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -73,7 +73,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -82,7 +82,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -91,7 +91,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx index b1c9af0a9..905a25ef6 100644 --- a/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx +++ b/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx @@ -10,7 +10,7 @@ Anthropic sampling handler for FastMCP. ## Classes -### `AnthropicSamplingHandler` +### `AnthropicSamplingHandler` Sampling handler that uses the Anthropic API. diff --git a/docs/python-sdk/fastmcp-client-transports-http.mdx b/docs/python-sdk/fastmcp-client-transports-http.mdx index e5ba1599a..163f02c5e 100644 --- a/docs/python-sdk/fastmcp-client-transports-http.mdx +++ b/docs/python-sdk/fastmcp-client-transports-http.mdx @@ -18,19 +18,19 @@ Transport implementation that connects to an MCP server via Streamable HTTP Requ **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `get_session_id` +#### `get_session_id` ```python get_session_id(self) -> str | None ``` -#### `close` +#### `close` ```python close(self) diff --git a/docs/python-sdk/fastmcp-client-transports-sse.mdx b/docs/python-sdk/fastmcp-client-transports-sse.mdx index 2f3449e2d..c1e1841de 100644 --- a/docs/python-sdk/fastmcp-client-transports-sse.mdx +++ b/docs/python-sdk/fastmcp-client-transports-sse.mdx @@ -18,7 +18,7 @@ Transport implementation that connects to an MCP server via Server-Sent Events. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index 723b5a08f..285c8aa29 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -7,13 +7,13 @@ sidebarTitle: auth ## Classes -### `AccessToken` +### `AccessToken` AccessToken that includes all JWT claims. -### `TokenHandler` +### `TokenHandler` TokenHandler that returns MCP-compliant error responses. @@ -33,7 +33,7 @@ This handler transforms responses to be compliant with both OAuth 2.1 and MCP sp **Methods:** -#### `handle` +#### `handle` ```python handle(self, request: Any) @@ -42,7 +42,37 @@ handle(self, request: Any) Wrap SDK handle() and transform auth error responses. -### `AuthProvider` +### `PrivateKeyJWTClientAuthenticator` + + +Client authenticator with private_key_jwt support for CIMD clients. + +Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt` +authentication method per RFC 7523. This is required for CIMD (Client ID Metadata +Document) clients that use asymmetric keys for authentication. + +The authenticator: +1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none) +2. Adds private_key_jwt handling for CIMD clients +3. Validates JWT assertions against client's JWKS + + +**Methods:** + +#### `authenticate_request` + +```python +authenticate_request(self, request: Request) -> OAuthClientInformationFull +``` + +Authenticate a client from an HTTP request. + +Extends SDK authentication to support private_key_jwt for CIMD clients. +Delegates to SDK for client_secret_basic (Authorization header) and +client_secret_post (form body) authentication. + + +### `AuthProvider` Base class for all FastMCP authentication providers. @@ -55,7 +85,7 @@ custom authentication routes. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -72,7 +102,7 @@ All auth providers must implement token verification. - AccessToken object if valid, None if invalid or expired -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -89,7 +119,7 @@ MCP endpoint path. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -113,7 +143,7 @@ provider does not create the actual MCP endpoint route. - List of all routes for this provider (excluding the MCP endpoint itself) -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] @@ -141,7 +171,7 @@ This is used to construct path-scoped well-known URLs. - List of well-known discovery routes (typically mounted at root level) -#### `get_middleware` +#### `get_middleware` ```python get_middleware(self) -> list @@ -153,7 +183,7 @@ Get HTTP application-level middleware for this auth provider. - List of Starlette Middleware instances to apply to the HTTP app -### `TokenVerifier` +### `TokenVerifier` Base class for token verifiers (Resource Servers). @@ -164,7 +194,7 @@ Token verifiers typically don't provide authentication routes by default. **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] @@ -178,7 +208,7 @@ where tokens contain short-form scopes but clients request full URI scopes). -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -187,7 +217,7 @@ verify_token(self, token: str) -> AccessToken | None Verify a bearer token and return access info if valid. -### `RemoteAuthProvider` +### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. @@ -204,7 +234,7 @@ the authorization servers that issue valid tokens. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -213,7 +243,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token using the configured token verifier. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -224,7 +254,7 @@ Get routes for this provider. Creates protected resource metadata routes (RFC 9728). -### `OAuthProvider` +### `OAuthProvider` OAuth Authorization Server provider. @@ -235,7 +265,7 @@ authorization flows, token issuance, and token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -253,7 +283,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -269,7 +299,7 @@ This method creates the full set of OAuth routes including: - List of OAuth routes -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-authorization.mdx b/docs/python-sdk/fastmcp-server-auth-authorization.mdx index d8e0611a9..6268118d8 100644 --- a/docs/python-sdk/fastmcp-server-auth-authorization.mdx +++ b/docs/python-sdk/fastmcp-server-auth-authorization.mdx @@ -19,36 +19,24 @@ Auth checks can also raise exceptions: Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes + from fastmcp.server.auth import require_scopes mcp = FastMCP() - @mcp.tool(auth=require_auth) + @mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) def secret_data(): ... - @mcp.prompt(auth=require_auth) + @mcp.prompt(auth=require_scopes("admin")) def admin_prompt(): ... ``` ## Functions -### `require_auth` - -```python -require_auth(ctx: AuthContext) -> bool -``` - - -Require any valid authentication. - -Returns True if the request has a valid token, False otherwise. - - -### `require_scopes` +### `require_scopes` ```python require_scopes(*scopes: str) -> AuthCheck @@ -64,7 +52,7 @@ in the token (AND logic). - `*scopes`: One or more scope strings that must all be present. -### `restrict_tag` +### `restrict_tag` ```python restrict_tag(tag: str) -> AuthCheck @@ -81,7 +69,7 @@ required scopes. If the component doesn't have the tag, access is allowed. - `scopes`: List of scopes required when the tag is present. -### `run_auth_checks` +### `run_auth_checks` ```python run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool diff --git a/docs/python-sdk/fastmcp-server-auth-cimd.mdx b/docs/python-sdk/fastmcp-server-auth-cimd.mdx new file mode 100644 index 000000000..c6d72ea6e --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-cimd.mdx @@ -0,0 +1,242 @@ +--- +title: cimd +sidebarTitle: cimd +--- + +# `fastmcp.server.auth.cimd` + + +CIMD (Client ID Metadata Document) support for FastMCP. + +.. warning:: + **Beta Feature**: CIMD support is currently in beta. The API may change + in future releases. Please report any issues you encounter. + +CIMD is a simpler alternative to Dynamic Client Registration where clients +host a static JSON document at an HTTPS URL, and that URL becomes their +client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document + +This module provides: +- CIMDDocument: Pydantic model for CIMD document validation +- CIMDFetcher: Fetch and validate CIMD documents with SSRF protection +- CIMDClientManager: Manages CIMD client operations + + +## Classes + +### `CIMDDocument` + + +CIMD document per draft-parecki-oauth-client-id-metadata-document. + +The client metadata document is a JSON document containing OAuth client +metadata. The client_id property MUST match the URL where this document +is hosted. + +Key constraint: token_endpoint_auth_method MUST NOT use shared secrets +(client_secret_post, client_secret_basic, client_secret_jwt). + +redirect_uris is required and must contain at least one entry. + + +**Methods:** + +#### `validate_auth_method` + +```python +validate_auth_method(cls, v: str) -> str +``` + +Ensure no shared-secret auth methods are used. + + +#### `validate_redirect_uris` + +```python +validate_redirect_uris(cls, v: list[str]) -> list[str] +``` + +Ensure redirect_uris is non-empty and each entry is a valid URI. + + +### `CIMDValidationError` + + +Raised when CIMD document validation fails. + + +### `CIMDFetchError` + + +Raised when CIMD document fetching fails. + + +### `CIMDFetcher` + + +Fetch and validate CIMD documents with SSRF protection. + +Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS +pinning, IP validation, size limits, and timeout enforcement. Documents are +cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with +a TTL fallback when response headers do not define caching behavior. + + +**Methods:** + +#### `is_cimd_client_id` + +```python +is_cimd_client_id(self, client_id: str) -> bool +``` + +Check if a client_id looks like a CIMD URL. + +CIMD URLs must be HTTPS with a host and non-root path. + + +#### `fetch` + +```python +fetch(self, client_id_url: str) -> CIMDDocument +``` + +Fetch and validate a CIMD document with SSRF protection. + +Uses ssrf_safe_fetch_response for the HTTP layer, which provides: +- HTTPS only, DNS resolution with IP validation +- DNS pinning (connects to validated IP directly) +- Blocks private/loopback/link-local/multicast IPs +- Response size limit and timeout enforcement +- Redirects disabled + +**Args:** +- `client_id_url`: The URL to fetch (also the expected client_id) + +**Returns:** +- Validated CIMDDocument + +**Raises:** +- `CIMDValidationError`: If document is invalid or URL blocked +- `CIMDFetchError`: If document cannot be fetched + + +#### `validate_redirect_uri` + +```python +validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool +``` + +Validate that a redirect_uri is allowed by the CIMD document. + +**Args:** +- `doc`: The CIMD document +- `redirect_uri`: The redirect URI to validate + +**Returns:** +- True if valid, False otherwise + + +### `CIMDAssertionValidator` + + +Validates JWT assertions for private_key_jwt CIMD clients. + +Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client +Authentication and Authorization Grants) for CIMD client authentication. + +JTI replay protection uses TTL-based caching to ensure proper security: +- JTIs are cached with expiration matching the JWT's exp claim +- Expired JTIs are automatically cleaned up +- Maximum assertion lifetime is enforced (5 minutes) + + +**Methods:** + +#### `validate_assertion` + +```python +validate_assertion(self, assertion: str, client_id: str, token_endpoint: str, cimd_doc: CIMDDocument) -> bool +``` + +Validate JWT assertion from client. + +**Args:** +- `assertion`: The JWT assertion string +- `client_id`: Expected client_id (must match iss and sub claims) +- `token_endpoint`: Token endpoint URL (must match aud claim) +- `cimd_doc`: CIMD document containing JWKS for key verification + +**Returns:** +- True if valid + +**Raises:** +- `ValueError`: If validation fails + + +### `CIMDClientManager` + + +Manages all CIMD client operations for OAuth proxy. + +This class encapsulates: +- CIMD client detection +- Document fetching and validation +- Synthetic OAuth client creation +- Private key JWT assertion validation + +This allows the OAuth proxy to delegate all CIMD-specific logic to a +single, focused manager class. + + +**Methods:** + +#### `is_cimd_client_id` + +```python +is_cimd_client_id(self, client_id: str) -> bool +``` + +Check if client_id is a CIMD URL. + +**Args:** +- `client_id`: Client ID to check + +**Returns:** +- True if client_id is an HTTPS URL (CIMD format) + + +#### `get_client` + +```python +get_client(self, client_id_url: str) +``` + +Fetch CIMD document and create synthetic OAuth client. + +**Args:** +- `client_id_url`: HTTPS URL pointing to CIMD document + +**Returns:** +- OAuthProxyClient with CIMD document attached, or None if fetch fails + + +#### `validate_private_key_jwt` + +```python +validate_private_key_jwt(self, assertion: str, client, token_endpoint: str) -> bool +``` + +Validate JWT assertion for private_key_jwt auth. + +**Args:** +- `assertion`: JWT assertion string from client +- `client`: OAuth proxy client (must have cimd_document) +- `token_endpoint`: Token endpoint URL for aud validation + +**Returns:** +- True if assertion is valid + +**Raises:** +- `ValueError`: If client doesn't have CIMD document or validation fails + diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx index 0b9f709f1..6e77a1079 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx @@ -15,7 +15,7 @@ cookie management, and consent page rendering. ## Classes -### `ConsentMixin` +### `ConsentMixin` Mixin class providing consent management functionality for OAuthProxy. diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx index 9de2cf8d2..bca8b088e 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx @@ -13,7 +13,7 @@ This module contains all Pydantic models and constants used by the OAuth proxy. ## Classes -### `OAuthTransaction` +### `OAuthTransaction` OAuth transaction state for consent flow. @@ -22,7 +22,7 @@ Stored server-side to track active authorization flows with client context. Includes CSRF tokens for consent protection per MCP security best practices. -### `ClientCode` +### `ClientCode` Client authorization code with PKCE and upstream tokens. @@ -31,7 +31,7 @@ Stored server-side after upstream IdP callback. Contains the upstream tokens bound to the client's PKCE challenge for secure token exchange. -### `UpstreamTokenSet` +### `UpstreamTokenSet` Stored upstream OAuth tokens from identity provider. @@ -41,7 +41,7 @@ and stored in plaintext within this model. Encryption is handled transparently at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. -### `JTIMapping` +### `JTIMapping` Maps FastMCP token JTI to upstream token ID. @@ -50,7 +50,7 @@ This allows stateless JWT validation while still being able to look up the corresponding upstream token when tools need to access upstream APIs. -### `RefreshTokenMetadata` +### `RefreshTokenMetadata` Metadata for a refresh token, stored keyed by token hash. @@ -59,7 +59,7 @@ We store only metadata (not the token itself) for security - if storage is compromised, attackers get hashes they can't reverse into usable tokens. -### `ProxyDCRClient` +### `ProxyDCRClient` Client for DCR proxy with configurable redirect URI validation. @@ -89,16 +89,17 @@ arise from accepting arbitrary redirect URIs. **Methods:** -#### `validate_redirect_uri` +#### `validate_redirect_uri` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl ``` -Validate redirect URI against allowed patterns. +Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. -Since we're acting as a proxy and clients register dynamically, -we validate their redirect URIs against configurable patterns. -This is essential for cached token scenarios where the client may -reconnect with a different port. +For CIMD clients: validates against BOTH the CIMD document's redirect_uris +AND the proxy's allowed patterns (if configured). Both must pass. + +For DCR clients: validates against proxy patterns first, falling back to +base validation (registered redirect_uris) if patterns don't match. diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx index 26f80c876..b89d05edc 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx @@ -26,7 +26,7 @@ production use with enterprise identity providers. ## Classes -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -140,7 +140,7 @@ Handles provider-specific requirements: **Methods:** -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -157,7 +157,7 @@ this specific MCP endpoint. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `jwt_issuer` +#### `jwt_issuer` ```python jwt_issuer(self) -> JWTIssuer @@ -169,7 +169,7 @@ The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -179,9 +179,10 @@ Get client information by ID. This is generally the random ID provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). +CIMD clients (URL-based client IDs) are looked up and cached automatically. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -195,7 +196,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -213,7 +214,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -225,7 +226,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -243,7 +244,7 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -255,7 +256,7 @@ Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -272,7 +273,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -291,7 +292,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -304,7 +305,7 @@ For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx index efd2338d6..02d1d8a1b 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx @@ -16,7 +16,7 @@ This module contains HTML generation functions for consent and error pages. ### `create_consent_html` ```python -create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None) -> str +create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None, is_cimd_client: bool = False, cimd_domain: str | None = None) -> str ``` @@ -29,7 +29,7 @@ If empty string "", disables CSP entirely (no meta tag is rendered). If a non-empty string, uses that as the CSP policy value. -### `create_error_html` +### `create_error_html` ```python create_error_html(error_title: str, error_message: str, error_details: dict[str, str] | None = None, server_name: str | None = None, server_icon_url: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index a1794bb6f..3c853c4ce 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx index 9dd176f2e..beabae8ec 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx @@ -10,19 +10,19 @@ TokenVerifier implementations for FastMCP. ## Classes -### `JWKData` +### `JWKData` JSON Web Key data structure. -### `JWKSData` +### `JWKSData` JSON Web Key Set data structure. -### `RSAKeyPair` +### `RSAKeyPair` RSA key pair for JWT testing. @@ -30,7 +30,7 @@ RSA key pair for JWT testing. **Methods:** -#### `generate` +#### `generate` ```python generate(cls) -> RSAKeyPair @@ -42,7 +42,7 @@ Generate an RSA key pair for testing. - Generated key pair -#### `create_token` +#### `create_token` ```python create_token(self, subject: str = 'fastmcp-user', issuer: str = 'https://fastmcp.example.com', audience: str | list[str] | None = None, scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, kid: str | None = None) -> str @@ -60,7 +60,7 @@ Generate a test JWT token for testing purposes. - `kid`: Key ID to include in header -### `JWTVerifier` +### `JWTVerifier` JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. @@ -82,7 +82,7 @@ Use this when: **Methods:** -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -97,7 +97,7 @@ Validate a JWT bearer token and return an AccessToken when the token is valid. - AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -115,7 +115,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -### `StaticTokenVerifier` +### `StaticTokenVerifier` Simple static token verifier for testing and development. @@ -136,7 +136,7 @@ WARNING: Never use this in production - tokens are stored in plain text! **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx index c7aa26786..b8ae3c83b 100644 --- a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx +++ b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx @@ -8,23 +8,33 @@ sidebarTitle: redirect_validation Utilities for validating client redirect URIs in OAuth flows. +This module provides secure redirect URI validation with wildcard support, +protecting against userinfo-based bypass attacks like http://localhost@evil.com. + + ## Functions -### `matches_allowed_pattern` +### `matches_allowed_pattern` ```python matches_allowed_pattern(uri: str, pattern: str) -> bool ``` -Check if a URI matches an allowed pattern with wildcard support. +Securely check if a URI matches an allowed pattern with wildcard support. -Patterns support * wildcard matching: +This function parses both the URI and pattern as URLs, comparing each +component separately to prevent bypass attacks like userinfo injection. + +Patterns support wildcards: - http://localhost:* matches any localhost port - http://127.0.0.1:* matches any 127.0.0.1 port - https://*.example.com/* matches any subdomain of example.com - https://app.example.com/auth/* matches any path under /auth/ +Security: Rejects URIs with userinfo (user:pass@host) which could bypass +naive string matching (e.g., http://localhost@evil.com). + **Args:** - `uri`: The redirect URI to validate - `pattern`: The allowed pattern (may contain wildcards) @@ -33,7 +43,7 @@ Patterns support * wildcard matching: - True if the URI matches the pattern -### `validate_redirect_uri` +### `validate_redirect_uri` ```python validate_redirect_uri(redirect_uri: str | AnyUrl | None, allowed_patterns: list[str] | None) -> bool diff --git a/docs/python-sdk/fastmcp-server-auth-ssrf.mdx b/docs/python-sdk/fastmcp-server-auth-ssrf.mdx new file mode 100644 index 000000000..f098ad3f9 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-ssrf.mdx @@ -0,0 +1,172 @@ +--- +title: ssrf +sidebarTitle: ssrf +--- + +# `fastmcp.server.auth.ssrf` + + +SSRF-safe HTTP utilities for FastMCP. + +This module provides SSRF-protected HTTP fetching with: +- DNS resolution and IP validation before requests +- DNS pinning to prevent rebinding TOCTOU attacks +- Support for both CIMD and JWKS fetches + + +## Functions + +### `format_ip_for_url` + +```python +format_ip_for_url(ip_str: str) -> str +``` + + +Format IP address for use in URL (bracket IPv6 addresses). + +IPv6 addresses must be bracketed in URLs to distinguish the address from +the port separator. For example: https://[2001:db8::1]:443/path + +**Args:** +- `ip_str`: IP address string + +**Returns:** +- IP string suitable for URL (IPv6 addresses are bracketed) + + +### `is_ip_allowed` + +```python +is_ip_allowed(ip_str: str) -> bool +``` + + +Check if an IP address is allowed (must be globally routable unicast). + +Uses ip.is_global which catches: +- Private (10.x, 172.16-31.x, 192.168.x) +- Loopback (127.x, ::1) +- Link-local (169.254.x, fe80::) - includes AWS metadata! +- Reserved, unspecified +- RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks + +Additionally blocks multicast addresses (not caught by is_global). + +**Args:** +- `ip_str`: IP address string to check + +**Returns:** +- True if the IP is allowed (public unicast internet), False if blocked + + +### `resolve_hostname` + +```python +resolve_hostname(hostname: str, port: int = 443) -> list[str] +``` + + +Resolve hostname to IP addresses using DNS. + +**Args:** +- `hostname`: Hostname to resolve +- `port`: Port number (used for getaddrinfo) + +**Returns:** +- List of resolved IP addresses + +**Raises:** +- `SSRFError`: If resolution fails + + +### `validate_url` + +```python +validate_url(url: str, require_path: bool = False) -> ValidatedURL +``` + + +Validate URL for SSRF and resolve to IPs. + +**Args:** +- `url`: URL to validate +- `require_path`: If True, require non-root path (for CIMD) + +**Returns:** +- ValidatedURL with resolved IPs + +**Raises:** +- `SSRFError`: If URL is invalid or resolves to blocked IPs + + +### `ssrf_safe_fetch` + +```python +ssrf_safe_fetch(url: str) -> bytes +``` + + +Fetch URL with comprehensive SSRF protection and DNS pinning. + +Security measures: +1. HTTPS only +2. DNS resolution with IP validation +3. Connects to validated IP directly (DNS pinning prevents rebinding) +4. Response size limit +5. Redirects disabled +6. Overall timeout + +**Args:** +- `url`: URL to fetch +- `require_path`: If True, require non-root path +- `max_size`: Maximum response size in bytes (default 5KB) +- `timeout`: Per-operation timeout in seconds +- `overall_timeout`: Overall timeout for entire operation + +**Returns:** +- Response body as bytes + +**Raises:** +- `SSRFError`: If SSRF validation fails +- `SSRFFetchError`: If fetch fails + + +### `ssrf_safe_fetch_response` + +```python +ssrf_safe_fetch_response(url: str) -> SSRFFetchResponse +``` + + +Fetch URL with SSRF protection and return response metadata. + +This is equivalent to :func:`ssrf_safe_fetch` but returns response headers +and status code, and supports conditional request headers. + + +## Classes + +### `SSRFError` + + +Raised when an SSRF protection check fails. + + +### `SSRFFetchError` + + +Raised when SSRF-safe fetch fails. + + +### `ValidatedURL` + + +A URL that has been validated for SSRF with resolved IPs. + + +### `SSRFFetchResponse` + + +Response payload from an SSRF-safe fetch. + diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index d718b2fc3..b066c8c8e 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -169,7 +169,7 @@ By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients. If `include_all` is True, all headers are returned. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -187,7 +187,7 @@ request is available. - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -212,7 +212,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -238,7 +238,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -257,7 +257,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -277,7 +277,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -297,7 +297,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -315,7 +315,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -335,7 +335,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -352,7 +352,7 @@ safe to use in code that might run over any transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -382,7 +382,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -393,7 +393,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -402,7 +402,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -411,7 +411,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -420,7 +420,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -429,7 +429,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -438,7 +438,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -447,7 +447,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -459,25 +459,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -486,7 +486,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -495,7 +495,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -504,7 +504,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. diff --git a/docs/python-sdk/fastmcp-server-middleware-authorization.mdx b/docs/python-sdk/fastmcp-server-middleware-authorization.mdx index a7853b5c6..27d4afefb 100644 --- a/docs/python-sdk/fastmcp-server-middleware-authorization.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-authorization.mdx @@ -14,12 +14,12 @@ AuthMiddleware applies auth checks globally to all components on the server. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth import require_auth, require_scopes, restrict_tag + from fastmcp.server.auth import require_scopes, restrict_tag from fastmcp.server.middleware import AuthMiddleware - # Require auth for all components + # Require specific scope for all components mcp = FastMCP(middleware=[ - AuthMiddleware(auth=require_auth) + AuthMiddleware(auth=require_scopes("api")) ]) # Tag-based: components tagged "admin" require "admin" scope @@ -52,7 +52,7 @@ All checks must pass for authorization to succeed (AND logic). **Methods:** -#### `on_list_tools` +#### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] @@ -61,7 +61,7 @@ on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: Filter tools/list response based on auth checks. -#### `on_call_tool` +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult @@ -70,7 +70,7 @@ on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_ne Check auth before tool execution. -#### `on_list_resources` +#### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] @@ -79,7 +79,7 @@ on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], cal Filter resources/list response based on auth checks. -#### `on_read_resource` +#### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult]) -> ResourceResult @@ -88,7 +88,7 @@ on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], Check auth before resource read. -#### `on_list_resource_templates` +#### `on_list_resource_templates` ```python on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] @@ -97,7 +97,7 @@ on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTempl Filter resource templates/list response based on auth checks. -#### `on_list_prompts` +#### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] @@ -106,7 +106,7 @@ on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_ne Filter prompts/list response based on auth checks. -#### `on_get_prompt` +#### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult]) -> PromptResult diff --git a/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx b/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx new file mode 100644 index 000000000..de673407b --- /dev/null +++ b/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx @@ -0,0 +1,32 @@ +--- +title: response_limiting +sidebarTitle: response_limiting +--- + +# `fastmcp.server.middleware.response_limiting` + + +Response limiting middleware for controlling tool response sizes. + +## Classes + +### `ResponseLimitingMiddleware` + + +Middleware that limits the response size of tool calls. + +Intercepts tool call responses and enforces size limits. If a response +exceeds the limit, it extracts text content, truncates it, and returns +a single TextContent block. + + +**Methods:** + +#### `on_call_tool` + +```python +on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult +``` + +Intercept tool calls and limit response size. + diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index ed4b9ce41..bc0b40d3c 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx @@ -10,7 +10,7 @@ OpenAPI component classes: Tool, Resource, and ResourceTemplate. ## Classes -### `OpenAPITool` +### `OpenAPITool` Tool implementation for OpenAPI endpoints. @@ -18,7 +18,7 @@ Tool implementation for OpenAPI endpoints. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Execute the HTTP request using RequestDirector. -### `OpenAPIResource` +### `OpenAPIResource` Resource implementation for OpenAPI endpoints. @@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -44,7 +44,7 @@ read(self) -> ResourceResult Fetch the resource data by making an HTTP request. -### `OpenAPIResourceTemplate` +### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. @@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx index 6be6e07e4..6892d1174 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx @@ -10,7 +10,7 @@ OpenAPIProvider for creating MCP components from OpenAPI specifications. ## Classes -### `OpenAPIProvider` +### `OpenAPIProvider` Provider that creates MCP components from an OpenAPI specification. @@ -21,7 +21,7 @@ spec. Each component makes HTTP calls to the described API endpoints. **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -30,7 +30,7 @@ lifespan(self) -> AsyncIterator[None] Manage the lifecycle of the auto-created httpx client. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index dba6cf8a6..48d0d45a9 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -293,7 +293,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -301,7 +301,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index 259ef10e0..aa8c7b2d5 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -60,7 +60,7 @@ the referenced definition while preserving $defs for nested references. ### `compress_schema` ```python -compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = True, prune_titles: bool = False) -> dict[str, Any] +compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False) -> dict[str, Any] ``` @@ -74,6 +74,8 @@ schema size. **Args:** - `schema`: The schema to compress - `prune_params`: List of parameter names to remove from properties -- `prune_additional_properties`: Whether to remove additionalProperties\: false +- `prune_additional_properties`: Whether to remove additionalProperties\: false. +Defaults to False to maintain MCP client compatibility, as some clients +(e.g., Claude) require additionalProperties\: false for strict validation. - `prune_titles`: Whether to remove title fields from the schema diff --git a/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx b/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx index 2464180bd..c7b0bf5fc 100644 --- a/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx +++ b/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx @@ -33,7 +33,7 @@ Unified parser for OpenAPI schemas with generic type parameters to handle both 3 **Methods:** -#### `parse` +#### `parse` ```python parse(self) -> list[HTTPRoute] From 25f3b0878ee369dec93cf468a74fda9a6b121907 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:27:42 -0500 Subject: [PATCH 30/63] Add missing beta2 features to v3 release tracking (#3105) generate-cli, goose integration, response limiting middleware, background task context, require_auth removal --- docs/development/v3-notes/v3-features.mdx | 79 +++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 432b191bd..f982513d9 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -120,6 +120,85 @@ Key details: Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) +### CLI: `fastmcp generate-cli` + +`fastmcp generate-cli` connects to any MCP server, reads its tool schemas, and writes a standalone Python CLI script where every tool becomes a typed subcommand with flags, help text, and tab completion ([#3065](https://github.com/jlowin/fastmcp/pull/3065)). The insight is that MCP tool schemas already contain everything a CLI framework needs — parameter names, types, descriptions, required/optional status — so the generator maps JSON Schema directly into [cyclopts](https://cyclopts.readthedocs.io/) commands. + +```bash +# Generate from any server spec +fastmcp generate-cli weather +fastmcp generate-cli http://localhost:8000/mcp +fastmcp generate-cli server.py my_weather_cli.py + +# Use the generated script +python my_weather_cli.py call-tool get_forecast --city London --days 3 +python my_weather_cli.py list-tools +python my_weather_cli.py read-resource docs://readme +``` + +The generated script embeds the resolved transport (URL or stdio command), so it's self-contained — users don't need to know about MCP or FastMCP to use it. Supports `-f` to overwrite existing files, and name-based resolution via `fastmcp discover`. + +Documentation: [Generate CLI](/clients/generate-cli) + +### CLI: Goose Integration + +New `fastmcp install goose` command that generates a `goose://extension?...` deeplink URL and opens it, prompting Goose to install the server as a STDIO extension ([#3040](https://github.com/jlowin/fastmcp/pull/3040)). Goose requires `uvx` rather than `uv run`, so the command builds the appropriate invocation automatically. + +```bash +fastmcp install goose server.py +fastmcp install goose server.py --with pandas --python 3.11 +``` + +Also adds a full integration guide at [Goose Integration](/integrations/goose). + +### ResponseLimitingMiddleware + +New middleware for controlling tool response sizes, preventing large outputs from overwhelming LLM context windows ([#3072](https://github.com/jlowin/fastmcp/pull/3072)). Text responses are truncated at UTF-8 character boundaries; structured responses (tools with `output_schema`) raise `ToolError` since truncation would corrupt the schema. + +```python +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware + +# Limit all tool responses to 500KB +mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) + +# Limit only specific tools, raise errors instead of truncating +mcp.add_middleware(ResponseLimitingMiddleware( + max_size=100_000, + tools=["search", "fetch_data"], + raise_on_unstructured=True, +)) +``` + +Key features: +- Configurable size limit (default 1MB) +- Tool-specific filtering via `tools` parameter +- Size metadata added to result's `meta` field for monitoring +- Configurable `raise_on_structured` and `raise_on_unstructured` behavior + +Documentation: [Middleware](/servers/middleware) + +### Background Task Context (SEP-1686) + +`Context` now works transparently in background tasks running in Docket workers ([#2905](https://github.com/jlowin/fastmcp/pull/2905)). Previously, tools running as background tasks couldn't use `ctx.elicit()` because there was no active request context. Now, when a tool executes in a Docket worker, `Context` detects this via its `task_id` and routes elicitation through Redis-based coordination: the task sets its status to `input_required`, sends a `notifications/tasks/updated` notification with elicitation metadata, and waits for the client to respond via `tasks/sendInput`. + +```python +@mcp.tool(task=True) +async def interactive_task(ctx: Context) -> str: + # Works transparently in both foreground and background task modes + result = await ctx.elicit("Please provide additional input", str) + + if isinstance(result, AcceptedElicitation): + return f"You provided: {result.data}" + else: + return "Elicitation was declined or cancelled" +``` + +`ctx.is_background_task` and `ctx.task_id` are available for tools that need to branch on execution mode. + +### `require_auth` Removed + +The `require_auth` authorization check introduced in beta1 has been removed in favor of scope-based authorization via `require_scopes` ([#3103](https://github.com/jlowin/fastmcp/pull/3103)). Since configuring an `AuthProvider` already rejects unauthenticated requests at the transport level, `require_auth` was redundant — `require_scopes` provides the same guarantee with better granularity. The beta1 Component Authorization section has been updated to reflect this. + ### MCP Apps (SDK Compatibility) Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. From 1ed97708926a25cb9ed0e7403d739a72b7003ec5 Mon Sep 17 00:00:00 2001 From: SrzStephen Date: Sat, 7 Feb 2026 21:18:39 +0800 Subject: [PATCH 31/63] Updated deprecation URL (#3108) For V3 this should be https://gofastmcp.com/servers/dependency-injection#using-depends For V2 this should be https://gofastmcp.com/v2/servers/context#using-depends current url fails for both v2 and v3 documentation https://gofastmcp.com/servers/dependencies https://gofastmcp.com/v2/servers/dependencies --- src/fastmcp/tools/function_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 38d3116c6..7ef6df398 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -193,7 +193,7 @@ class FunctionTool(Tool): warnings.warn( "The `exclude_args` parameter is deprecated as of FastMCP 2.14. " "Use dependency injection with `Depends()` instead for better lifecycle management. " - "See https://gofastmcp.com/servers/dependencies for examples.", + "See https://gofastmcp.com/servers/dependency-injection#using-depends for examples.", DeprecationWarning, stacklevel=2, ) From 806aa8c57985d5a0cdacfd9b22a2051e1a18a838 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:33:12 -0500 Subject: [PATCH 32/63] Update docs to reference beta 2 (#3112) --- README.md | 2 +- docs/docs.json | 2 +- docs/getting-started/installation.mdx | 8 ++++---- docs/servers/tasks.mdx | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a0afe2231..5892d00b2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ These compose cleanly, so complex patterns don't require complex code. And becau ## Installation > [!Note] -> FastMCP 3.0 is currently in beta. Install with: `pip install fastmcp==3.0.0b1` +> FastMCP 3.0 is currently in beta. Install with: `pip install fastmcp==3.0.0b2` > > For production systems requiring stability, pin to v2: `pip install 'fastmcp<3'` diff --git a/docs/docs.json b/docs/docs.json index 2864855fe..995cb146c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -665,7 +665,7 @@ "icon": "code" } ], - "version": "v3.0.0 (beta 1)" + "version": "v3.0.0 (beta 2)" }, { "dropdowns": [ diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 9eb54bb16..0a566bd68 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -8,17 +8,17 @@ icon: arrow-down-to-line We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP. -FastMCP 3.0 is currently in beta. Package managers won't install beta versions by default—you must explicitly request one (e.g., `>=3.0.0b1`). +FastMCP 3.0 is currently in beta. Package managers won't install beta versions by default—you must explicitly request one (e.g., `>=3.0.0b2`). ```bash -pip install "fastmcp>=3.0.0b1" +pip install "fastmcp>=3.0.0b2" ``` Or with uv: ```bash -uv add "fastmcp>=3.0.0b1" +uv add "fastmcp>=3.0.0b2" ``` ### Optional Dependencies @@ -26,7 +26,7 @@ uv add "fastmcp>=3.0.0b1" FastMCP provides optional extras for specific features. For example, to install the background tasks extra: ```bash -pip install "fastmcp[tasks]==3.0.0b1" +pip install "fastmcp[tasks]==3.0.0b2" ``` See [Background Tasks](/servers/tasks) for details on the task system. diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index 24fdd4d9d..2b38dc3f0 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -43,7 +43,7 @@ MCP background tasks are different: they're **protocol-native**. This means MCP Background tasks require the `tasks` extra: ```bash -pip install "fastmcp[tasks]>=3.0.0b1" +pip install "fastmcp[tasks]>=3.0.0b2" ``` Add `task=True` to any tool, resource, resource template, or prompt decorator. This marks the component as capable of background execution. From ecbce07636381f1ce5030063e981e70a44898926 Mon Sep 17 00:00:00 2001 From: Guillaume FORTAINE Date: Sun, 8 Feb 2026 01:59:01 +0100 Subject: [PATCH 33/63] feat: distributed notification queue for background task elicitation Add Redis-backed notification queue (LPUSH/BRPOP) enabling the MCP server to notify clients about background task events like elicitation requests. - notifications.py: subscriber management with weakref tracking, retry logic, TTL expiration, and graceful shutdown - __init__.py: export ensure_subscriber_running, push_notification, stop_subscriber --- src/fastmcp/server/tasks/__init__.py | 8 + src/fastmcp/server/tasks/notifications.py | 254 ++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/fastmcp/server/tasks/notifications.py diff --git a/src/fastmcp/server/tasks/__init__.py b/src/fastmcp/server/tasks/__init__.py index 13ba9e80c..20dd733a8 100644 --- a/src/fastmcp/server/tasks/__init__.py +++ b/src/fastmcp/server/tasks/__init__.py @@ -11,6 +11,11 @@ from fastmcp.server.tasks.keys import ( get_client_task_id_from_key, parse_task_key, ) +from fastmcp.server.tasks.notifications import ( + ensure_subscriber_running, + push_notification, + stop_subscriber, +) __all__ = [ "TaskConfig", @@ -18,8 +23,11 @@ __all__ = [ "TaskMode", "build_task_key", "elicit_for_task", + "ensure_subscriber_running", "get_client_task_id_from_key", "get_task_capabilities", "handle_task_input", "parse_task_key", + "push_notification", + "stop_subscriber", ] diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py new file mode 100644 index 000000000..07fe95ff6 --- /dev/null +++ b/src/fastmcp/server/tasks/notifications.py @@ -0,0 +1,254 @@ +"""Distributed notification queue for background task events (SEP-1686). + +Enables distributed Docket workers to send MCP notifications to clients +without holding session references. Workers push to a Redis queue, +the MCP server process subscribes and forwards to the client's session. + +Pattern: Fire-and-forward with retry +- One queue per session_id +- LPUSH/BRPOP for reliable ordered delivery +- Retry up to 3 times on delivery failure, then discard +- TTL-based expiration for stale messages + +Note: Docket's execution.subscribe() handles task state/progress events via +Redis Pub/Sub. This module handles elicitation-specific notifications that +require reliable delivery (input_required prompts, cancel signals). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import weakref +from contextlib import suppress +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +import mcp.types + +if TYPE_CHECKING: + from docket import Docket + from mcp.server.session import ServerSession + +logger = logging.getLogger(__name__) + +# Redis key patterns +NOTIFICATION_QUEUE_KEY = "fastmcp:notifications:{session_id}" +NOTIFICATION_ACTIVE_KEY = "fastmcp:notifications:{session_id}:active" + +# Configuration +NOTIFICATION_TTL_SECONDS = 300 # 5 minute message TTL (elicitation response window) +MAX_DELIVERY_ATTEMPTS = 3 # Retry failed deliveries before discarding +SUBSCRIBER_TIMEOUT_SECONDS = 30 # BRPOP timeout (also heartbeat interval) + + +async def push_notification( + session_id: str, + notification: dict[str, Any], + docket: Docket, +) -> None: + """Push notification to session's queue (called from Docket worker). + + Used for elicitation-specific notifications (input_required, cancel) + that need reliable delivery across distributed processes. + + Args: + session_id: Target session's identifier + notification: MCP notification dict (method, params, _meta) + docket: Docket instance for Redis access + """ + key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) + message = json.dumps( + { + "notification": notification, + "attempt": 0, + "enqueued_at": datetime.now(timezone.utc).isoformat(), + } + ) + async with docket.redis() as redis: + await redis.lpush(key, message) + await redis.expire(key, NOTIFICATION_TTL_SECONDS) + + +async def notification_subscriber_loop( + session_id: str, + session: ServerSession, + docket: Docket, +) -> None: + """Subscribe to notification queue and forward to session. + + Runs in the MCP server process. Bridges distributed workers to clients. + + This loop: + 1. Maintains a heartbeat (active subscriber marker for debugging) + 2. Blocks on BRPOP waiting for notifications + 3. Forwards notifications to the client's session + 4. Retries failed deliveries, then discards (no dead-letter queue) + + Args: + session_id: Session identifier to subscribe to + session: MCP ServerSession for sending notifications + docket: Docket instance for Redis access + """ + queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) + active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id)) + + logger.debug("Starting notification subscriber for session %s", session_id) + + while True: + try: + async with docket.redis() as redis: + # Heartbeat: mark subscriber as active (for distributed debugging) + await redis.set(active_key, "1", ex=SUBSCRIBER_TIMEOUT_SECONDS * 2) + + # Blocking wait for notification (timeout refreshes heartbeat) + # Using BRPOP (right pop) for FIFO order with LPUSH (left push) + result = await redis.brpop( + queue_key, timeout=SUBSCRIBER_TIMEOUT_SECONDS + ) + if not result: + continue # Timeout - refresh heartbeat and retry + + _, message_bytes = result + message = json.loads(message_bytes) + notification_dict = message["notification"] + attempt = message.get("attempt", 0) + + try: + # Reconstruct and send MCP notification + await _send_mcp_notification(session, notification_dict) + logger.debug( + "Delivered notification to session %s (attempt %d)", + session_id, + attempt + 1, + ) + except Exception as send_error: + # Delivery failed - retry or discard + if attempt < MAX_DELIVERY_ATTEMPTS - 1: + # Re-queue with incremented attempt (back of queue) + message["attempt"] = attempt + 1 + message["last_error"] = str(send_error) + await redis.lpush(queue_key, json.dumps(message)) + logger.debug( + "Requeued notification for session %s (attempt %d): %s", + session_id, + attempt + 2, + send_error, + ) + else: + # Discard after max attempts (session likely disconnected) + logger.warning( + "Discarding notification for session %s after %d attempts: %s", + session_id, + MAX_DELIVERY_ATTEMPTS, + send_error, + ) + + except asyncio.CancelledError: + # Graceful shutdown - leave pending messages in queue for reconnect + logger.debug("Notification subscriber cancelled for session %s", session_id) + break + except Exception as e: + logger.debug( + "Notification subscriber error for session %s: %s", session_id, e + ) + await asyncio.sleep(1) # Backoff on error + + +async def _send_mcp_notification( + session: ServerSession, + notification_dict: dict[str, Any], +) -> None: + """Reconstruct MCP notification from dict and send to session. + + Args: + session: MCP ServerSession + notification_dict: Notification as dict (method, params, _meta) + """ + # Build JSONRPCNotification from dict + notification = mcp.types.JSONRPCNotification( + jsonrpc="2.0", + method=notification_dict.get("method", "notifications/tasks/updated"), + params=notification_dict.get("params", {}), + ) + + # Preserve _meta if present (contains related-task info for elicitation) + if "_meta" in notification_dict: + notification._meta = notification_dict["_meta"] # type: ignore[attr-defined] + + await session.send_notification(notification) # type: ignore[arg-type] + + +# ============================================================================= +# Subscriber Management +# ============================================================================= + +# Registry of active subscribers per session (prevents duplicates) +# Uses weakref to session to detect disconnects +_active_subscribers: dict[ + str, tuple[asyncio.Task[None], weakref.ref[ServerSession]] +] = {} + + +async def ensure_subscriber_running( + session_id: str, + session: ServerSession, + docket: Docket, +) -> None: + """Start notification subscriber if not already running (idempotent). + + Subscriber is created on first task submission and cleaned up on disconnect. + Safe to call multiple times for the same session. + + Args: + session_id: Session identifier + session: MCP ServerSession + docket: Docket instance + """ + # Check if subscriber already running for this session + if session_id in _active_subscribers: + task, session_ref = _active_subscribers[session_id] + # Check if task is still running AND session is still alive + if not task.done() and session_ref() is not None: + return # Already running + + # Task finished or session dead - clean up + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + del _active_subscribers[session_id] + + # Start new subscriber task + task = asyncio.create_task( + notification_subscriber_loop(session_id, session, docket), + name=f"notification-subscriber-{session_id[:8]}", + ) + _active_subscribers[session_id] = (task, weakref.ref(session)) + logger.debug("Started notification subscriber for session %s", session_id) + + +async def stop_subscriber(session_id: str) -> None: + """Stop notification subscriber for a session. + + Called when session disconnects. Pending messages remain in queue + for delivery if client reconnects (with TTL expiration). + + Args: + session_id: Session identifier + """ + if session_id not in _active_subscribers: + return + + task, _ = _active_subscribers.pop(session_id) + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + logger.debug("Stopped notification subscriber for session %s", session_id) + + +def get_subscriber_count() -> int: + """Get number of active subscribers (for monitoring).""" + return len(_active_subscribers) From 5dafe19a9db0d5606557b0598a5cd7c68ec3357d Mon Sep 17 00:00:00 2001 From: Guillaume FORTAINE Date: Sun, 8 Feb 2026 01:59:04 +0100 Subject: [PATCH 34/63] fix: store progress in Redis and use BLPOP for elicitation - context.py: report_progress uses delta tracking via increment() instead of set_current() (which doesn't exist), stores progress in Redis for background tasks - elicitation.py: replace polling with BLPOP for efficient blocking wait, fail-fast on notification push failure, use get_task_context() for authoritative session_id - handlers.py: subscriber cleanup on session disconnect via _exit_stack.push_async_callback() --- src/fastmcp/server/context.py | 52 +++++++++-- src/fastmcp/server/tasks/elicitation.py | 119 +++++++++++++++++------- src/fastmcp/server/tasks/handlers.py | 31 ++++++ 3 files changed, 159 insertions(+), 43 deletions(-) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index a9245fa2b..b1f0ae227 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -338,9 +338,13 @@ class Context: ) -> None: """Report progress for the current operation. + Works in both foreground (MCP progress notifications) and background + (Docket task execution) contexts. + Args: progress: Current progress value e.g. 24 total: Optional total value e.g. 100 + message: Optional status message describing current progress """ progress_token = ( @@ -349,16 +353,48 @@ class Context: else None ) - if progress_token is None: + # Foreground: Send MCP progress notification if we have a token + if progress_token is not None: + await self.session.send_progress_notification( + progress_token=progress_token, + progress=progress, + total=total, + message=message, + related_request_id=self.request_id, + ) return - await self.session.send_progress_notification( - progress_token=progress_token, - progress=progress, - total=total, - message=message, - related_request_id=self.request_id, - ) + # Background: Update Docket execution progress (stored in Redis) + # This makes progress visible via tasks/get and notifications/tasks/status + from fastmcp.server.dependencies import is_docket_available + + if not is_docket_available(): + return + + try: + from docket.dependencies import Dependency + + # Get current execution from worker context + execution = Dependency.execution.get() + + # Update progress in Redis using Docket's progress API. + # Docket only exposes increment() (relative), so we compute + # the delta from the last reported value stored on this execution. + if total is not None: + await execution.progress.set_total(int(total)) + + current = int(progress) + last: int = getattr(execution, "_fastmcp_last_progress", 0) + delta = current - last + if delta > 0: + await execution.progress.increment(delta) + execution._fastmcp_last_progress = current # type: ignore[attr-defined] + + if message is not None: + await execution.progress.set_message(message) + except LookupError: + # Not running in Docket worker context - no progress tracking available + pass async def _paginate_list( self, diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py index 2fc0bef5f..b12d0f10e 100644 --- a/src/fastmcp/server/tasks/elicitation.py +++ b/src/fastmcp/server/tasks/elicitation.py @@ -15,7 +15,6 @@ internal APIs for background task coordination. from __future__ import annotations -import asyncio import json import logging import uuid @@ -75,12 +74,21 @@ async def elicit_for_task( # Generate a unique request ID for this elicitation request_id = str(uuid.uuid4()) - # Get session ID for Redis key construction - session_id = getattr(session, "_fastmcp_state_prefix", None) - if session_id is None: - # Generate a session ID if not already set - session_id = str(uuid.uuid4()) - session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] + # Get session ID from task context (authoritative source for background tasks) + # This is extracted from the Docket execution key: {session_id}:{task_id}:... + from fastmcp.server.dependencies import get_task_context + + task_context = get_task_context() + if task_context is not None: + session_id = task_context.session_id + else: + # Fallback: try to get from session attribute (shouldn't happen in background) + session_id = getattr(session, "_fastmcp_state_prefix", None) + if session_id is None: + raise RuntimeError( + "Cannot determine session_id for elicitation. " + "This typically means elicit_for_task() was called outside a Docket worker context." + ) # Store elicitation request in Redis request_key = ELICIT_REQUEST_KEY.format(session_id=session_id, task_id=task_id) @@ -109,11 +117,14 @@ async def elicit_for_task( # Send task status update notification with input_required status # This follows SEP-1686 for background task status updates - notification = mcp.types.JSONRPCNotification( - jsonrpc="2.0", - method="notifications/tasks/updated", - params={}, - _meta={ # type: ignore[call-arg] + # + # NOTE: We use the distributed notification queue instead of session.send_notification() + # This enables notifications to work when workers run in separate processes + # (Azure Web PubSub / Service Bus inspired pattern) + notification_dict = { + "method": "notifications/tasks/updated", + "params": {}, + "_meta": { "modelcontextprotocol.io/related-task": { "taskId": task_id, "status": "input_required", @@ -125,49 +136,84 @@ async def elicit_for_task( }, } }, - ) + } + + # Push notification to Redis queue (works from any process) + # Server's subscriber loop will forward to client + from fastmcp.server.tasks.notifications import push_notification - # Send notification (best effort - task status is stored in Redis) - # Log failures for debugging but don't fail the elicitation try: - await session.send_notification(notification) # type: ignore[arg-type] + await push_notification(session_id, notification_dict, docket) except Exception as e: + # Fail fast: if notification can't be queued, client won't know to respond + # Return cancel immediately rather than waiting for 1-hour timeout logger.warning( - "Failed to send input_required notification for task %s: %s", + "Failed to queue input_required notification for task %s, cancelling elicitation: %s", task_id, e, ) + # Best-effort cleanup + try: + async with docket.redis() as redis: + await redis.delete( + docket.key(request_key), + docket.key(status_key), + ) + except Exception: + pass # Keys will expire via TTL + return mcp.types.ElicitResult(action="cancel", content=None) - # Wait for response (poll Redis) - # In a production implementation, this could use Redis pub/sub for lower latency + # Wait for response using BLPOP (blocking pop) + # This is much more efficient than polling - single Redis round-trip + # that blocks until a response is pushed, vs 7,200 round-trips/hour with polling max_wait_seconds = ELICIT_TTL_SECONDS - poll_interval = 0.5 # seconds - for _ in range(int(max_wait_seconds / poll_interval)): + try: async with docket.redis() as redis: - response_data = await redis.get(docket.key(response_key)) - if response_data: + # BLPOP blocks until an item is pushed to the list or timeout + # Returns tuple of (key, value) or None on timeout + result = await redis.blpop( + docket.key(response_key), + timeout=max_wait_seconds, + ) + + if result: + # result is (key, value) tuple + _key, response_data = result response = json.loads(response_data) + # Clean up Redis keys await redis.delete( docket.key(request_key), - docket.key(response_key), docket.key(status_key), ) + # Convert to ElicitResult return mcp.types.ElicitResult( action=response.get("action", "accept"), content=response.get("content"), ) + except Exception as e: + logger.warning( + "BLPOP failed for task %s elicitation, falling back to cancel: %s", + task_id, + e, + ) - await asyncio.sleep(poll_interval) - - # Timeout - treat as cancellation - async with docket.redis() as redis: - await redis.delete( - docket.key(request_key), - docket.key(response_key), - docket.key(status_key), + # Timeout or error - treat as cancellation + # Best-effort cleanup - if Redis is unavailable, keys will expire via TTL + try: + async with docket.redis() as redis: + await redis.delete( + docket.key(request_key), + docket.key(response_key), + docket.key(status_key), + ) + except Exception as cleanup_error: + logger.debug( + "Failed to clean up elicitation keys for task %s (will expire via TTL): %s", + task_id, + cleanup_error, ) return mcp.types.ElicitResult(action="cancel", content=None) @@ -213,12 +259,15 @@ async def handle_task_input( if status is None or status.decode("utf-8") != "waiting": return False - # Store the response - await redis.set( + # Push response to list - this wakes up the BLPOP in elicit_for_task + # Using LPUSH instead of SET enables the efficient blocking wait pattern + await redis.lpush( docket.key(response_key), json.dumps(response), - ex=ELICIT_TTL_SECONDS, ) + # Set TTL on the response list (in case BLPOP doesn't consume it) + await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS) + # Update status to "responded" await redis.set( docket.key(status_key), diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index 02da22148..646cf82dc 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -17,6 +17,7 @@ from mcp.types import INTERNAL_ERROR, ErrorData from fastmcp.server.dependencies import _current_docket, get_context from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.tasks.keys import build_task_key +from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.prompts.prompt import Prompt @@ -24,6 +25,8 @@ if TYPE_CHECKING: from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.tool import Tool +logger = get_logger(__name__) + # Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60 @@ -151,6 +154,34 @@ async def submit_to_docket( poll_interval_ms, ) + # Start notification subscriber for distributed elicitation (idempotent) + # This enables ctx.elicit() to work when workers run in separate processes + # Subscriber forwards notifications from Redis queue to client session + from fastmcp.server.tasks.notifications import ( + ensure_subscriber_running, + stop_subscriber, + ) + + try: + await ensure_subscriber_running(session_id, ctx.session, docket) + + # Register cleanup callback on session exit (once per session) + # This ensures subscriber is stopped when the session disconnects + if ( + hasattr(ctx.session, "_exit_stack") + and ctx.session._exit_stack is not None + and not getattr(ctx.session, "_notification_cleanup_registered", False) + ): + + async def _cleanup_subscriber() -> None: + await stop_subscriber(session_id) + + ctx.session._exit_stack.push_async_callback(_cleanup_subscriber) + ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined] + except Exception as e: + # Non-fatal: elicitation will still work via polling fallback + logger.debug("Failed to start notification subscriber: %s", e) + # Return CreateTaskResult with proper Task object # Tasks MUST begin in "working" status per SEP-1686 final spec (line 381) return mcp.types.CreateTaskResult( From 1f84e5055ebc1f2f768cafb11e68a2e7718bfd4c Mon Sep 17 00:00:00 2001 From: Guillaume FORTAINE Date: Sun, 8 Feb 2026 01:59:07 +0100 Subject: [PATCH 35/63] test: rewrite as integration tests with zero mocks Replace 1300+ lines of mock-heavy unit tests with 391 lines of integration tests using real Client(mcp) connections and memory:// Docket backend. - test_context_background_task.py: 17 tests covering report_progress delta tracking, elicitation flow, edge cases, and fail-fast on push failure - test_notifications.py: 2 E2E tests for notification queue lifecycle --- .../tasks/test_context_background_task.py | 928 +++++------------- tests/server/tasks/test_notifications.py | 104 ++ 2 files changed, 332 insertions(+), 700 deletions(-) create mode 100644 tests/server/tasks/test_notifications.py diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py index b778a63b2..0cdc9636a 100644 --- a/tests/server/tasks/test_context_background_task.py +++ b/tests/server/tasks/test_context_background_task.py @@ -1,11 +1,23 @@ -"""Tests for Context background task support (SEP-1686).""" +"""Tests for Context background task support (SEP-1686). + +Tests Context API surface (unit) and background task elicitation (integration). +Integration tests use Client(mcp) with the real memory:// Docket backend — +no mocking of Redis, Docket, or session internals. +""" + +import asyncio import pytest from fastmcp import FastMCP +from fastmcp.client import Client from fastmcp.server.context import Context -from fastmcp.server.elicitation import AcceptedElicitation -from fastmcp.server.tasks.elicitation import elicit_for_task, handle_task_input +from fastmcp.server.elicitation import AcceptedElicitation, DeclinedElicitation +from fastmcp.server.tasks.elicitation import handle_task_input + +# ============================================================================= +# Unit tests: Context API surface (no Redis/Docket needed) +# ============================================================================= class TestContextBackgroundTaskSupport: @@ -54,7 +66,6 @@ class TestContextSessionProperty: mock_session = MockSession() ctx = Context(mcp, session=mock_session, task_id="test-task-123") # type: ignore[arg-type] - # In background task mode, should return the stored session assert ctx.session is mock_session def test_session_uses_stored_session_during_on_initialize(self): @@ -65,23 +76,19 @@ class TestContextSessionProperty: _fastmcp_state_prefix = "test-session" mock_session = MockSession() - # Simulating on_initialize: has session but not a background task ctx = Context(mcp, session=mock_session) # type: ignore[arg-type] - # Should return the stored session as fallback assert ctx.session is mock_session class TestContextElicitBackgroundTask: """Tests for Context.elicit() in background task mode.""" - @pytest.mark.asyncio async def test_elicit_raises_when_background_task_but_no_docket(self): """elicit() should raise when in background task mode but Docket unavailable.""" mcp = FastMCP("test") ctx = Context(mcp, task_id="test-task-123") - # Set up minimal session mock class MockSession: _fastmcp_state_prefix = "test-session" @@ -91,6 +98,53 @@ class TestContextElicitBackgroundTask: await ctx.elicit("Need input", str) +class TestElicitFailFast: + """Tests for elicit_for_task fail-fast on notification push failure.""" + + async def test_elicit_returns_cancel_when_notification_push_fails(self): + """elicit_for_task should return cancel immediately when push_notification fails. + + If the client can't receive the input_required notification, waiting + for a response that will never come would block for up to 1 hour. + Instead, we return cancel immediately (fail-fast). + + This test patches ONLY push_notification — all other components + (Docket, Redis, session) are real via the memory:// backend. + """ + from unittest.mock import patch + + from fastmcp.server.elicitation import CancelledElicitation + + mcp = FastMCP("failfast-test") + elicit_started = asyncio.Event() + captured: dict[str, object] = {} + + @mcp.tool(task=True) + async def failfast_tool(ctx: Context) -> str: + elicit_started.set() + result = await ctx.elicit("This notification will fail", str) + captured["result_type"] = type(result).__name__ + captured["is_cancelled"] = isinstance(result, CancelledElicitation) + return "done" + + # Patch push_notification BEFORE starting client so it's active + # when the tool runs in the Docket worker + with patch( + "fastmcp.server.tasks.notifications.push_notification", + side_effect=ConnectionError("Redis queue unavailable"), + ): + async with Client(mcp) as client: + task = await client.call_tool("failfast_tool", {}, task=True) + await asyncio.wait_for(elicit_started.wait(), timeout=5.0) + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "done" + + # The tool should have received CancelledElicitation (fail-fast) + assert captured["is_cancelled"] is True + assert captured["result_type"] == "CancelledElicitation" + + class TestContextDocumentation: """Tests to verify Context documentation and API surface.""" @@ -110,735 +164,97 @@ class TestContextDocumentation: assert "background task" in Context.session.fget.__doc__.lower() -class TestBackgroundTaskElicitationE2E: - """End-to-end tests for background task elicitation (SEP-1686). +# ============================================================================= +# Integration tests: Client(mcp) + memory:// Docket backend +# ============================================================================= - These tests demonstrate the full flow: - 1. Client calls a tool with task=True (background execution) - 2. Tool uses ctx.elicit() to request user input - 3. Task status changes to "input_required" - 4. Client sends input via handle_task_input() - 5. Task resumes and completes with the elicited value - This simulates what a client would see when interacting with - a background task that needs user input. +class TestBackgroundTaskIntegration: + """Integration tests for background task context using real Docket memory backend. + + These tests use Client(mcp) with the memory:// broker — no mocking. + The memory:// backend provides a fully functional in-memory Redis store + that Docket uses automatically when running tests. """ - async def test_elicit_for_task_stores_request_in_redis(self): - """Test that elicit_for_task stores the elicitation request in Redis. - - This tests the Redis coordination layer that enables client interaction. - When a background task calls elicit(), the request is stored in Redis - so clients can retrieve it and respond. - """ - from unittest.mock import AsyncMock, MagicMock, patch - - from fastmcp.server.tasks.elicitation import ( - elicit_for_task, - ) - - # Create mocks - mock_redis = AsyncMock() - mock_redis.set = AsyncMock() - mock_redis.get = AsyncMock(return_value=None) # No response yet - mock_redis.delete = AsyncMock() - - mock_docket = MagicMock() - mock_docket.redis = MagicMock(return_value=AsyncMock()) - mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) - mock_docket.redis.return_value.__aexit__ = AsyncMock() - mock_docket.key = lambda k: k - - mock_fastmcp = MagicMock() - mock_fastmcp._docket = mock_docket - - mock_session = MagicMock() - mock_session._fastmcp_state_prefix = "test-session-id" - mock_session.send_notification = AsyncMock() - - # Call elicit_for_task with a short timeout to avoid blocking - with patch("fastmcp.server.tasks.elicitation.ELICIT_TTL_SECONDS", 1): - with patch("fastmcp.server.tasks.elicitation.asyncio.sleep", AsyncMock()): - # Make it return after first poll - mock_redis.get = AsyncMock( - return_value=b'{"action": "accept", "content": {"value": 42}}' - ) - - result = await elicit_for_task( - task_id="test-task-123", - session=mock_session, - message="Please provide a number", - schema={ - "type": "object", - "properties": {"value": {"type": "integer"}}, - }, - fastmcp=mock_fastmcp, - ) - - # Verify the result - assert result.action == "accept" - assert result.content == {"value": 42} - - # Verify Redis operations were called - assert mock_redis.set.call_count >= 2 # request + status - - async def test_handle_task_input_stores_response(self): - """Test that handle_task_input stores the response in Redis. - - This tests the client-side flow: when a client sends input via - tasks/sendInput, the response is stored in Redis for the waiting task. - """ - from unittest.mock import AsyncMock, MagicMock - - # Create mocks - mock_redis = AsyncMock() - mock_redis.get = AsyncMock(return_value=b"waiting") # Status is waiting - mock_redis.set = AsyncMock() - - mock_docket = MagicMock() - mock_docket.redis = MagicMock(return_value=AsyncMock()) - mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) - mock_docket.redis.return_value.__aexit__ = AsyncMock() - mock_docket.key = lambda k: k - - mock_fastmcp = MagicMock() - mock_fastmcp._docket = mock_docket - - # Call handle_task_input - success = await handle_task_input( - task_id="test-task-123", - session_id="test-session-id", - action="accept", - content={"value": 42}, - fastmcp=mock_fastmcp, - ) - - # Verify success - assert success is True - - # Verify Redis operations - assert mock_redis.set.call_count == 2 # response + status update - - async def test_handle_task_input_rejects_when_not_waiting(self): - """Test that handle_task_input rejects input when task isn't waiting. - - This verifies proper state management - clients can only send input - when a task is actually waiting for it. - """ - from unittest.mock import AsyncMock, MagicMock - - mock_redis = AsyncMock() - mock_redis.get = AsyncMock(return_value=None) # No waiting status - - mock_docket = MagicMock() - mock_docket.redis = MagicMock(return_value=AsyncMock()) - mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) - mock_docket.redis.return_value.__aexit__ = AsyncMock() - mock_docket.key = lambda k: k - - mock_fastmcp = MagicMock() - mock_fastmcp._docket = mock_docket - - success = await handle_task_input( - task_id="test-task-123", - session_id="test-session-id", - action="accept", - content={"value": 42}, - fastmcp=mock_fastmcp, - ) - - # Should fail because no task is waiting - assert success is False - - async def test_elicit_for_task_sends_notification(self): - """Test that elicit_for_task sends input_required notification. - - Per SEP-1686, the server should send notifications/tasks/updated - with status="input_required" when a task needs input. - """ - from unittest.mock import AsyncMock, MagicMock, patch - - mock_redis = AsyncMock() - mock_redis.set = AsyncMock() - mock_redis.get = AsyncMock( - return_value=b'{"action": "accept", "content": {"value": 1}}' - ) - mock_redis.delete = AsyncMock() - - mock_docket = MagicMock() - mock_docket.redis = MagicMock(return_value=AsyncMock()) - mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) - mock_docket.redis.return_value.__aexit__ = AsyncMock() - mock_docket.key = lambda k: k - - mock_fastmcp = MagicMock() - mock_fastmcp._docket = mock_docket - - mock_session = MagicMock() - mock_session._fastmcp_state_prefix = "test-session" - mock_session.send_notification = AsyncMock() - - with patch("fastmcp.server.tasks.elicitation.asyncio.sleep", AsyncMock()): - await elicit_for_task( - task_id="my-task-id", - session=mock_session, - message="Enter value", - schema={"type": "object"}, - fastmcp=mock_fastmcp, - ) - - # Verify notification was sent - mock_session.send_notification.assert_called_once() - notification = mock_session.send_notification.call_args[0][0] - assert notification.method == "notifications/tasks/updated" - - async def test_elicit_for_task_timeout_returns_cancel(self): - """Test that elicit_for_task returns cancel on timeout. - - If no response is received within the TTL, the elicitation - should be treated as cancelled. - """ - from unittest.mock import AsyncMock, MagicMock, patch - - mock_redis = AsyncMock() - mock_redis.set = AsyncMock() - mock_redis.get = AsyncMock(return_value=None) # Never responds - mock_redis.delete = AsyncMock() - - mock_docket = MagicMock() - mock_docket.redis = MagicMock(return_value=AsyncMock()) - mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) - mock_docket.redis.return_value.__aexit__ = AsyncMock() - mock_docket.key = lambda k: k - - mock_fastmcp = MagicMock() - mock_fastmcp._docket = mock_docket - - mock_session = MagicMock() - mock_session._fastmcp_state_prefix = "test-session" - mock_session.send_notification = AsyncMock() - - # Use very short TTL for test - with patch("fastmcp.server.tasks.elicitation.ELICIT_TTL_SECONDS", 0.1): - with patch( - "fastmcp.server.tasks.elicitation.asyncio.sleep", - AsyncMock(), - ): - result = await elicit_for_task( - task_id="timeout-task", - session=mock_session, - message="This will timeout", - schema={"type": "object"}, - fastmcp=mock_fastmcp, - ) - - # Should return cancel on timeout - assert result.action == "cancel" - assert result.content is None - - async def test_elicit_notification_includes_full_schema(self): - """Test that the notification includes the full JSON schema for complex types. - - This test demonstrates what the client sees when eliciting a Pydantic model. - The client receives a full JSON Schema that describes the expected input, - which they can use to: - - Render a dynamic form - - Validate user input before sending - - Show field descriptions to the user - - Example notification metadata for a UserInfo model: - ```json - { - "modelcontextprotocol.io/related-task": { - "taskId": "test-task", - "status": "input_required", - "statusMessage": "Please provide user info", - "elicitation": { - "requestId": "...", - "message": "Please provide user info", - "requestedSchema": { - "type": "object", - "properties": { - "name": {"type": "string", "title": "Name"}, - "age": {"type": "integer", "title": "Age"} - }, - "required": ["name", "age"], - "title": "UserInfo" - } - } - } - } - ``` - """ - from unittest.mock import AsyncMock, MagicMock, patch - - from pydantic import BaseModel - - class UserInfo(BaseModel): - """User information for registration.""" - - name: str - age: int - - mock_redis = AsyncMock() - mock_redis.set = AsyncMock() - mock_redis.get = AsyncMock( - return_value=b'{"action": "accept", "content": {"name": "Alice", "age": 30}}' - ) - mock_redis.delete = AsyncMock() - - mock_docket = MagicMock() - mock_docket.redis = MagicMock(return_value=AsyncMock()) - mock_docket.redis.return_value.__aenter__ = AsyncMock(return_value=mock_redis) - mock_docket.redis.return_value.__aexit__ = AsyncMock() - mock_docket.key = lambda k: k - - mock_fastmcp = MagicMock() - mock_fastmcp._docket = mock_docket - - mock_session = MagicMock() - mock_session._fastmcp_state_prefix = "test-session" - mock_session.send_notification = AsyncMock() - - # Create task-aware context - ctx = Context( - mock_fastmcp, - session=mock_session, - task_id="schema-test-task", - ) - - # Call elicit with a Pydantic model type - with patch("fastmcp.server.tasks.elicitation.asyncio.sleep", AsyncMock()): - result = await ctx.elicit("Please provide user info", UserInfo) - - # Verify the notification includes the full schema - mock_session.send_notification.assert_called_once() - notification = mock_session.send_notification.call_args[0][0] - meta = notification._meta - related_task = meta["modelcontextprotocol.io/related-task"] - schema = related_task["elicitation"]["requestedSchema"] - - # Verify schema structure matches UserInfo - assert schema["type"] == "object" - assert "properties" in schema - assert "name" in schema["properties"] - assert "age" in schema["properties"] - assert schema["properties"]["name"]["type"] == "string" - assert schema["properties"]["age"]["type"] == "integer" - assert "required" in schema - assert set(schema["required"]) == {"name", "age"} - - # Verify the result is properly parsed into the Pydantic model - assert result.action == "accept" - assert isinstance(result, AcceptedElicitation) # Type narrowing - assert isinstance(result.data, UserInfo) - assert result.data.name == "Alice" - assert result.data.age == 30 - - -class TestBackgroundTaskContextWiring: - """Integration tests for Context wiring in Docket workers. - - These tests verify that when a background task runs in a Docket worker, - the Context dependency is properly created with task_id and session, - allowing ctx.elicit() to work transparently. - - Per Chris Guidry's review request: "Could we get at least one test showing - the end-to-end of it working, with a background task that's eliciting input? - This will help with what the client-side sees when this happens." - - The key test is `test_context_elicit_full_flow_with_mocked_redis` which shows: - - CLIENT RECEIVES: - notifications/tasks/updated with: - - taskId: the background task ID - - status: "input_required" - - statusMessage: the elicit prompt - - elicitation.requestedSchema: JSON schema for expected input - - CLIENT RESPONDS: - handle_task_input(task_id, session_id, action="accept", content={...}) - - TOOL RECEIVES: - AcceptedElicitation(action="accept", data=) - """ - - async def test_context_is_created_with_task_id_in_worker(self): - """Test that Context is created with task_id when running in Docket worker. - - This verifies the wiring from _CurrentContext that creates a task-aware - Context when get_task_context() returns TaskContextInfo. - """ - from unittest.mock import MagicMock, patch - - from fastmcp.server.dependencies import ( - TaskContextInfo, - _current_server, - _CurrentContext, - _task_sessions, - ) - - # Set up mock server - mock_server = MagicMock() - mock_server._docket = MagicMock() - server_token = _current_server.set(MagicMock(return_value=mock_server)) - - # Set up mock session in registry - mock_session = MagicMock() - mock_session._fastmcp_state_prefix = "test-session-id" - _task_sessions["test-session-id"] = MagicMock(return_value=mock_session) - - try: - # Mock get_task_context to return TaskContextInfo - task_info = TaskContextInfo( - task_id="test-task-123", - session_id="test-session-id", - ) - with patch( - "fastmcp.server.dependencies.get_task_context", - return_value=task_info, - ): - # Create the dependency and enter it - dep = _CurrentContext() - ctx = await dep.__aenter__() - - # Verify context is task-aware - assert ctx.is_background_task is True - assert ctx.task_id == "test-task-123" - assert ctx.session is mock_session - - # Clean up - await dep.__aexit__(None, None, None) - finally: - _current_server.reset(server_token) - _task_sessions.pop("test-session-id", None) - - async def test_context_falls_back_to_foreground_mode(self): - """Test that Context uses foreground mode when not in worker context. - - When _current_context has a value (normal request handling), - _CurrentContext should return that context instead of creating a new one. - """ - from unittest.mock import MagicMock - - from fastmcp.server.context import Context, _current_context - from fastmcp.server.dependencies import _CurrentContext - - mcp = MagicMock() - foreground_ctx = Context(mcp) - - # Set the foreground context - token = _current_context.set(foreground_ctx) - try: - dep = _CurrentContext() - ctx = await dep.__aenter__() - - # Should return the foreground context - assert ctx is foreground_ctx - assert ctx.is_background_task is False - - await dep.__aexit__(None, None, None) - finally: - _current_context.reset(token) - - async def test_session_registered_when_task_submitted(self): - """Test that session is registered when a task is submitted to Docket. - - This verifies that submit_to_docket calls register_task_session, - which enables the Context wiring in background workers. - """ - import asyncio - - from fastmcp import FastMCP - from fastmcp.client import Client - from fastmcp.server.dependencies import get_task_session - - mcp = FastMCP("test-server") - - task_started = asyncio.Event() - session_id_captured = None + async def test_report_progress_in_background_task(self): + """report_progress() should complete without error in a background task.""" + mcp = FastMCP("progress-test") + progress_reported = asyncio.Event() @mcp.tool(task=True) - async def capture_session_tool(ctx: Context) -> str: - """Tool that captures the session ID for verification.""" - nonlocal session_id_captured - task_started.set() - # Access session to verify it works - session_id_captured = ctx.session_id + async def progress_tool(ctx: Context) -> str: + await ctx.report_progress(0, 100, "Starting...") + await ctx.report_progress(50, 100, "Half done") + await ctx.report_progress(100, 100, "Complete") + progress_reported.set() return "done" async with Client(mcp) as client: - # Start the task - task = await client.call_tool("capture_session_tool", {}, task=True) - assert task is not None - - # Wait for the task to start - await asyncio.wait_for(task_started.wait(), timeout=5.0) - - # Verify the session was registered - assert session_id_captured is not None - # The session should be retrievable via get_task_session - # (it was registered when the task was submitted) - # Session may be available or None if cleaned up - key is registration happened - _ = get_task_session(session_id_captured) - - # Wait for task to complete + task = await client.call_tool("progress_tool", {}, task=True) + await asyncio.wait_for(progress_reported.wait(), timeout=5.0) await task.wait(timeout=5.0) result = await task.result() assert result.data == "done" - async def test_context_elicit_works_in_background_task(self): - """E2E test: verify Context is properly wired in background tasks. - - This test demonstrates that: - 1. Context.task_id is set correctly in background tasks - 2. Context.is_background_task returns True - 3. Context.session_id is available - - The wiring is what enables ctx.elicit() to work in background tasks. - """ - import asyncio - - from fastmcp import FastMCP - from fastmcp.client import Client - from fastmcp.server.context import Context - - mcp = FastMCP("context-wiring-test") - - # Track what happens in the background task + async def test_context_wiring_in_background_task(self): + """Context should be properly wired with task_id and session_id.""" + mcp = FastMCP("wiring-test") task_completed = asyncio.Event() - captured_task_id: str | None = None - captured_session_id: str | None = None - captured_is_background: bool | None = None + captured: dict[str, object] = {} @mcp.tool(task=True) - async def verify_context_tool(ctx: Context) -> str: - """Tool that verifies Context is wired correctly for background tasks.""" - nonlocal captured_task_id, captured_session_id, captured_is_background - - # Capture context properties - this is the key verification - captured_task_id = ctx.task_id - captured_session_id = ctx.session_id - captured_is_background = ctx.is_background_task - + async def verify_wiring(ctx: Context) -> str: + captured["task_id"] = ctx.task_id + captured["session_id"] = ctx.session_id + captured["is_background"] = ctx.is_background_task task_completed.set() - return f"task_id={ctx.task_id}, is_background={ctx.is_background_task}" + return "ok" async with Client(mcp) as client: - # Start the background task - task = await client.call_tool("verify_context_tool", {}, task=True) - assert task is not None - assert task.task_id is not None - - # Wait for the task to complete - await asyncio.wait_for(task_completed.wait(), timeout=10.0) - - # Verify Context was properly wired in the background task - assert captured_task_id is not None, "Context.task_id should be set" - assert captured_session_id is not None, "Context.session_id should be set" - assert captured_is_background is True, ( - "Context.is_background_task should be True" - ) - - # Wait for task result - await task.wait(timeout=10.0) + task = await client.call_tool("verify_wiring", {}, task=True) + await asyncio.wait_for(task_completed.wait(), timeout=5.0) + await task.wait(timeout=5.0) result = await task.result() - assert "is_background=True" in result.data + assert result.data == "ok" - async def test_context_elicit_full_flow_with_mocked_redis(self): - """E2E test with mocked Redis to show complete elicitation flow. + assert captured["task_id"] is not None + assert captured["session_id"] is not None + assert captured["is_background"] is True - This test demonstrates what the client sees during background task - elicitation, with a mocked Redis layer to avoid requiring real Redis. + async def test_elicit_accept_flow(self): + """E2E: tool elicits input, client accepts, tool receives value. Flow: - 1. Tool calls ctx.elicit() in background task - 2. Elicitation stores request in Redis, sends input_required notification - 3. Simulated client sends response via handle_task_input() - 4. Tool receives response and completes - - This is the key test that fulfills Chris Guidry's request for an - "end-to-end test showing a background task that's eliciting input" - and demonstrates "what the client-side sees when this happens." + 1. Tool calls ctx.elicit("name?", str) — blocks waiting for input + 2. Client polls handle_task_input(action="accept", content={"value":"Bob"}) + 3. Tool resumes with AcceptedElicitation(data="Bob") """ - import asyncio - from unittest.mock import AsyncMock, MagicMock - - from fastmcp.server.context import Context - from fastmcp.server.tasks.elicitation import handle_task_input - - # Shared Redis storage that both elicit and handle_task_input will use - redis_storage: dict[str, bytes] = {} - - # Create a mock Redis that uses our shared storage - class MockRedis: - async def set( - self, key: str, value: str | bytes, ex: int | None = None - ) -> None: - redis_storage[key] = value.encode() if isinstance(value, str) else value - - async def get(self, key: str) -> bytes | None: - return redis_storage.get(key) - - async def delete(self, *keys: str) -> None: - for key in keys: - redis_storage.pop(key, None) - - mock_redis = MockRedis() - - # Create mock context manager for redis() - class MockRedisContext: - async def __aenter__(self): - return mock_redis - - async def __aexit__(self, *args): - pass - - mock_docket = MagicMock() - mock_docket.redis = lambda: MockRedisContext() - mock_docket.key = lambda k: k - - mock_fastmcp = MagicMock() - mock_fastmcp._docket = mock_docket - - mock_session = MagicMock() - mock_session._fastmcp_state_prefix = "test-session-123" - mock_session.send_notification = AsyncMock() - - # Create task-aware context (as would be created in background worker) - ctx = Context( - mock_fastmcp, - session=mock_session, - task_id="test-task-456", - ) - - # Verify context is properly configured for background task - assert ctx.is_background_task is True - assert ctx.task_id == "test-task-456" - - # Start elicit in a background task (simulating the Docket worker) - async def run_elicit(): - return await ctx.elicit("What is your name?", str) - - elicit_task = asyncio.create_task(run_elicit()) - - # Wait for elicit to store request and start polling - # The elicit_for_task function stores the request and sends notification - await asyncio.sleep(0.2) - - # ═══════════════════════════════════════════════════════════════════════ - # CLIENT PERSPECTIVE: What does the client see? - # ═══════════════════════════════════════════════════════════════════════ - - # 1. CLIENT RECEIVES: notifications/tasks/updated notification - mock_session.send_notification.assert_called() - notification = mock_session.send_notification.call_args[0][0] - assert notification.method == "notifications/tasks/updated" - - # 2. CLIENT INSPECTS: The notification metadata tells the client: - # - Which task needs input (taskId) - # - What status the task is in (input_required) - # - What message to display (statusMessage) - # - The schema for the expected response (elicitation.requestedSchema) - meta = notification._meta - related_task = meta["modelcontextprotocol.io/related-task"] - - assert related_task["taskId"] == "test-task-456" - assert related_task["status"] == "input_required" - assert related_task["statusMessage"] == "What is your name?" - assert "elicitation" in related_task - assert related_task["elicitation"]["message"] == "What is your name?" - assert "requestedSchema" in related_task["elicitation"] - - # 3. CLIENT RESPONDS: Send input via handle_task_input - # This is what a real client would do when it receives input_required - success = await handle_task_input( - task_id="test-task-456", - session_id="test-session-123", - action="accept", - content={"value": "Alice"}, - fastmcp=mock_fastmcp, - ) - assert success is True, "Client should successfully send input" - - # ═══════════════════════════════════════════════════════════════════════ - # TOOL PERSPECTIVE: What does the tool receive? - # ═══════════════════════════════════════════════════════════════════════ - - # Wait for elicit to receive the response and return - result = await asyncio.wait_for(elicit_task, timeout=5.0) - - # Verify the result contains what the client sent - # AcceptedElicitation has 'action' and 'data' attributes - assert result.action == "accept" - assert result.data == "Alice" # The value from content["value"] - - async def test_context_elicit_with_real_docket_memory_backend(self): - """E2E test using Docket's real memory:// backend. - - This test uses the real Docket memory backend instead of mocking Redis, - as suggested by Chris Guidry during code review. The memory:// backend - provides a fully functional in-memory Redis-like store that Docket uses - automatically when running tests. - - Flow: - 1. Create FastMCP server with task-enabled tool that calls ctx.elicit() - 2. Start the task via Client (which initializes Docket with memory://) - 3. Background task blocks waiting for client input - 4. Simulate client sending input via handle_task_input() - 5. Task resumes and completes with the elicited value - - This demonstrates the complete elicitation flow with real infrastructure. - """ - import asyncio - - from fastmcp import FastMCP - from fastmcp.client import Client - from fastmcp.server.context import Context - from fastmcp.server.tasks.elicitation import handle_task_input - - mcp = FastMCP("elicit-memory-test") - - # Track task state using mutable container (avoids nonlocal) + mcp = FastMCP("elicit-accept-test") elicit_started = asyncio.Event() captured: dict[str, str | None] = {"task_id": None, "session_id": None} @mcp.tool(task=True) - async def ask_for_name(ctx: Context) -> str: - """Tool that elicits user's name via background task.""" - # Capture IDs for handle_task_input call + async def ask_name(ctx: Context) -> str: captured["task_id"] = ctx.task_id captured["session_id"] = ctx.session_id elicit_started.set() - # This will block until client sends input result = await ctx.elicit("What is your name?", str) - if isinstance(result, AcceptedElicitation): return f"Hello, {result.data}!" - else: - return "Elicitation was declined or cancelled" + return "No name provided" async with Client(mcp) as client: - # Start the background task - task = await client.call_tool("ask_for_name", {}, task=True) - assert task is not None - assert task.task_id is not None - - # Wait for task to reach elicit() call + task = await client.call_tool("ask_name", {}, task=True) await asyncio.wait_for(elicit_started.wait(), timeout=5.0) - # Poll until handle_task_input succeeds - # We need to wait for elicit_for_task to store the "waiting" status in Redis - # before we can send input. Using fixed-interval polling (not exponential - # backoff) because we're waiting for state, not recovering from errors. assert captured["task_id"] is not None assert captured["session_id"] is not None - max_attempts = 40 - poll_interval_seconds = 0.05 # 50ms - fast for tests, 2s max total + # Poll until the "waiting" status is stored in Redis success = False - for _ in range(max_attempts): + for _ in range(40): success = await handle_task_input( task_id=captured["task_id"], session_id=captured["session_id"], @@ -848,15 +264,127 @@ class TestBackgroundTaskContextWiring: ) if success: break - await asyncio.sleep(poll_interval_seconds) + await asyncio.sleep(0.05) - assert success is True, ( - f"handle_task_input should succeed within {max_attempts * poll_interval_seconds}s" - ) + assert success is True, "handle_task_input should succeed within 2s" - # Wait for task to complete await task.wait(timeout=10.0) result = await task.result() - - # Verify the tool received the elicited value and returned correctly assert result.data == "Hello, Bob!" + + async def test_elicit_decline_flow(self): + """E2E: tool elicits input, client declines, tool gets DeclinedElicitation.""" + mcp = FastMCP("elicit-decline-test") + elicit_started = asyncio.Event() + captured: dict[str, str | None] = {"task_id": None, "session_id": None} + + @mcp.tool(task=True) + async def optional_input(ctx: Context) -> str: + captured["task_id"] = ctx.task_id + captured["session_id"] = ctx.session_id + elicit_started.set() + + result = await ctx.elicit("Want to provide a name?", str) + if isinstance(result, DeclinedElicitation): + return "User declined" + if isinstance(result, AcceptedElicitation): + return f"Got: {result.data}" + return "Cancelled" + + async with Client(mcp) as client: + task = await client.call_tool("optional_input", {}, task=True) + await asyncio.wait_for(elicit_started.wait(), timeout=5.0) + + assert captured["task_id"] is not None + assert captured["session_id"] is not None + + success = False + for _ in range(40): + success = await handle_task_input( + task_id=captured["task_id"], + session_id=captured["session_id"], + action="decline", + content=None, + fastmcp=mcp, + ) + if success: + break + await asyncio.sleep(0.05) + + assert success is True + + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "User declined" + + async def test_elicit_with_pydantic_model(self): + """E2E: tool elicits structured Pydantic input, data round-trips correctly.""" + from pydantic import BaseModel + + class UserInfo(BaseModel): + name: str + age: int + + mcp = FastMCP("elicit-pydantic-test") + elicit_started = asyncio.Event() + captured: dict[str, str | None] = {"task_id": None, "session_id": None} + + @mcp.tool(task=True) + async def get_user_info(ctx: Context) -> str: + captured["task_id"] = ctx.task_id + captured["session_id"] = ctx.session_id + elicit_started.set() + + result = await ctx.elicit("Provide user info", UserInfo) + if isinstance(result, AcceptedElicitation): + assert isinstance(result.data, UserInfo) + return f"{result.data.name} is {result.data.age}" + return "No info" + + async with Client(mcp) as client: + task = await client.call_tool("get_user_info", {}, task=True) + await asyncio.wait_for(elicit_started.wait(), timeout=5.0) + + assert captured["task_id"] is not None + assert captured["session_id"] is not None + + success = False + for _ in range(40): + success = await handle_task_input( + task_id=captured["task_id"], + session_id=captured["session_id"], + action="accept", + content={"name": "Alice", "age": 30}, + fastmcp=mcp, + ) + if success: + break + await asyncio.sleep(0.05) + + assert success is True + + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "Alice is 30" + + async def test_handle_task_input_rejects_when_not_waiting(self): + """handle_task_input returns False when no task is waiting for input.""" + mcp = FastMCP("reject-test") + + @mcp.tool(task=True) + async def simple_tool() -> str: + return "done" + + async with Client(mcp) as client: + task = await client.call_tool("simple_tool", {}, task=True) + await task.wait(timeout=5.0) + + # Task already completed — no elicitation waiting + success = await handle_task_input( + task_id=task.task_id, + session_id="nonexistent-session", + action="accept", + content={"value": "too late"}, + fastmcp=mcp, + ) + assert success is False diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py new file mode 100644 index 000000000..d669a5ed6 --- /dev/null +++ b/tests/server/tasks/test_notifications.py @@ -0,0 +1,104 @@ +"""Tests for distributed notification queue (SEP-1686). + +Integration tests verify that the notification queue works end-to-end +using Client(mcp) with the real memory:// Docket backend. +No mocking of Redis, sessions, or Docket internals. +""" + +import asyncio + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.context import Context +from fastmcp.server.elicitation import AcceptedElicitation +from fastmcp.server.tasks.elicitation import handle_task_input +from fastmcp.server.tasks.notifications import ( + get_subscriber_count, +) + + +class TestNotificationIntegration: + """Integration tests for the notification queue using real Docket memory backend. + + The elicitation flow implicitly validates the full notification pipeline: + 1. Tool calls ctx.elicit() → stores request in Redis → pushes notification + 2. Subscriber picks up notification → sends MCP notification to client + 3. Client calls handle_task_input() → LPUSH response → BLPOP wakes tool + """ + + async def test_notification_delivered_during_elicitation(self): + """Full E2E: notification queue delivers elicitation notification to client.""" + mcp = FastMCP("notification-test") + elicit_started = asyncio.Event() + captured: dict[str, str | None] = {"task_id": None, "session_id": None} + + @mcp.tool(task=True) + async def elicit_tool(ctx: Context) -> str: + captured["task_id"] = ctx.task_id + captured["session_id"] = ctx.session_id + elicit_started.set() + + result = await ctx.elicit("Enter value", str) + if isinstance(result, AcceptedElicitation): + return f"got: {result.data}" + return "no value" + + async with Client(mcp) as client: + task = await client.call_tool("elicit_tool", {}, task=True) + await asyncio.wait_for(elicit_started.wait(), timeout=5.0) + + # Poll until the task is waiting for input + success = False + for _ in range(40): + success = await handle_task_input( + task_id=captured["task_id"], + session_id=captured["session_id"], + action="accept", + content={"value": "hello"}, + fastmcp=mcp, + ) + if success: + break + await asyncio.sleep(0.05) + + assert success is True + + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "got: hello" + + async def test_subscriber_lifecycle(self): + """Subscriber starts during background task and stops when client disconnects.""" + mcp = FastMCP("subscriber-test") + tool_started = asyncio.Event() + tool_continue = asyncio.Event() + + @mcp.tool(task=True) + async def lifecycle_tool(ctx: Context) -> str: + tool_started.set() + await asyncio.wait_for(tool_continue.wait(), timeout=10.0) + return "done" + + count_before = get_subscriber_count() + + async with Client(mcp) as client: + task = await client.call_tool("lifecycle_tool", {}, task=True) + await asyncio.wait_for(tool_started.wait(), timeout=5.0) + + # While a background task is running, subscriber should be active + count_during = get_subscriber_count() + assert count_during > count_before + + # Let the tool complete + tool_continue.set() + await task.wait(timeout=5.0) + result = await task.result() + assert result.data == "done" + + # After client disconnects, subscriber should be cleaned up + # Allow brief time for async cleanup + for _ in range(20): + if get_subscriber_count() == count_before: + break + await asyncio.sleep(0.05) + assert get_subscriber_count() == count_before From 3d665d48c0763a06e080653c15324578fd46d2a3 Mon Sep 17 00:00:00 2001 From: Guillaume FORTAINE Date: Sun, 8 Feb 2026 15:20:22 +0100 Subject: [PATCH 36/63] fix: stabilize task notification integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Codex --- src/fastmcp/server/tasks/elicitation.py | 32 ++++++--- src/fastmcp/server/tasks/handlers.py | 32 ++++++--- src/fastmcp/server/tasks/notifications.py | 32 +++++---- .../tasks/test_context_background_task.py | 12 ++-- tests/server/tasks/test_notifications.py | 69 +++++++++++++++++-- tests/server/tasks/test_task_protocol.py | 2 +- 6 files changed, 134 insertions(+), 45 deletions(-) diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py index b12d0f10e..299382df6 100644 --- a/src/fastmcp/server/tasks/elicitation.py +++ b/src/fastmcp/server/tasks/elicitation.py @@ -5,7 +5,7 @@ in Docket workers. Unlike regular MCP requests, background tasks don't have an active request context, so elicitation requires special handling: 1. Set task status to "input_required" via Redis -2. Send notifications/tasks/updated with elicitation metadata +2. Send notifications/tasks/status with elicitation metadata 3. Wait for client to send input via tasks/sendInput 4. Resume task execution with the provided input @@ -18,7 +18,8 @@ from __future__ import annotations import json import logging import uuid -from typing import TYPE_CHECKING, Any +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, cast import mcp.types from mcp import ServerSession @@ -115,15 +116,23 @@ async def elicit_for_task( ex=ELICIT_TTL_SECONDS, ) - # Send task status update notification with input_required status - # This follows SEP-1686 for background task status updates + # Send task status update notification with input_required status. + # Use notifications/tasks/status so typed MCP clients can consume it. # # NOTE: We use the distributed notification queue instead of session.send_notification() # This enables notifications to work when workers run in separate processes # (Azure Web PubSub / Service Bus inspired pattern) + timestamp = datetime.now(timezone.utc).isoformat() notification_dict = { - "method": "notifications/tasks/updated", - "params": {}, + "method": "notifications/tasks/status", + "params": { + "taskId": task_id, + "status": "input_required", + "statusMessage": message, + "createdAt": timestamp, + "lastUpdatedAt": timestamp, + "ttl": ELICIT_TTL_SECONDS * 1000, + }, "_meta": { "modelcontextprotocol.io/related-task": { "taskId": task_id, @@ -172,9 +181,12 @@ async def elicit_for_task( async with docket.redis() as redis: # BLPOP blocks until an item is pushed to the list or timeout # Returns tuple of (key, value) or None on timeout - result = await redis.blpop( - docket.key(response_key), - timeout=max_wait_seconds, + result = await cast( + Any, + redis.blpop( + [docket.key(response_key)], + timeout=max_wait_seconds, + ), ) if result: @@ -261,7 +273,7 @@ async def handle_task_input( # Push response to list - this wakes up the BLPOP in elicit_for_task # Using LPUSH instead of SET enables the efficient blocking wait pattern - await redis.lpush( + await redis.lpush( # type: ignore[invalid-await] # redis-py union type (sync/async) docket.key(response_key), json.dumps(response), ) diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index 646cf82dc..494fce87f 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -112,21 +112,31 @@ async def submit_to_docket( register_task_session(session_id, ctx.session) - # Send notifications/tasks/created per SEP-1686 (mandatory) - # Send BEFORE queuing to avoid race where task completes before notification - notification = mcp.types.JSONRPCNotification( - jsonrpc="2.0", - method="notifications/tasks/created", - params={}, # Empty params per spec - _meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field - "modelcontextprotocol.io/related-task": { + # Send an initial tasks/status notification before queueing. + # This guarantees clients can observe task creation immediately. + notification = mcp.types.TaskStatusNotification.model_validate( + { + "method": "notifications/tasks/status", + "params": { "taskId": server_task_id, - } - }, + "status": "working", + "statusMessage": "Task submitted", + "createdAt": created_at, + "lastUpdatedAt": created_at, + "ttl": ttl_ms, + "pollInterval": poll_interval_ms, + }, + "_meta": { + "modelcontextprotocol.io/related-task": { + "taskId": server_task_id, + } + }, + } ) + server_notification = mcp.types.ServerNotification(notification) with suppress(Exception): # Don't let notification failures break task creation - await ctx.session.send_notification(notification) # type: ignore[arg-type] + await ctx.session.send_notification(server_notification) # Queue function to Docket by key (result storage via execution_ttl) # Use component.add_to_docket() which handles calling conventions diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py index 07fe95ff6..2c65e31e1 100644 --- a/src/fastmcp/server/tasks/notifications.py +++ b/src/fastmcp/server/tasks/notifications.py @@ -23,7 +23,7 @@ import logging import weakref from contextlib import suppress from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import mcp.types @@ -67,7 +67,7 @@ async def push_notification( } ) async with docket.redis() as redis: - await redis.lpush(key, message) + await redis.lpush(key, message) # type: ignore[invalid-await] # redis-py union type (sync/async) await redis.expire(key, NOTIFICATION_TTL_SECONDS) @@ -104,8 +104,8 @@ async def notification_subscriber_loop( # Blocking wait for notification (timeout refreshes heartbeat) # Using BRPOP (right pop) for FIFO order with LPUSH (left push) - result = await redis.brpop( - queue_key, timeout=SUBSCRIBER_TIMEOUT_SECONDS + result = await cast( + Any, redis.brpop([queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS) ) if not result: continue # Timeout - refresh heartbeat and retry @@ -129,7 +129,7 @@ async def notification_subscriber_loop( # Re-queue with incremented attempt (back of queue) message["attempt"] = attempt + 1 message["last_error"] = str(send_error) - await redis.lpush(queue_key, json.dumps(message)) + await redis.lpush(queue_key, json.dumps(message)) # type: ignore[invalid-await] logger.debug( "Requeued notification for session %s (attempt %d): %s", session_id, @@ -166,18 +166,20 @@ async def _send_mcp_notification( session: MCP ServerSession notification_dict: Notification as dict (method, params, _meta) """ - # Build JSONRPCNotification from dict - notification = mcp.types.JSONRPCNotification( - jsonrpc="2.0", - method=notification_dict.get("method", "notifications/tasks/updated"), - params=notification_dict.get("params", {}), + method = notification_dict.get("method", "notifications/tasks/status") + if method != "notifications/tasks/status": + raise ValueError(f"Unsupported notification method for subscriber: {method}") + + notification = mcp.types.TaskStatusNotification.model_validate( + { + "method": "notifications/tasks/status", + "params": notification_dict.get("params", {}), + "_meta": notification_dict.get("_meta"), + } ) + server_notification = mcp.types.ServerNotification(notification) - # Preserve _meta if present (contains related-task info for elicitation) - if "_meta" in notification_dict: - notification._meta = notification_dict["_meta"] # type: ignore[attr-defined] - - await session.send_notification(notification) # type: ignore[arg-type] + await session.send_notification(server_notification) # ============================================================================= diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py index 0cdc9636a..219a7ae21 100644 --- a/tests/server/tasks/test_context_background_task.py +++ b/tests/server/tasks/test_context_background_task.py @@ -6,8 +6,10 @@ no mocking of Redis, Docket, or session internals. """ import asyncio +from typing import cast import pytest +from mcp import ServerSession from fastmcp import FastMCP from fastmcp.client import Client @@ -42,7 +44,7 @@ class TestContextBackgroundTaskSupport: mcp = FastMCP("test") ctx = Context(mcp, task_id="test-task-123") with pytest.raises(AttributeError): - ctx.task_id = "new-id" # type: ignore[misc] + setattr(ctx, "task_id", "new-id") class TestContextSessionProperty: @@ -64,7 +66,9 @@ class TestContextSessionProperty: _fastmcp_state_prefix = "test-session" mock_session = MockSession() - ctx = Context(mcp, session=mock_session, task_id="test-task-123") # type: ignore[arg-type] + ctx = Context( + mcp, session=cast(ServerSession, mock_session), task_id="test-task-123" + ) assert ctx.session is mock_session @@ -76,7 +80,7 @@ class TestContextSessionProperty: _fastmcp_state_prefix = "test-session" mock_session = MockSession() - ctx = Context(mcp, session=mock_session) # type: ignore[arg-type] + ctx = Context(mcp, session=cast(ServerSession, mock_session)) assert ctx.session is mock_session @@ -92,7 +96,7 @@ class TestContextElicitBackgroundTask: class MockSession: _fastmcp_state_prefix = "test-session" - ctx._session = MockSession() # type: ignore[assignment] + ctx._session = cast(ServerSession, MockSession()) with pytest.raises(RuntimeError, match="Docket"): await ctx.elicit("Need input", str) diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py index d669a5ed6..07b2b7f3b 100644 --- a/tests/server/tasks/test_notifications.py +++ b/tests/server/tasks/test_notifications.py @@ -7,8 +7,11 @@ No mocking of Redis, sessions, or Docket internals. import asyncio +import mcp.types + from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.client.messages import MessageHandler from fastmcp.server.context import Context from fastmcp.server.elicitation import AcceptedElicitation from fastmcp.server.tasks.elicitation import handle_task_input @@ -17,6 +20,24 @@ from fastmcp.server.tasks.notifications import ( ) +class NotificationCaptureHandler(MessageHandler): + """Capture server notifications for test assertions.""" + + def __init__(self) -> None: + super().__init__() + self.notifications: list[mcp.types.ServerNotification] = [] + + async def on_notification(self, message: mcp.types.ServerNotification) -> None: + self.notifications.append(message) + + def for_method(self, method: str) -> list[mcp.types.ServerNotification]: + return [ + notification + for notification in self.notifications + if notification.root.method == method + ] + + class TestNotificationIntegration: """Integration tests for the notification queue using real Docket memory backend. @@ -27,8 +48,9 @@ class TestNotificationIntegration: """ async def test_notification_delivered_during_elicitation(self): - """Full E2E: notification queue delivers elicitation notification to client.""" + """Full E2E: notification queue delivers input_required metadata to client.""" mcp = FastMCP("notification-test") + notification_handler = NotificationCaptureHandler() elicit_started = asyncio.Event() captured: dict[str, str | None] = {"task_id": None, "session_id": None} @@ -43,11 +65,50 @@ class TestNotificationIntegration: return f"got: {result.data}" return "no value" - async with Client(mcp) as client: + async with Client(mcp, message_handler=notification_handler) as client: task = await client.call_tool("elicit_tool", {}, task=True) await asyncio.wait_for(elicit_started.wait(), timeout=5.0) - # Poll until the task is waiting for input + assert captured["task_id"] is not None + assert captured["session_id"] is not None + + notification: mcp.types.ServerNotification | None = None + for _ in range(40): + candidates = notification_handler.for_method( + "notifications/tasks/status" + ) + for candidate in reversed(candidates): + candidate_meta = getattr(candidate.root, "_meta", None) + related_task = ( + candidate_meta.get("modelcontextprotocol.io/related-task") + if isinstance(candidate_meta, dict) + else None + ) + if ( + isinstance(related_task, dict) + and related_task.get("status") == "input_required" + ): + notification = candidate + break + if notification is not None: + break + await asyncio.sleep(0.05) + + assert notification is not None, "expected notifications/tasks/status" + task_meta = getattr(notification.root, "_meta", None) + assert isinstance(task_meta, dict) + + related_task = task_meta.get("modelcontextprotocol.io/related-task") + assert isinstance(related_task, dict) + assert related_task.get("taskId") == captured["task_id"] + assert related_task.get("status") == "input_required" + + elicitation = related_task.get("elicitation") + assert isinstance(elicitation, dict) + assert elicitation.get("message") == "Enter value" + assert isinstance(elicitation.get("requestId"), str) + assert isinstance(elicitation.get("requestedSchema"), dict) + success = False for _ in range(40): success = await handle_task_input( @@ -67,7 +128,7 @@ class TestNotificationIntegration: result = await task.result() assert result.data == "got: hello" - async def test_subscriber_lifecycle(self): + async def test_subscriber_started_and_cleaned_up(self): """Subscriber starts during background task and stops when client disconnects.""" mcp = FastMCP("subscriber-test") tool_started = asyncio.Event() diff --git a/tests/server/tasks/test_task_protocol.py b/tests/server/tasks/test_task_protocol.py index 1d1daa02c..9c6a88d22 100644 --- a/tests/server/tasks/test_task_protocol.py +++ b/tests/server/tasks/test_task_protocol.py @@ -48,7 +48,7 @@ async def test_task_metadata_includes_task_id_and_ttl(task_enabled_server): async def test_task_notification_sent_after_submission(task_enabled_server): - """Server sends notifications/tasks/created after task submission.""" + """Server sends an initial task status notification after submission.""" @task_enabled_server.tool(task=True) async def background_tool(message: str) -> str: From f7cdd20a42b11f6443489c9d114fe7328c552be2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:09:28 -0500 Subject: [PATCH 37/63] generate-cli: auto-generate SKILL.md agent skill (#3115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * generate-cli: auto-generate SKILL.md alongside CLI script generate-cli now produces a SKILL.md agent skill file next to the CLI script, documenting every tool's exact invocation syntax, parameter flags, and types. Agents can use the CLI immediately without discovery. * Use uv run --with fastmcp in generated SKILL.md invocations * Fix skill generation issues from review - Escape pipe chars in union type labels so markdown tables render - Boolean params omit placeholder in example invocations - Quote YAML frontmatter values to handle special chars in names - Match cyclopts camelCase→snake_case in flag derivation - Use four-backtick fence for nested code block in docs * Replace --skill/--no-skill with just --no-skill * Escape quotes in YAML frontmatter description * Strip newlines from param descriptions in skill table rows * Detect boolean union types for flag placeholder --- docs/clients/generate-cli.mdx | 38 ++++- src/fastmcp/cli/generate.py | 180 ++++++++++++++++++++- tests/cli/test_generate_cli.py | 280 +++++++++++++++++++++++++++++++++ 3 files changed, 494 insertions(+), 4 deletions(-) diff --git a/docs/clients/generate-cli.mdx b/docs/clients/generate-cli.mdx index 833637423..4d05e0515 100644 --- a/docs/clients/generate-cli.mdx +++ b/docs/clients/generate-cli.mdx @@ -23,7 +23,7 @@ fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py my_weather_cli.py ``` -The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If the file already exists, the command refuses to overwrite unless you pass `-f`: +The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If either the CLI file or its companion `SKILL.md` already exists, the command refuses to overwrite unless you pass `-f`: ```bash fastmcp generate-cli weather -f @@ -85,6 +85,42 @@ Options: Tool names are preserved exactly as the server defines them — underscores stay as underscores, so `call-tool get_forecast` matches what the server expects. +## Agent Skill + +Alongside the CLI script, `generate-cli` also writes a `SKILL.md` file — a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents the generated CLI. The skill includes every tool's exact invocation syntax, parameter flags with types and descriptions, and the utility commands, so an agent can use the CLI immediately without running `--help` or experimenting with flag names. + +The skill is written to the same directory as the CLI script. For a weather server, it looks something like: + +````markdown +--- +name: "weather-cli" +description: "CLI for the weather MCP server. Call tools, list resources, and get prompts." +--- + +# weather CLI + +## Tool Commands + +### get_forecast + +Get the weather forecast for a city. + +```bash +uv run --with fastmcp python cli.py call-tool get_forecast --city --days +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--city` | string | yes | City name | +| `--days` | integer | no | Number of forecast days | +```` + +To skip skill generation, pass `--no-skill`: + +```bash +fastmcp generate-cli weather --no-skill +``` + ## How It Works The generated script is a client, not a server. It doesn't bundle or embed the MCP server — it connects to it on every invocation. For URL-based servers, the server needs to be running. For stdio-based servers, the command specified in `CLIENT_SPEC` must be available on the system's `PATH`. diff --git a/src/fastmcp/cli/generate.py b/src/fastmcp/cli/generate.py index 7fdc6cd8a..b5e652909 100644 --- a/src/fastmcp/cli/generate.py +++ b/src/fastmcp/cli/generate.py @@ -1,4 +1,4 @@ -"""Generate a standalone CLI script from an MCP server's capabilities.""" +"""Generate a standalone CLI script and agent skill from an MCP server.""" import keyword import re @@ -518,6 +518,152 @@ def generate_cli_script( return "\n".join(lines) +# --------------------------------------------------------------------------- +# Skill (SKILL.md) generation +# --------------------------------------------------------------------------- + +_JSON_SCHEMA_TYPE_LABELS: dict[str, str] = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + "null": "null", + "array": "array", + "object": "object", +} + + +def _param_to_cli_flag(prop_name: str) -> str: + """Convert a JSON Schema property name to its CLI flag form. + + Replicates cyclopts' default_name_transform: camelCase → snake_case, + lowercase, underscores → hyphens, strip leading/trailing hyphens. + """ + safe = _to_python_identifier(prop_name) + # camelCase / PascalCase → snake_case + safe = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", safe) + safe = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", safe) + safe = safe.lower().replace("_", "-").strip("-") + return f"--{safe}" if safe else "--arg" + + +def _schema_type_label(prop_schema: dict[str, Any]) -> str: + """Return a human-readable type label for a property schema.""" + schema_type = prop_schema.get("type", "string") + if isinstance(schema_type, list): + labels = [_JSON_SCHEMA_TYPE_LABELS.get(t, t) for t in schema_type] + return " | ".join(labels) + + label = _JSON_SCHEMA_TYPE_LABELS.get(schema_type, schema_type) + + # For arrays, include item type if simple + if schema_type == "array": + items = prop_schema.get("items", {}) + item_type = items.get("type", "") + if isinstance(item_type, str) and item_type in _JSON_SCHEMA_TYPE_LABELS: + return f"array[{item_type}]" + + return label + + +def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str: + """Generate a SKILL.md section for a single tool.""" + schema = tool.inputSchema + properties: dict[str, Any] = schema.get("properties", {}) + required = set(schema.get("required", [])) + + # Build example invocation flags + flag_parts_list: list[str] = [] + for p, p_schema in properties.items(): + flag = _param_to_cli_flag(p) + schema_type = p_schema.get("type") + is_bool = schema_type == "boolean" or ( + isinstance(schema_type, list) and "boolean" in schema_type + ) + if is_bool: + flag_parts_list.append(flag) + else: + flag_parts_list.append(f"{flag} ") + flag_parts = " ".join(flag_parts_list) + invocation = f"uv run --with fastmcp python {cli_filename} call-tool {tool.name}" + if flag_parts: + invocation += f" {flag_parts}" + + # Build parameter table rows + rows: list[str] = [] + for prop_name, prop_schema in properties.items(): + flag = f"`{_param_to_cli_flag(prop_name)}`" + type_label = _schema_type_label(prop_schema).replace("|", "\\|") + is_required = "yes" if prop_name in required else "no" + description = prop_schema.get("description", "") + _, needs_json = _schema_to_python_type(prop_schema) + if needs_json: + description = ( + f"{description} (JSON string)" if description else "JSON string" + ) + description = description.replace("\n", " ").replace("|", "\\|") + rows.append(f"| {flag} | {type_label} | {is_required} | {description} |") + + param_table = "" + if rows: + header = "| Flag | Type | Required | Description |\n|------|------|----------|-------------|" + param_table = f"\n{header}\n" + "\n".join(rows) + "\n" + + lines: list[str] = [f"### {tool.name}"] + if tool.description: + lines.extend(["", tool.description]) + lines.extend(["", "```bash", invocation, "```"]) + if param_table: + lines.extend(["", param_table.strip("\n")]) + return "\n".join(lines) + + +def generate_skill_content( + server_name: str, + cli_filename: str, + tools: list[mcp.types.Tool], +) -> str: + """Generate a SKILL.md file for a generated CLI script.""" + skill_name = ( + server_name.replace(" ", "-").lower().replace("\\", "").replace('"', "") + ) + safe_name = server_name.replace("\\", "").replace('"', "") + description = f"CLI for the {safe_name} MCP server. Call tools, list resources, and get prompts." + + lines = [ + "---", + f'name: "{skill_name}-cli"', + f'description: "{description}"', + "---", + "", + f"# {server_name} CLI", + "", + ] + + if tools: + tool_bodies = "\n\n".join( + _tool_skill_section(tool, cli_filename) for tool in tools + ) + lines.extend(["## Tool Commands", "", tool_bodies, ""]) + + lines.extend( + [ + "## Utility Commands", + "", + "```bash", + f"uv run --with fastmcp python {cli_filename} list-tools", + f"uv run --with fastmcp python {cli_filename} list-resources", + f"uv run --with fastmcp python {cli_filename} read-resource ", + f"uv run --with fastmcp python {cli_filename} list-prompts", + f"uv run --with fastmcp python {cli_filename} get-prompt [key=value ...]", + "```", + "", + ] + ) + + return "\n".join(lines) + + # --------------------------------------------------------------------------- # CLI command # --------------------------------------------------------------------------- @@ -555,22 +701,40 @@ async def generate_cli_command( help="Auth method: 'oauth', a bearer token string, or 'none' to disable", ), ] = None, + no_skill: Annotated[ + bool, + cyclopts.Parameter( + "--no-skill", + help="Skip generating a SKILL.md agent skill alongside the CLI", + ), + ] = False, ) -> None: """Generate a standalone CLI script from an MCP server. Connects to the server, reads its tools/resources/prompts, and writes - a Python script that can invoke them directly. + a Python script that can invoke them directly. Also generates a SKILL.md + agent skill file unless --no-skill is passed. Examples: fastmcp generate-cli weather fastmcp generate-cli weather my_cli.py fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py output.py -f + fastmcp generate-cli weather --no-skill """ output_path = Path(output) + skill_path = output_path.parent / "SKILL.md" + + # Check both files up front before doing any work + existing: list[Path] = [] if output_path.exists() and not force: + existing.append(output_path) + if not no_skill and skill_path.exists() and not force: + existing.append(skill_path) + if existing: + names = ", ".join(f"[cyan]{p}[/cyan]" for p in existing) console.print( - f"[bold red]Error:[/bold red] [cyan]{output_path}[/cyan] already exists. " + f"[bold red]Error:[/bold red] {names} already exist(s). " f"Use [cyan]-f[/cyan] to overwrite." ) sys.exit(1) @@ -612,6 +776,16 @@ async def generate_cli_command( f"[green]✓[/green] Wrote [cyan]{output_path}[/cyan] " f"with {len(tools)} tool command(s)" ) + + if not no_skill: + skill_content = generate_skill_content( + server_name=server_name, + cli_filename=output_path.name, + tools=tools, + ) + skill_path.write_text(skill_content) + console.print(f"[green]✓[/green] Wrote [cyan]{skill_path}[/cyan]") + console.print(f"[dim]Run: python {output_path} --help[/dim]") diff --git a/tests/cli/test_generate_cli.py b/tests/cli/test_generate_cli.py index f513338d7..8f567c846 100644 --- a/tests/cli/test_generate_cli.py +++ b/tests/cli/test_generate_cli.py @@ -13,11 +13,14 @@ from fastmcp.cli import generate as generate_module from fastmcp.cli.client import Client from fastmcp.cli.generate import ( _derive_server_name, + _param_to_cli_flag, _schema_to_python_type, + _schema_type_label, _to_python_identifier, _tool_function_source, generate_cli_command, generate_cli_script, + generate_skill_content, serialize_transport, ) from fastmcp.client.transports.stdio import StdioTransport @@ -636,3 +639,280 @@ class TestGenerateCliCommand: output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) assert output.stat().st_mode & 0o111 + + @pytest.mark.usefixtures("_patch_client") + async def test_writes_skill_file(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + skill_path = tmp_path / "SKILL.md" + assert skill_path.exists() + content = skill_path.read_text() + assert "---" in content + assert "name:" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_contains_tools(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "### greet" in content + assert "### add" in content + assert "--name" in content + assert "call-tool greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_no_skill_flag(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output), no_skill=True) + assert not (tmp_path / "SKILL.md").exists() + + @pytest.mark.usefixtures("_patch_client") + async def test_error_if_skill_exists(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + with pytest.raises(SystemExit): + await generate_cli_command("test-server", str(output)) + + @pytest.mark.usefixtures("_patch_client") + async def test_force_overwrites_skill(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + await generate_cli_command("test-server", str(output), force=True) + content = (tmp_path / "SKILL.md").read_text() + assert content != "existing" + assert "### greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_references_cli_filename(self, tmp_path: Path): + output = tmp_path / "my_weather.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "uv run --with fastmcp python my_weather.py" in content + + +# --------------------------------------------------------------------------- +# _param_to_cli_flag +# --------------------------------------------------------------------------- + + +class TestParamToCliFlag: + def test_simple_name(self): + assert _param_to_cli_flag("city") == "--city" + + def test_underscore_name(self): + assert _param_to_cli_flag("max_days") == "--max-days" + + def test_hyphenated_name(self): + # content-type → _to_python_identifier → content_type → --content-type + assert _param_to_cli_flag("content-type") == "--content-type" + + def test_digit_prefix(self): + # 3d_mode → _3d_mode → --3d-mode (leading underscore stripped) + assert _param_to_cli_flag("3d_mode") == "--3d-mode" + + def test_trailing_underscore(self): + # from → from_ after identifier sanitization; Cyclopts strips trailing "-" + assert _param_to_cli_flag("from") == "--from" + + def test_camel_case(self): + # camelCase → camel-case (cyclopts default_name_transform) + assert _param_to_cli_flag("myParam") == "--my-param" + + def test_pascal_case(self): + assert _param_to_cli_flag("MyParam") == "--my-param" + + +# --------------------------------------------------------------------------- +# _schema_type_label +# --------------------------------------------------------------------------- + + +class TestSchemaTypeLabel: + def test_simple_string(self): + assert _schema_type_label({"type": "string"}) == "string" + + def test_integer(self): + assert _schema_type_label({"type": "integer"}) == "integer" + + def test_array_of_strings(self): + assert ( + _schema_type_label({"type": "array", "items": {"type": "string"}}) + == "array[string]" + ) + + def test_union_types(self): + result = _schema_type_label({"type": ["string", "null"]}) + assert "string" in result + assert "null" in result + + def test_object(self): + assert _schema_type_label({"type": "object"}) == "object" + + def test_missing_type(self): + assert _schema_type_label({}) == "string" + + +# --------------------------------------------------------------------------- +# generate_skill_content +# --------------------------------------------------------------------------- + + +class TestGenerateSkillContent: + def test_frontmatter(self): + content = generate_skill_content("weather", "cli.py", []) + assert content.startswith("---\n") + assert 'name: "weather-cli"' in content + assert "description:" in content + + def test_no_tools(self): + content = generate_skill_content("weather", "cli.py", []) + assert "## Utility Commands" in content + assert "## Tool Commands" not in content + + def test_tool_sections(self): + tools = [ + mcp.types.Tool( + name="greet", + description="Say hello", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Who to greet"} + }, + "required": ["name"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "## Tool Commands" in content + assert "### greet" in content + assert "Say hello" in content + assert "call-tool greet" in content + assert "`--name`" in content + assert "| string |" in content + assert "| yes |" in content + + def test_frontmatter_with_tools_starts_at_column_zero(self): + tools = [ + mcp.types.Tool( + name="greet", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("weather", "cli.py", tools) + assert content.splitlines()[0] == "---" + + def test_optional_param(self): + tools = [ + mcp.types.Tool( + name="search", + description="Search things", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # query is required, limit is not + assert "| `--query` | string | yes |" in content + assert "| `--limit` | integer | no |" in content + + def test_complex_json_param(self): + tools = [ + mcp.types.Tool( + name="create", + description="Create item", + inputSchema={ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": {"x": {"type": "integer"}}, + }, + }, + "required": ["data"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "JSON string" in content + + def test_no_params_tool(self): + tools = [ + mcp.types.Tool( + name="ping", + description="Ping the server", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "### ping" in content + assert "call-tool ping" in content + # No parameter table + assert "| Flag |" not in content + + def test_cli_filename_in_utility_commands(self): + content = generate_skill_content("test", "my_cli.py", []) + assert "uv run --with fastmcp python my_cli.py list-tools" in content + assert "uv run --with fastmcp python my_cli.py list-resources" in content + + def test_pipe_in_description_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "mode": {"type": "string", "description": "a|b|c"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "a\\|b\\|c" in content + + def test_union_type_pipes_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "val": {"type": ["string", "null"]}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # Pipes in type label must be escaped so markdown table renders correctly + assert "string \\| null" in content + + def test_boolean_param_no_value_placeholder(self): + tools = [ + mcp.types.Tool( + name="run", + description="Run something", + inputSchema={ + "type": "object", + "properties": { + "verbose": {"type": "boolean", "description": "Verbose output"}, + "name": {"type": "string"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "--verbose " not in content + assert "--name " in content + + def test_server_name_in_header(self): + content = generate_skill_content("My Weather API", "cli.py", []) + assert "# My Weather API CLI" in content + assert 'name: "my-weather-api-cli"' in content From 3b0660a6868b779f3b108b1c085cc15f92348ce7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:26:47 -0500 Subject: [PATCH 38/63] Rename ui= to app= and consolidate ToolUI/ResourceUI into AppConfig (#3117) * Rename ui= to app= and consolidate ToolUI/ResourceUI into AppConfig * Remove backward-compat aliases for ToolUI/ResourceUI/ui_to_meta_dict * Add extra=allow to AppConfig model_config for forward compatibility --- docs/development/v3-notes/v3-features.mdx | 23 +-- examples/apps/qr_server/README.md | 2 +- examples/apps/qr_server/qr_server.py | 8 +- src/fastmcp/server/apps.py | 44 ++---- .../local_provider/decorators/tools.py | 46 +++--- src/fastmcp/server/server.py | 45 ++++-- src/fastmcp/tools/function_tool.py | 1 + tests/test_apps.py | 143 +++++++++++------- 8 files changed, 179 insertions(+), 133 deletions(-) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index f982513d9..90d72f416 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -203,18 +203,20 @@ The `require_auth` authorization check introduced in beta1 has been removed in f 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. -**Registering tools with UI metadata:** +**Breaking change from beta 2:** The `ui=` parameter on `@mcp.tool()` and `@mcp.resource()` has been renamed to `app=`, and the `ToolUI`/`ResourceUI` classes have been consolidated into a single `AppConfig` class. This follows the established `task=True`/`TaskConfig` pattern. The wire format (`meta["ui"]`, `_meta.ui`) is unchanged. + +**Registering tools with app metadata:** ```python from fastmcp import FastMCP -from fastmcp.server.apps import ToolUI, ResourceUI, ResourceCSP, ResourcePermissions +from fastmcp.server.apps import AppConfig, ResourceCSP, ResourcePermissions mcp = FastMCP("My Server") # Register the HTML bundle as a ui:// resource with CSP @mcp.resource( "ui://my-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP(resource_domains=["https://unpkg.com"]), permissions=ResourcePermissions(clipboard_write={}), ), @@ -224,17 +226,17 @@ def app_html() -> str: return Path("./dist/index.html").read_text() # Tool with UI — clients render an iframe alongside the result -@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) +@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) async def list_users() -> list[dict]: return [{"id": "1", "name": "Alice"}] # App-only tool — visible to the UI but hidden from the model -@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"])) +@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"])) async def delete_user(id: str) -> dict: return {"deleted": True} ``` -The `ui=` parameter accepts either a typed model (`ToolUI`, `ResourceUI`) or a raw dict for forward compatibility. It merges into `meta["ui"]` — alongside any other metadata you set. +The `app=` parameter accepts `True` (enable with defaults), an `AppConfig` instance, or a raw dict for forward compatibility. It merges into `meta["ui"]` — alongside any other metadata you set. **`ui://` resources** automatically get the correct MIME type (`text/html;profile=mcp-app`) unless you override it explicitly. @@ -242,9 +244,9 @@ The `ui=` parameter accepts either a typed model (`ToolUI`, `ResourceUI`) or a r ```python from fastmcp import Context -from fastmcp.server.apps import ToolUI, UI_EXTENSION_ID +from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID -@mcp.tool(ui=ToolUI(resource_uri="ui://dashboard")) +@mcp.tool(app=AppConfig(resource_uri="ui://dashboard")) async def dashboard(ctx: Context) -> dict: data = compute_dashboard() if ctx.client_supports_extension(UI_EXTENSION_ID): @@ -253,11 +255,10 @@ async def dashboard(ctx: Context) -> dict: ``` **Key details:** -- `ToolUI` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional except for typical usage of `resource_uri`) -- `ResourceUI` fields: `csp`, `permissions`, `domain`, `prefers_border` — metadata for the resource itself when it's a UI bundle +- `AppConfig` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional). On resources, `resource_uri` and `visibility` are validated as not-applicable and will raise `ValueError` if set. - `csp` accepts a `ResourceCSP` model with structured domain lists: `connect_domains`, `resource_domains`, `frame_domains`, `base_uri_domains` - `permissions` accepts a `ResourcePermissions` model: `camera`, `microphone`, `geolocation`, `clipboard_write` (each set to `{}` to request) -- Both models use `extra="allow"` for forward compatibility with future spec additions +- `AppConfig` uses `extra="allow"` for forward compatibility with future spec additions - Models use Pydantic aliases for wire format (`resourceUri`, `prefersBorder`, `connectDomains`, `clipboardWrite`) - Resource metadata (including CSP/permissions) is propagated to `resources/read` response content items so hosts can read it when rendering the iframe - `ctx.client_supports_extension(id)` is a general-purpose method — works for any extension, not just MCP Apps diff --git a/examples/apps/qr_server/README.md b/examples/apps/qr_server/README.md index f2c9ebf1d..2cfe806b1 100644 --- a/examples/apps/qr_server/README.md +++ b/examples/apps/qr_server/README.md @@ -4,7 +4,7 @@ An MCP App server that generates QR codes with an interactive viewer UI. Ported ## What it demonstrates -- Linking a tool to a `ui://` resource via `ToolUI` +- Linking a tool to a `ui://` resource via `AppConfig` - Serving embedded HTML with the `@modelcontextprotocol/ext-apps` JS SDK from CDN - Declaring CSP resource domains via `ResourceCSP` - Returning `ImageContent` (base64 PNG) from a tool diff --git a/examples/apps/qr_server/qr_server.py b/examples/apps/qr_server/qr_server.py index 8478cd557..7a3d5ee64 100644 --- a/examples/apps/qr_server/qr_server.py +++ b/examples/apps/qr_server/qr_server.py @@ -1,7 +1,7 @@ """QR Code MCP App Server — generates QR codes with an interactive view UI. Demonstrates MCP Apps with FastMCP: -- Tool linked to a ui:// resource via ToolUI +- Tool linked to a ui:// resource via AppConfig - HTML resource with CSP metadata for CDN-loaded dependencies - Embedded HTML using the @modelcontextprotocol/ext-apps JS SDK - ImageContent return type for binary data @@ -26,7 +26,7 @@ import qrcode # type: ignore[import-untyped] from mcp import types from fastmcp import FastMCP -from fastmcp.server.apps import ResourceCSP, ResourceUI, ToolUI +from fastmcp.server.apps import AppConfig, ResourceCSP from fastmcp.tools import ToolResult VIEW_URI: str = "ui://qr-server/view.html" @@ -104,7 +104,7 @@ EMBEDDED_VIEW_HTML: str = """\ """ -@mcp.tool(ui=ToolUI(resource_uri=VIEW_URI)) +@mcp.tool(app=AppConfig(resource_uri=VIEW_URI)) def generate_qr( text: str = "https://gofastmcp.com", box_size: int = 10, @@ -159,7 +159,7 @@ def generate_qr( @mcp.resource( VIEW_URI, - ui=ResourceUI(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), + app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), ) def view() -> str: """Interactive QR code viewer — renders tool results as images.""" diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py index 566938b98..9da7bc8e2 100644 --- a/src/fastmcp/server/apps.py +++ b/src/fastmcp/server/apps.py @@ -74,8 +74,13 @@ class ResourcePermissions(BaseModel): model_config = {"populate_by_name": True, "extra": "allow"} -class ToolUI(BaseModel): - """Typed ``_meta.ui`` for tools — links a tool to its UI resource. +class AppConfig(BaseModel): + """Configuration for MCP App tools and resources. + + Controls how a tool or resource participates in the MCP Apps extension. + On tools, ``resource_uri`` and ``visibility`` specify which UI resource + to render and where the tool appears. On resources, those fields must + be left unset (the resource itself is the UI). All fields use ``exclude_none`` serialization so only explicitly-set values appear on the wire. Aliases match the MCP Apps wire format @@ -85,11 +90,11 @@ class ToolUI(BaseModel): resource_uri: str | None = Field( default=None, alias="resourceUri", - description="URI of the UI resource (typically ui:// scheme)", + description="URI of the UI resource (typically ui:// scheme). Tools only.", ) visibility: list[str] | None = Field( default=None, - description="Where this tool is visible: 'app', 'model', or both", + description="Where this tool is visible: 'app', 'model', or both. Tools only.", ) csp: ResourceCSP | None = Field( default=None, description="Content Security Policy for the app iframe" @@ -104,33 +109,14 @@ class ToolUI(BaseModel): description="Whether the UI prefers a visible border", ) - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "allow"} -class ResourceUI(BaseModel): - """Typed ``_meta.ui`` for resources — rendering hints for UI-capable clients.""" - - csp: ResourceCSP | None = Field( - default=None, description="Content Security Policy for the app iframe" - ) - permissions: ResourcePermissions | None = Field( - default=None, description="Iframe sandbox permissions" - ) - domain: str | None = Field(default=None, description="Domain for the iframe") - prefers_border: bool | None = Field( - default=None, - alias="prefersBorder", - description="Whether the UI prefers a visible border", - ) - - model_config = {"populate_by_name": True} - - -def ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any]: - """Convert a UI model or dict to the wire-format dict for ``meta["ui"]``.""" - if isinstance(ui, (ToolUI, ResourceUI)): - return ui.model_dump(by_alias=True, exclude_none=True) - return ui +def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: + """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.""" + if isinstance(app, AppConfig): + return app.model_dump(by_alias=True, exclude_none=True) + return app def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None: diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index bff0443a5..7527f1b76 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -46,26 +46,38 @@ class ToolDecoratorMixin: from fastmcp.decorators import get_fastmcp_meta from fastmcp.tools.function_tool import ToolMeta - meta = get_fastmcp_meta(tool) - if meta is not None and isinstance(meta, ToolMeta): - resolved_task = meta.task if meta.task is not None else False - enabled = meta.enabled + fmeta = get_fastmcp_meta(tool) + if fmeta is not None and isinstance(fmeta, ToolMeta): + resolved_task = fmeta.task if fmeta.task is not None else False + enabled = fmeta.enabled + + # Merge ToolMeta.app into the meta dict + tool_meta = fmeta.meta + if fmeta.app is not None: + from fastmcp.server.apps import app_config_to_meta_dict + + tool_meta = dict(tool_meta) if tool_meta else {} + if fmeta.app is True: + tool_meta["ui"] = True + else: + tool_meta["ui"] = app_config_to_meta_dict(fmeta.app) + tool = Tool.from_function( tool, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - tags=meta.tags, - output_schema=meta.output_schema, - annotations=meta.annotations, - meta=meta.meta, + name=fmeta.name, + version=fmeta.version, + title=fmeta.title, + description=fmeta.description, + icons=fmeta.icons, + tags=fmeta.tags, + output_schema=fmeta.output_schema, + annotations=fmeta.annotations, + meta=tool_meta, task=resolved_task, - exclude_args=meta.exclude_args, - serializer=meta.serializer, - timeout=meta.timeout, - auth=meta.auth, + exclude_args=fmeta.exclude_args, + serializer=fmeta.serializer, + timeout=fmeta.timeout, + auth=fmeta.auth, ) else: tool = Tool.from_function(tool) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 27494f706..7dc747821 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -59,10 +59,9 @@ from fastmcp.prompts.prompt import PromptResult from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.apps import ( - ResourceUI, - ToolUI, + AppConfig, + app_config_to_meta_dict, resolve_ui_mime_type, - ui_to_meta_dict, ) from fastmcp.server.auth import AuthContext, AuthProvider, run_auth_checks from fastmcp.server.dependencies import get_access_token @@ -1410,7 +1409,7 @@ class FastMCP( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, - ui: ToolUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1431,7 +1430,7 @@ class FastMCP( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, - ui: ToolUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1451,7 +1450,7 @@ class FastMCP( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, - ui: ToolUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1508,10 +1507,13 @@ class FastMCP( server.tool(my_function, name="custom_name") ``` """ - # Merge UI metadata into meta["ui"] before passing to provider - if ui is not None: + # Merge app config into meta["ui"] (wire format) before passing to provider + if app is not None and app is not False: meta = dict(meta) if meta else {} - meta["ui"] = ui_to_meta_dict(ui) + if app is True: + meta["ui"] = True + else: + meta["ui"] = app_config_to_meta_dict(app) # Delegate to LocalProvider with server-level defaults result = self._local_provider.tool( @@ -1571,7 +1573,7 @@ class FastMCP( tags: set[str] | None = None, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, - ui: ResourceUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, ) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]: @@ -1637,10 +1639,27 @@ class FastMCP( # Apply default MIME type for ui:// scheme resources mime_type = resolve_ui_mime_type(uri, mime_type) - # Merge UI metadata into meta["ui"] before passing to provider - if ui is not None: + # Validate app config for resources — resource_uri and visibility + # don't apply since the resource itself is the UI + if isinstance(app, AppConfig): + if app.resource_uri is not None: + raise ValueError( + "resource_uri cannot be set on resources — " + "the resource itself is the UI. " + "Use resource_uri on tools to point to a UI resource." + ) + if app.visibility is not None: + raise ValueError( + "visibility cannot be set on resources — it only applies to tools." + ) + + # Merge app config into meta["ui"] (wire format) before passing to provider + if app is not None and app is not False: meta = dict(meta) if meta else {} - meta["ui"] = ui_to_meta_dict(ui) + if app is True: + meta["ui"] = True + else: + meta["ui"] = app_config_to_meta_dict(app) # Delegate to LocalProvider with server-level defaults inner_decorator = self._local_provider.resource( diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 7ef6df398..812f5108c 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -73,6 +73,7 @@ class ToolMeta: output_schema: dict[str, Any] | NotSetT | None = NotSet annotations: ToolAnnotations | None = None meta: dict[str, Any] | None = None + app: Any = None task: bool | TaskConfig | None = None exclude_args: list[str] | None = None serializer: Any | None = None diff --git a/tests/test_apps.py b/tests/test_apps.py index 348eab36b..5d41897a2 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -1,6 +1,6 @@ """Tests for MCP Apps Phase 1 — SDK compatibility. -Covers UI metadata models, tool/resource registration with ``ui=``, +Covers app config models, tool/resource registration with ``app=``, extension negotiation, and the ``Context.client_supports_extension`` method. """ @@ -8,15 +8,16 @@ from __future__ import annotations from typing import Any +import pytest + from fastmcp import Client, FastMCP from fastmcp.server.apps import ( UI_EXTENSION_ID, UI_MIME_TYPE, + AppConfig, ResourceCSP, ResourcePermissions, - ResourceUI, - ToolUI, - ui_to_meta_dict, + app_config_to_meta_dict, ) from fastmcp.server.context import Context @@ -25,19 +26,19 @@ from fastmcp.server.context import Context # --------------------------------------------------------------------------- -class TestToolUI: +class TestAppConfig: def test_serializes_with_aliases(self): - ui = ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"]) - d = ui.model_dump(by_alias=True, exclude_none=True) + cfg = AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"]) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {"resourceUri": "ui://my-app/view.html", "visibility": ["app"]} def test_excludes_none_fields(self): - ui = ToolUI(resource_uri="ui://foo") - d = ui.model_dump(by_alias=True, exclude_none=True) + cfg = AppConfig(resource_uri="ui://foo") + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {"resourceUri": "ui://foo"} def test_all_fields(self): - ui = ToolUI( + cfg = AppConfig( resource_uri="ui://app", visibility=["app", "model"], csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), @@ -45,7 +46,7 @@ class TestToolUI: domain="example.com", prefers_border=True, ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "resourceUri": "ui://app", "visibility": ["app", "model"], @@ -56,8 +57,8 @@ class TestToolUI: } def test_populate_by_name(self): - ui = ToolUI(resource_uri="ui://app") - assert ui.resource_uri == "ui://app" + cfg = AppConfig(resource_uri="ui://app") + assert cfg.resource_uri == "ui://app" class TestResourceCSP: @@ -152,61 +153,63 @@ class TestResourcePermissions: assert d == {} -class TestResourceUI: +class TestAppConfigForResources: + """AppConfig without resource_uri/visibility — for use on resources.""" + def test_serializes_with_aliases(self): - ui = ResourceUI( + cfg = AppConfig( prefers_border=True, csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "prefersBorder": True, "csp": {"resourceDomains": ["https://cdn.example.com"]}, } def test_excludes_none_fields(self): - ui = ResourceUI() - d = ui.model_dump(by_alias=True, exclude_none=True) + cfg = AppConfig() + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {} def test_with_permissions(self): - ui = ResourceUI( + cfg = AppConfig( permissions=ResourcePermissions(microphone={}, clipboard_write={}), ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "permissions": {"microphone": {}, "clipboardWrite": {}}, } -class TestUIToMetaDict: - def test_from_tool_ui(self): - ui = ToolUI(resource_uri="ui://app", visibility=["app"]) - result = ui_to_meta_dict(ui) +class TestAppConfigToMetaDict: + def test_from_app_config_with_tool_fields(self): + cfg = AppConfig(resource_uri="ui://app", visibility=["app"]) + result = app_config_to_meta_dict(cfg) assert result["resourceUri"] == "ui://app" assert result["visibility"] == ["app"] - def test_from_resource_ui(self): - ui = ResourceUI(prefers_border=False) - result = ui_to_meta_dict(ui) + def test_from_app_config_resource_fields_only(self): + cfg = AppConfig(prefers_border=False) + result = app_config_to_meta_dict(cfg) assert result == {"prefersBorder": False} def test_passthrough_for_dict(self): raw: dict[str, Any] = {"resourceUri": "ui://app", "custom": "value"} - result = ui_to_meta_dict(raw) + result = app_config_to_meta_dict(raw) assert result is raw # --------------------------------------------------------------------------- -# Tool registration with ui= +# Tool registration with app= # --------------------------------------------------------------------------- -class TestToolRegistrationWithUI: - async def test_tool_ui_model(self): +class TestToolRegistrationWithApp: + async def test_app_config_model(self): server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) + @server.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) def my_tool() -> str: return "hello" @@ -215,10 +218,10 @@ class TestToolRegistrationWithUI: assert tools[0].meta is not None assert tools[0].meta["ui"]["resourceUri"] == "ui://my-app/view.html" - async def test_tool_ui_dict(self): + async def test_app_dict(self): server = FastMCP("test") - @server.tool(ui={"resourceUri": "ui://foo", "visibility": ["app"]}) + @server.tool(app={"resourceUri": "ui://foo", "visibility": ["app"]}) def my_tool() -> str: return "hello" @@ -227,10 +230,10 @@ class TestToolRegistrationWithUI: assert tools[0].meta["ui"]["resourceUri"] == "ui://foo" assert tools[0].meta["ui"]["visibility"] == ["app"] - async def test_ui_merges_with_existing_meta(self): + async def test_app_merges_with_existing_meta(self): server = FastMCP("test") - @server.tool(meta={"custom": "data"}, ui=ToolUI(resource_uri="ui://app")) + @server.tool(meta={"custom": "data"}, app=AppConfig(resource_uri="ui://app")) def my_tool() -> str: return "hello" @@ -240,10 +243,10 @@ class TestToolRegistrationWithUI: assert meta["custom"] == "data" assert meta["ui"]["resourceUri"] == "ui://app" - async def test_ui_in_mcp_wire_format(self): + async def test_app_in_mcp_wire_format(self): server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app", visibility=["app"])) + @server.tool(app=AppConfig(resource_uri="ui://app", visibility=["app"])) def my_tool() -> str: return "hello" @@ -253,7 +256,7 @@ class TestToolRegistrationWithUI: assert mcp_tool.meta["ui"]["resourceUri"] == "ui://app" assert mcp_tool.meta["ui"]["visibility"] == ["app"] - async def test_tool_without_ui_has_no_ui_meta(self): + async def test_tool_without_app_has_no_ui_meta(self): server = FastMCP("test") @server.tool @@ -266,11 +269,11 @@ class TestToolRegistrationWithUI: # --------------------------------------------------------------------------- -# Resource registration with ui:// and ui= +# Resource registration with ui:// and app= # --------------------------------------------------------------------------- -class TestResourceWithUI: +class TestResourceWithApp: async def test_ui_scheme_defaults_mime_type(self): server = FastMCP("test") @@ -292,12 +295,12 @@ class TestResourceWithUI: resources = list(await server.list_resources()) assert resources[0].mime_type == "text/html" - async def test_resource_ui_metadata(self): + async def test_resource_app_metadata(self): server = FastMCP("test") @server.resource( "ui://my-app/view.html", - ui=ResourceUI(prefers_border=True), + app=AppConfig(prefers_border=True), ) def app_html() -> str: return "hello" @@ -317,7 +320,7 @@ class TestResourceWithUI: assert resources[0].mime_type != UI_MIME_TYPE async def test_standalone_decorator_ui_scheme_defaults_mime_type(self): - """Test that the standalone @resource decorator also applies ui:// MIME default.""" + """The standalone @resource decorator also applies ui:// MIME default.""" from fastmcp.resources import resource @resource("ui://standalone-app/view.html") @@ -332,7 +335,7 @@ class TestResourceWithUI: assert resources[0].mime_type == UI_MIME_TYPE async def test_resource_template_ui_scheme_defaults_mime_type(self): - """Test that resource templates also apply ui:// MIME default.""" + """Resource templates also apply ui:// MIME default.""" server = FastMCP("test") @server.resource("ui://template-app/{view}") @@ -343,6 +346,30 @@ class TestResourceWithUI: assert len(templates) == 1 assert templates[0].mime_type == UI_MIME_TYPE + async def test_resource_rejects_resource_uri(self): + """AppConfig with resource_uri raises ValueError on resources.""" + server = FastMCP("test") + with pytest.raises(ValueError, match="resource_uri cannot be set on resources"): + + @server.resource( + "ui://my-app/view.html", + app=AppConfig(resource_uri="ui://other"), + ) + def app_html() -> str: + return "hello" + + async def test_resource_rejects_visibility(self): + """AppConfig with visibility raises ValueError on resources.""" + server = FastMCP("test") + with pytest.raises(ValueError, match="visibility cannot be set on resources"): + + @server.resource( + "ui://my-app/view.html", + app=AppConfig(visibility=["app"]), + ) + def app_html() -> str: + return "hello" + # --------------------------------------------------------------------------- # Extension advertisement @@ -382,11 +409,13 @@ class TestContextClientSupportsExtension: class TestIntegration: - async def test_tool_with_ui_roundtrip(self): - """UI metadata flows through to clients — no server-side stripping.""" + async def test_tool_with_app_roundtrip(self): + """App metadata flows through to clients — no server-side stripping.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app/view.html", visibility=["app"])) + @server.tool( + app=AppConfig(resource_uri="ui://app/view.html", visibility=["app"]) + ) async def my_tool() -> dict[str, str]: return {"result": "ok"} @@ -425,11 +454,11 @@ class TestIntegration: assert len(result.contents) == 1 assert result.contents[0].mimeType == UI_MIME_TYPE - async def test_ui_tool_callable(self): - """A tool registered with ui= is still callable normally.""" + async def test_app_tool_callable(self): + """A tool registered with app= is still callable normally.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app")) + @server.tool(app=AppConfig(resource_uri="ui://app")) async def greet(name: str) -> str: return f"Hello, {name}!" @@ -438,19 +467,17 @@ class TestIntegration: assert any("Hello, Alice!" in str(c) for c in result.content) async def test_extension_and_tool_together(self): - """Server advertises extension AND tool has UI meta (stored on FastMCP Tool).""" + """Server advertises extension AND tool has app meta.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://dashboard", visibility=["app"])) + @server.tool(app=AppConfig(resource_uri="ui://dashboard", visibility=["app"])) def dashboard() -> str: return "data" - # Verify the stored FastMCP Tool still has full metadata tools = list(await server.list_tools()) assert tools[0].meta is not None assert tools[0].meta["ui"]["resourceUri"] == "ui://dashboard" - # Verify the server advertises the extension async with Client(server) as client: extras = client.initialize_result.capabilities.model_extra or {} assert UI_EXTENSION_ID in extras.get("extensions", {}) @@ -461,7 +488,7 @@ class TestIntegration: @server.resource( "ui://secure-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP( resource_domains=["https://unpkg.com"], connect_domains=["https://api.example.com"], @@ -473,7 +500,7 @@ class TestIntegration: return "secure" @server.tool( - ui=ToolUI( + app=AppConfig( resource_uri="ui://secure-app/view.html", csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), permissions=ResourcePermissions(camera={}), @@ -509,7 +536,7 @@ class TestIntegration: @server.resource( "ui://csp-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP(resource_domains=["https://unpkg.com"]), ), ) From dfb857aa08f8ebeaa8950f8c535d73453e9f350b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:35:53 -0500 Subject: [PATCH 39/63] Scope Martian triage to bug-labeled issues for jlowin (#3124) --- .github/workflows/martian-issue-triage.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/martian-issue-triage.yml b/.github/workflows/martian-issue-triage.yml index 276c7142e..4cc499b85 100644 --- a/.github/workflows/martian-issue-triage.yml +++ b/.github/workflows/martian-issue-triage.yml @@ -8,7 +8,8 @@ jobs: martian-issue-triage: # For labeled events, verify the labeler is a repo member to prevent privilege escalation if: | - (github.event.action == 'opened' && contains(fromJSON('["strawgate", "jlowin"]'), github.actor)) || + (github.event.action == 'opened' && github.actor == 'strawgate') || + (github.event.action == 'opened' && github.actor == 'jlowin' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'triage-martian' && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.sender.author_association)) concurrency: From 45af482e738450b7c30df897a463641f3ba0f740 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:06:57 -0500 Subject: [PATCH 40/63] Add Azure OBO dependencies, auth token injection, and documentation (#2918) --- docs/integrations/azure.mdx | 132 ++++++++++++++++++ docs/servers/dependency-injection.mdx | 31 +++++ pyproject.toml | 3 +- src/fastmcp/dependencies.py | 2 + src/fastmcp/server/auth/providers/azure.py | 148 ++++++++++++++++++++- src/fastmcp/server/dependencies.py | 145 ++++++++++++++------ tests/server/auth/providers/test_azure.py | 96 +++++++++++++ tests/server/test_dependencies.py | 112 ++++++++++++++++ uv.lock | 65 ++++++++- 9 files changed, 688 insertions(+), 46 deletions(-) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index a5b316d88..4376a38ce 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -326,3 +326,135 @@ mcp = FastMCP(name="Azure MI App", auth=auth) For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`. + +## On-Behalf-Of (OBO) + + + +The On-Behalf-Of (OBO) flow allows your FastMCP server to call downstream Microsoft APIs—like Microsoft Graph—using the authenticated user's identity. When a user authenticates to your MCP server, you receive a token for your API. OBO exchanges that token for a new token that can call other services, maintaining the user's identity and permissions throughout the chain. + +This pattern is useful when your tools need to access user-specific data from Microsoft services: reading emails, accessing calendar events, querying SharePoint, or any other Graph API operation that requires user context. + + +OBO features require the `azure` extra: + +```bash +pip install 'fastmcp[azure]' +``` + + +### Azure Portal Setup + +OBO requires additional configuration in your Azure App registration beyond basic authentication. + + + + In your App registration, navigate to **API permissions** and add the Microsoft Graph permissions your tools will need. + + - Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** + - Select the permissions required for your use case (e.g., `Mail.Read`, `Calendars.Read`, `User.Read`) + - Repeat for any other APIs you need to call + + + Only add delegated permissions for OBO. Application permissions bypass user context entirely and are inappropriate for the OBO flow. + + + + + OBO requires admin consent for the permissions you've added. In the **API permissions** page, click **Grant admin consent for [Your Organization]**. + + Without admin consent, OBO token exchanges will fail with an `AADSTS65001` error indicating the user or administrator hasn't consented to use the application. + + + For development, you can grant consent for just your own account. For production, an Azure AD administrator must grant tenant-wide consent. + + + + +### Configure AzureProvider for OBO + +The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later. + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth.providers.azure import AzureProvider + +auth_provider = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], # Your API scope + # Include Graph scopes for OBO + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + "offline_access", # Enables refresh tokens + ], +) + +mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider) +``` + +Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access. + + +Use fully-qualified scope URIs for downstream APIs (e.g., `https://graph.microsoft.com/Mail.Read`). Short forms like `Mail.Read` work for authorization requests, but fully-qualified URIs are clearer and avoid ambiguity. + + +### EntraOBOToken Dependency + +The `EntraOBOToken` dependency handles the complete OBO flow automatically. Declare it as a parameter default with the scopes you need, and FastMCP exchanges the user's token for a downstream API token before your function runs. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken +import httpx + +auth_provider = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + ], +) + +mcp = FastMCP(name="Email Reader", auth=auth_provider) + +@mcp.tool +async def get_recent_emails( + count: int = 10, + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), +) -> list[dict]: + """Get the user's recent emails from Microsoft Graph.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://graph.microsoft.com/v1.0/me/messages?$top={count}", + headers={"Authorization": f"Bearer {graph_token}"}, + ) + response.raise_for_status() + data = response.json() + + return [ + {"subject": msg["subject"], "from": msg["from"]["emailAddress"]["address"]} + for msg in data.get("value", []) + ] +``` + +The `graph_token` parameter receives a ready-to-use access token for Microsoft Graph. FastMCP handles the OBO exchange transparently—your function just uses the token to call the API. + + +**Scope alignment is critical.** The scopes passed to `EntraOBOToken` must be a subset of the scopes in `additional_authorize_scopes`. If you request a scope during OBO that wasn't included in the initial authorization, the exchange will fail. + + + +For advanced OBO scenarios, use `CurrentAccessToken()` to get the user's token, then construct an `azure.identity.aio.OnBehalfOfCredential` directly with your Azure credentials. + + + +For a complete working example of Azure OBO with FastMCP, see [Pamela Fox's blog post on OBO flow for Entra-based MCP servers](https://blog.pamelafox.org/2026/01/using-on-behalf-of-flow-for-entra-based.html). + diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index d986bc52a..27dd3fd0c 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -237,6 +237,37 @@ The `AccessToken` object provides: - **`expires_at`**: Token expiration timestamp (if available) - **`claims`**: Dictionary of all token claims (JWT claims or provider-specific data) +### Token Claims + +When you need just one specific value from the token—like a user ID or tenant identifier—`TokenClaim()` extracts it directly without needing the full token object. + +```python +from fastmcp import FastMCP +from fastmcp.server.dependencies import TokenClaim + +mcp = FastMCP("Demo") + + +@mcp.tool +async def add_expense( + amount: float, + user_id: str = TokenClaim("oid"), # Azure object ID +) -> dict: + await db.insert({"user_id": user_id, "amount": amount}) + return {"status": "created", "user_id": user_id} +``` + +`TokenClaim()` raises a `RuntimeError` if the claim doesn't exist, listing available claims to help with debugging. + +Common claims vary by identity provider: + +| Provider | User ID Claim | Email Claim | Name Claim | +|----------|--------------|-------------|------------| +| Azure/Entra | `oid` | `email` | `name` | +| GitHub | `sub` | `email` | `name` | +| Google | `sub` | `email` | `name` | +| Auth0 | `sub` | `email` | `name` | + ### Background Task Dependencies diff --git a/pyproject.toml b/pyproject.toml index d1a7c5814..4c0eb22f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,13 +52,14 @@ classifiers = [ [project.optional-dependencies] anthropic = ["anthropic>=0.40.0"] +azure = ["azure-identity>=1.16.0"] openai = ["openai>=1.102.0"] tasks = ["pydocket>=0.17.2"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", - "fastmcp[anthropic,openai,tasks]", + "fastmcp[anthropic,azure,openai,tasks]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "opentelemetry-sdk>=1.20.0", diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py index 87d5367a9..b23222e9d 100644 --- a/src/fastmcp/dependencies.py +++ b/src/fastmcp/dependencies.py @@ -26,6 +26,7 @@ from fastmcp.server.dependencies import ( CurrentWorker, Progress, ProgressLike, + TokenClaim, ) __all__ = [ @@ -39,4 +40,5 @@ __all__ = [ "Depends", "Progress", "ProgressLike", + "TokenClaim", ] diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index cc0d2544e..b5974d887 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,7 +6,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from key_value.aio.protocols import AsyncKeyValue @@ -16,6 +16,7 @@ from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: + from azure.identity.aio import OnBehalfOfCredential from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull @@ -161,6 +162,10 @@ class AzureProvider(OAuthProxy): if "offline_access" not in parsed_additional_scopes: parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"] + # Store Azure-specific config for OBO credential creation + self._tenant_id = tenant_id + self._base_authority = base_authority + # Apply defaults self.identifier_uri = identifier_uri or f"api://{client_id}" self.additional_authorize_scopes: list[str] = parsed_additional_scopes @@ -453,6 +458,33 @@ class AzureProvider(OAuthProxy): logger.debug("Failed to extract Azure claims: %s", e) return None + def create_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential: + """Create an OnBehalfOfCredential for OBO token exchange. + + Uses the AzureProvider's configuration (client_id, client_secret, + tenant_id, authority) to create a credential that can exchange the + user's token for downstream API tokens. + + Args: + user_assertion: The user's access token to exchange via OBO. + + Returns: + A configured OnBehalfOfCredential ready for get_token() calls. + + Raises: + ImportError: If azure-identity is not installed (requires fastmcp[azure]). + """ + _require_azure_identity("OBO token exchange") + from azure.identity.aio import OnBehalfOfCredential + + return OnBehalfOfCredential( + tenant_id=self._tenant_id, + client_id=self._upstream_client_id, + client_secret=self._upstream_client_secret.get_secret_value(), + user_assertion=user_assertion, + authority=f"https://{self._base_authority}", + ) + class AzureJWTVerifier(JWTVerifier): """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -552,3 +584,117 @@ class AzureJWTVerifier(JWTVerifier): else: prefixed.append(f"{self._identifier_uri}/{scope}") return prefixed + + +# --- Dependency injection support --- +# These require fastmcp[azure] extra for azure-identity + +# Check if DI engine is available +try: + from docket.dependencies import Dependency +except ImportError: + from fastmcp._vendor.docket_di import Dependency + + +def _require_azure_identity(feature: str) -> None: + """Raise ImportError with install instructions if azure-identity is not available.""" + try: + import azure.identity # noqa: F401 + except ImportError as e: + raise ImportError( + f"{feature} requires the `azure` extra. " + "Install with: pip install 'fastmcp[azure]'" + ) from e + + +class _EntraOBOToken(Dependency): # type: ignore[misc] + """Dependency that performs OBO token exchange for Microsoft Entra. + + Uses azure.identity's OnBehalfOfCredential for async-native OBO, + with automatic token caching and refresh. + """ + + def __init__(self, scopes: list[str]): + self.scopes = scopes + self._credential: OnBehalfOfCredential | None = None + + async def __aenter__(self) -> str: + _require_azure_identity("EntraOBOToken") + + from fastmcp.server.dependencies import get_access_token, get_server + + access_token = get_access_token() + if access_token is None: + raise RuntimeError( + "No access token available. Cannot perform OBO exchange." + ) + + server = get_server() + if not isinstance(server.auth, AzureProvider): + raise RuntimeError( + "EntraOBOToken requires an AzureProvider as the auth provider. " + f"Current provider: {type(server.auth).__name__}" + ) + + self._credential = server.auth.create_obo_credential( + user_assertion=access_token.token, + ) + + try: + result = await self._credential.get_token(*self.scopes) + except BaseException: + await self._credential.close() + self._credential = None + raise + + return result.token + + async def __aexit__(self, *args: object) -> None: + if self._credential is not None: + await self._credential.close() + self._credential = None + + +def EntraOBOToken(scopes: list[str]) -> str: + """Exchange the user's Entra token for a downstream API token via OBO. + + This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, + allowing your MCP server to call downstream APIs (like Microsoft Graph) on + behalf of the authenticated user. + + Args: + scopes: The scopes to request for the downstream API. For Microsoft Graph, + use scopes like ["https://graph.microsoft.com/Mail.Read"] or + ["https://graph.microsoft.com/.default"]. + + Returns: + A dependency that resolves to the downstream API access token string + + Raises: + ImportError: If fastmcp[azure] is not installed + RuntimeError: If no access token is available, provider is not Azure, + or OBO exchange fails + + Example: + ```python + from fastmcp.server.auth.providers.azure import EntraOBOToken + import httpx + + @mcp.tool() + async def get_my_emails( + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]) + ): + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://graph.microsoft.com/v1.0/me/messages", + headers={"Authorization": f"Bearer {graph_token}"} + ) + return resp.json() + ``` + + Note: + For OBO to work, ensure the scopes are included in the AzureProvider's + `additional_authorize_scopes` parameter, and that admin consent has been + granted for those scopes in your Entra app registration. + """ + return cast(str, _EntraOBOToken(scopes)) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index ffef2561b..97cf4fbef 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -53,6 +53,7 @@ __all__ = [ "CurrentWorker", "Progress", "TaskContextInfo", + "TokenClaim", "get_access_token", "get_context", "get_http_headers", @@ -991,47 +992,6 @@ def CurrentHeaders() -> dict[str, str]: return cast(dict[str, str], _CurrentHeaders()) -class _CurrentAccessToken(Dependency): # type: ignore[misc] - """Async context manager for AccessToken dependency.""" - - async def __aenter__(self) -> AccessToken: - token = get_access_token() - if token is None: - raise RuntimeError( - "No access token found. Ensure authentication is configured " - "and the request is authenticated." - ) - return token - - async def __aexit__(self, *args: object) -> None: - pass - - -def CurrentAccessToken() -> AccessToken: - """Get the current access token for the authenticated user. - - This dependency provides access to the AccessToken for the current - authenticated request. Raises an error if no authentication is present. - - Returns: - A dependency that resolves to the active AccessToken - - Raises: - RuntimeError: If no authenticated user (use get_access_token() for optional) - - Example: - ```python - from fastmcp.server.dependencies import CurrentAccessToken - from fastmcp.server.auth import AccessToken - - @mcp.tool() - async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: - return token.claims.get("sub", "unknown") - ``` - """ - return cast(AccessToken, _CurrentAccessToken()) - - # --- Progress dependency --- @@ -1162,3 +1122,106 @@ class Progress(Dependency): # type: ignore[misc] async def __aexit__(self, *args: object) -> None: pass + + +# --- Access Token dependency --- + + +class _CurrentAccessToken(Dependency): # type: ignore[misc] + """Async context manager for AccessToken dependency.""" + + async def __aenter__(self) -> AccessToken: + token = get_access_token() + if token is None: + raise RuntimeError( + "No access token found. Ensure authentication is configured " + "and the request is authenticated." + ) + return token + + async def __aexit__(self, *args: object) -> None: + pass + + +def CurrentAccessToken() -> AccessToken: + """Get the current access token for the authenticated user. + + This dependency provides access to the AccessToken for the current + authenticated request. Raises an error if no authentication is present. + + Returns: + A dependency that resolves to the active AccessToken + + Raises: + RuntimeError: If no authenticated user (use get_access_token() for optional) + + Example: + ```python + from fastmcp.server.dependencies import CurrentAccessToken + from fastmcp.server.auth import AccessToken + + @mcp.tool() + async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: + return token.claims.get("sub", "unknown") + ``` + """ + return cast(AccessToken, _CurrentAccessToken()) + + +# --- Token Claim dependency --- + + +class _TokenClaim(Dependency): # type: ignore[misc] + """Dependency that extracts a specific claim from the access token.""" + + def __init__(self, claim_name: str): + self.claim_name = claim_name + + async def __aenter__(self) -> str: + token = get_access_token() + if token is None: + raise RuntimeError( + f"No access token available. Cannot extract claim '{self.claim_name}'." + ) + value = token.claims.get(self.claim_name) + if value is None: + raise RuntimeError( + f"Claim '{self.claim_name}' not found in access token. " + f"Available claims: {list(token.claims.keys())}" + ) + return str(value) + + async def __aexit__(self, *args: object) -> None: + pass + + +def TokenClaim(name: str) -> str: + """Get a specific claim from the access token. + + This dependency extracts a single claim value from the current access token. + It's useful for getting user identifiers, roles, or other token claims + without needing the full token object. + + Args: + name: The name of the claim to extract (e.g., "oid", "sub", "email") + + Returns: + A dependency that resolves to the claim value as a string + + Raises: + RuntimeError: If no access token is available or claim is missing + + Example: + ```python + from fastmcp.server.dependencies import TokenClaim + + @mcp.tool() + async def add_expense( + user_id: str = TokenClaim("oid"), # Azure object ID + amount: float, + ): + # user_id is automatically injected from the token + await db.insert({"user_id": user_id, "amount": amount}) + ``` + """ + return cast(str, _TokenClaim(name)) diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 6bf25a50d..9542c19f9 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -1122,3 +1122,99 @@ class TestAzureJWTVerifier: verifier.issuer == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0" ) + + +class TestAzureOBOIntegration: + """Tests for azure.identity OBO integration (create_obo_credential, EntraOBOToken).""" + + def test_create_obo_credential_returns_configured_credential(self): + """Test that create_obo_credential returns a properly configured credential.""" + from unittest.mock import MagicMock, patch + + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="test-tenant-id", + base_url="https://myserver.com", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + mock_credential = MagicMock() + with patch( + "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential + ) as mock_class: + credential = provider.create_obo_credential(user_assertion="user-token-123") + + mock_class.assert_called_once_with( + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + user_assertion="user-token-123", + authority="https://login.microsoftonline.com", + ) + assert credential is mock_credential + + def test_create_obo_credential_with_custom_authority(self): + """Test that create_obo_credential uses custom base_authority.""" + from unittest.mock import MagicMock, patch + + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="gov-tenant-id", + base_url="https://myserver.com", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + mock_credential = MagicMock() + with patch( + "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential + ) as mock_class: + provider.create_obo_credential(user_assertion="user-token") + + call_kwargs = mock_class.call_args[1] + assert call_kwargs["authority"] == "https://login.microsoftonline.us" + + def test_tenant_and_authority_stored_as_attributes(self): + """Test that tenant_id and base_authority are stored for OBO credential creation.""" + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="my-tenant", + base_url="https://myserver.com", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + assert provider._tenant_id == "my-tenant" + assert provider._base_authority == "login.microsoftonline.us" + + def test_entra_obo_token_is_importable(self): + """Test that EntraOBOToken can be imported.""" + from fastmcp.server.auth.providers.azure import EntraOBOToken + + assert EntraOBOToken is not None + + def test_entra_obo_token_creates_dependency(self): + """Test that EntraOBOToken creates a dependency with scopes.""" + from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken + + dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"]) + assert isinstance(dep, _EntraOBOToken) + assert dep.scopes == ["https://graph.microsoft.com/User.Read"] + + def test_entra_obo_token_is_dependency_instance(self): + """Test that EntraOBOToken is a Dependency instance.""" + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.auth.providers.azure import _EntraOBOToken + + dep = _EntraOBOToken(["scope"]) + assert isinstance(dep, Dependency) diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 7d4119c8e..106babecc 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -1045,3 +1045,115 @@ class TestVendoredDI: db_dep = deps["db"] assert isinstance(db_dep, _Depends) assert db_dep.dependency is get_db + + +class TestAuthDependencies: + """Tests for authentication dependencies (CurrentAccessToken, TokenClaim).""" + + def test_current_access_token_is_importable(self): + """Test that CurrentAccessToken can be imported.""" + from fastmcp.server.dependencies import CurrentAccessToken + + assert CurrentAccessToken is not None + + def test_token_claim_is_importable(self): + """Test that TokenClaim can be imported.""" + from fastmcp.server.dependencies import TokenClaim + + assert TokenClaim is not None + + def test_current_access_token_is_dependency(self): + """Test that CurrentAccessToken is a Dependency instance.""" + # Import the Dependency class the same way the code does + # (docket if available, vendored otherwise) + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.dependencies import _CurrentAccessToken + + dep = _CurrentAccessToken() + assert isinstance(dep, Dependency) + + def test_token_claim_creates_dependency(self): + """Test that TokenClaim creates a Dependency instance.""" + # Import the Dependency class the same way the code does + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.dependencies import TokenClaim, _TokenClaim + + dep = TokenClaim("oid") + assert isinstance(dep, _TokenClaim) + assert isinstance(dep, Dependency) + assert dep.claim_name == "oid" + + async def test_current_access_token_raises_without_token(self): + """Test that CurrentAccessToken raises when no token is available.""" + from fastmcp.server.dependencies import _CurrentAccessToken + + dep = _CurrentAccessToken() + with pytest.raises(RuntimeError, match="No access token found"): + await dep.__aenter__() + + async def test_token_claim_raises_without_token(self): + """Test that TokenClaim raises when no token is available.""" + from fastmcp.server.dependencies import _TokenClaim + + dep = _TokenClaim("oid") + with pytest.raises(RuntimeError, match="No access token available"): + await dep.__aenter__() + + async def test_current_access_token_excluded_from_tool_schema(self, mcp: FastMCP): + """Test that CurrentAccessToken dependency is excluded from tool schema.""" + import mcp.types as mcp_types + + from fastmcp.server.auth import AccessToken + from fastmcp.server.dependencies import CurrentAccessToken + + @mcp.tool() + async def tool_with_token( + name: str, + token: AccessToken = CurrentAccessToken(), + ) -> str: + return name + + result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) + tool = next(t for t in result.tools if t.name == "tool_with_token") + + assert "name" in tool.inputSchema["properties"] + assert "token" not in tool.inputSchema["properties"] + + async def test_token_claim_excluded_from_tool_schema(self, mcp: FastMCP): + """Test that TokenClaim dependency is excluded from tool schema.""" + import mcp.types as mcp_types + + from fastmcp.server.dependencies import TokenClaim + + @mcp.tool() + async def tool_with_claim( + name: str, + user_id: str = TokenClaim("oid"), + ) -> str: + return name + + result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) + tool = next(t for t in result.tools if t.name == "tool_with_claim") + + assert "name" in tool.inputSchema["properties"] + assert "user_id" not in tool.inputSchema["properties"] + + def test_current_access_token_exported_from_all(self): + """Test that CurrentAccessToken is exported from __all__.""" + from fastmcp.server import dependencies + + assert "CurrentAccessToken" in dependencies.__all__ + + def test_token_claim_exported_from_all(self): + """Test that TokenClaim is exported from __all__.""" + from fastmcp.server import dependencies + + assert "TokenClaim" in dependencies.__all__ diff --git a/uv.lock b/uv.lock index c158b7fb6..3f329e1f3 100644 --- a/uv.lock +++ b/uv.lock @@ -97,6 +97,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, ] +[[package]] +name = "azure-core" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -707,6 +736,9 @@ dependencies = [ anthropic = [ { name = "anthropic" }, ] +azure = [ + { name = "azure-identity" }, +] openai = [ { name = "openai" }, ] @@ -718,7 +750,7 @@ tasks = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["anthropic", "openai", "tasks"] }, + { name = "fastmcp", extra = ["anthropic", "azure", "openai", "tasks"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -748,6 +780,7 @@ dev = [ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" }, { name = "authlib", specifier = ">=1.6.5" }, + { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" }, { name = "cyclopts", specifier = ">=4.0.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1,<1.0" }, @@ -770,13 +803,13 @@ requires-dist = [ { name = "watchfiles", specifier = ">=1.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["anthropic", "openai", "tasks"] +provides-extras = ["anthropic", "azure", "openai", "tasks"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "fastmcp", extras = ["anthropic", "openai", "tasks"] }, + { name = "fastmcp", extras = ["anthropic", "azure", "openai", "tasks"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "loq", specifier = ">=0.1.0a3" }, @@ -1413,6 +1446,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] +[[package]] +name = "msal" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "openai" version = "2.16.0" From 939cf5fcf252a8b4e673254de433dce93a825a53 Mon Sep 17 00:00:00 2001 From: Martim Santos Date: Tue, 10 Feb 2026 01:40:06 +0000 Subject: [PATCH 41/63] feat: add Static Client Registration (#3085) (#3086) Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- docs/clients/auth/oauth.mdx | 34 ++- src/fastmcp/client/auth/oauth.py | 44 ++- tests/client/auth/test_oauth_static_client.py | 274 ++++++++++++++++++ 3 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 tests/client/auth/test_oauth_static_client.py diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 25804adc3..84fbe2164 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -55,6 +55,8 @@ You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — - **`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_id`** (`str`, optional): Pre-registered OAuth client ID. When provided, skips Dynamic Client Registration entirely. See [Pre-Registered Clients](#pre-registered-clients) +- **`client_secret`** (`str`, optional): OAuth client secret for pre-registered clients. Optional — public clients that rely on PKCE can omit this - **`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 @@ -74,7 +76,7 @@ The client first checks the configured `token_storage` backend for existing, val If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`.
-If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. 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. +If a `client_id` is provided, the client uses those pre-registered credentials directly and skips this step entirely. Otherwise, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity. As a fallback, the client performs Dynamic Client Registration (RFC 7591) if the server supports it. A temporary local HTTP server is started on an available port (or the port specified via `callback_port`). This server's address (e.g., `http://127.0.0.1:/callback`) acts as the `redirect_uri` for the OAuth flow. @@ -152,3 +154,33 @@ async with Client( ``` See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents. + +## Pre-Registered Clients + + + +Some OAuth servers don't support Dynamic Client Registration — the MCP spec explicitly makes DCR optional. If your client has been pre-registered with the server (you already have a `client_id` and optionally a `client_secret`), you can provide them directly to skip DCR entirely. + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_id="my-registered-client-id", + client_secret="my-client-secret", + ), +) as client: + await client.ping() +``` + +Public clients that rely on PKCE for security can omit `client_secret`: + +```python +oauth = OAuth(client_id="my-public-client-id") +``` + + +When using pre-registered credentials, the client will not attempt Dynamic Client Registration. If the server rejects the credentials, the error is surfaced immediately rather than falling back to DCR. + diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 9fc90b4e8..a4d1e9c77 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -154,7 +154,12 @@ class OAuth(OAuthClientProvider): additional_client_metadata: dict[str, Any] | None = None, callback_port: int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, + # Alternative to dynamic client registration: + # --- Clients host a static JSON document at an HTTPS URL --- client_metadata_url: str | None = None, + # --- OR clients provide full client information --- + client_id: str | None = None, + client_secret: str | None = None, ): """ Initialize OAuth client provider for an MCP server. @@ -173,6 +178,9 @@ class OAuth(OAuthClientProvider): 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"). + client_id: Pre-registered OAuth client ID. When provided, skips dynamic + client registration and uses these static credentials instead. + client_secret: OAuth client secret (optional, used with client_id) """ # Store config for deferred binding if mcp_url not yet known self._scopes = scopes @@ -181,6 +189,9 @@ class OAuth(OAuthClientProvider): self._additional_client_metadata = additional_client_metadata self._callback_port = callback_port self._client_metadata_url = client_metadata_url + self._client_id = client_id + self._client_secret = client_secret + self._static_client_info = None self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient self._bound = False @@ -218,6 +229,23 @@ class OAuth(OAuthClientProvider): **(self._additional_client_metadata or {}), ) + if self._client_id: + # Create the full static client info directly which will avoid DCR. + # Spread client_metadata so redirect_uris, grant_types, response_types, + # scope, etc. are included — servers may validate these fields. + metadata = client_metadata.model_dump(exclude_none=True) + # Default token_endpoint_auth_method based on whether a secret is + # provided, unless the caller already set it via additional_client_metadata. + if "token_endpoint_auth_method" not in metadata: + metadata["token_endpoint_auth_method"] = ( + "client_secret_post" if self._client_secret else "none" + ) + self._static_client_info = OAuthClientInformationFull( + client_id=self._client_id, + client_secret=self._client_secret, + **metadata, + ) + token_storage = self._token_storage or MemoryStore() if isinstance(token_storage, MemoryStore): @@ -230,6 +258,7 @@ 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 ) @@ -249,10 +278,12 @@ class OAuth(OAuthClientProvider): 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 await super()._initialize() - # If tokens were loaded and have expires_in, update the context's token_expiry_time + if self._static_client_info is not None: + self.context.client_info = self._static_client_info + await self.token_storage_adapter.set_client_info(self._static_client_info) + if self.context.current_tokens and self.context.current_tokens.expires_in: self.context.update_token_expiry(self.context.current_tokens) @@ -342,6 +373,15 @@ class OAuth(OAuthClientProvider): break except ClientNotFoundError: + # Static credentials are fixed — retrying won't help. Surface the + # error so the user can correct their client_id / client_secret. + if self._static_client_info is not None: + raise ClientNotFoundError( + "OAuth server rejected the static client credentials. " + "Verify that the client_id (and client_secret, if provided) " + "are correct and that the client is registered with the server." + ) from None + logger.debug( "OAuth client not found on server, clearing cache and retrying..." ) diff --git a/tests/client/auth/test_oauth_static_client.py b/tests/client/auth/test_oauth_static_client.py new file mode 100644 index 000000000..c9f17cdbe --- /dev/null +++ b/tests/client/auth/test_oauth_static_client.py @@ -0,0 +1,274 @@ +"""Tests for OAuth static client registration (pre-registered client_id/client_secret).""" + +from unittest.mock import patch + +import httpx +import pytest +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl + +from fastmcp.client import Client +from fastmcp.client.auth import OAuth +from fastmcp.client.auth.oauth import ClientNotFoundError +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.auth.auth import ClientRegistrationOptions +from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider +from fastmcp.server.server import FastMCP +from fastmcp.utilities.http import find_available_port +from fastmcp.utilities.tests import HeadlessOAuth, run_server_async + + +class TestStaticClientInfoConstruction: + """Static client info should include full metadata from client_metadata.""" + + def test_static_client_info_includes_metadata(self): + """Static client info should include redirect_uris, grant_types, etc.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client-id", + client_secret="my-secret", + scopes=["read", "write"], + ) + + info = oauth._static_client_info + assert info is not None + assert info.client_id == "my-client-id" + assert info.client_secret == "my-secret" + # Metadata fields should be populated from client_metadata + assert info.redirect_uris is not None + assert len(info.redirect_uris) == 1 + assert info.grant_types is not None + assert "authorization_code" in info.grant_types + assert "refresh_token" in info.grant_types + assert info.response_types is not None + assert "code" in info.response_types + assert info.scope == "read write" + assert info.token_endpoint_auth_method == "client_secret_post" + + def test_static_client_info_without_secret(self): + """Public clients can provide client_id without client_secret.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="public-client", + ) + + info = oauth._static_client_info + assert info is not None + assert info.client_id == "public-client" + assert info.client_secret is None + assert info.token_endpoint_auth_method == "none" + # Metadata should still be present + assert info.redirect_uris is not None + assert info.grant_types is not None + + def test_no_static_client_info_without_client_id(self): + """When no client_id is provided, _static_client_info should be None.""" + oauth = OAuth(mcp_url="https://example.com/mcp") + assert oauth._static_client_info is None + + def test_static_client_info_includes_additional_metadata(self): + """Additional client metadata should be included in static client info.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client", + additional_client_metadata={ + "token_endpoint_auth_method": "client_secret_post" + }, + ) + + info = oauth._static_client_info + assert info is not None + assert info.token_endpoint_auth_method == "client_secret_post" + + +class TestStaticClientInitialize: + """_initialize should set context.client_info and persist to storage.""" + + async def test_initialize_sets_context_client_info(self): + """_initialize should inject static client info into the auth context.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client", + client_secret="my-secret", + ) + + # Mock the parent _initialize since it needs a real server + with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): + await oauth._initialize() + + assert oauth.context.client_info is not None + assert oauth.context.client_info.client_id == "my-client" + assert oauth.context.client_info.client_secret == "my-secret" + + async def test_initialize_persists_static_client_to_storage(self): + """Static client info should be persisted to token storage.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client", + client_secret="my-secret", + ) + + with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): + await oauth._initialize() + + # Verify it was persisted to storage + stored = await oauth.token_storage_adapter.get_client_info() + assert stored is not None + assert stored.client_id == "my-client" + + async def test_initialize_without_static_creds_works(self): + """_initialize should not error when no static credentials are provided.""" + oauth = OAuth(mcp_url="https://example.com/mcp") + + with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): + # This should not raise AttributeError + await oauth._initialize() + + # context.client_info should be whatever the parent set (None by default) + + +class TestStaticClientRetryBehavior: + """Retry-on-stale-credentials should short-circuit for static creds.""" + + async def test_retry_skipped_with_static_creds(self): + """When static creds are rejected, should raise immediately, not retry.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="bad-client-id", + client_secret="bad-secret", + ) + + # Make the parent auth flow raise ClientNotFoundError + async def failing_auth_flow(request): + raise ClientNotFoundError("client not found") + yield # make it a generator # noqa: E275 + + with patch.object( + OAuth.__bases__[0], "async_auth_flow", side_effect=failing_auth_flow + ): + flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) + with pytest.raises(ClientNotFoundError, match="static client credentials"): + await flow.__anext__() + + async def test_retry_still_works_without_static_creds(self): + """Without static creds, the retry behavior should be preserved.""" + oauth = OAuth(mcp_url="https://example.com/mcp") + + call_count = 0 + + async def auth_flow_with_retry(request): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ClientNotFoundError("client not found") + # Second attempt succeeds + yield httpx.Request("GET", "https://example.com") + + with patch.object( + OAuth.__bases__[0], "async_auth_flow", side_effect=auth_flow_with_retry + ): + flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) + request = await flow.__anext__() + assert request is not None + assert call_count == 2 + + +class TestStaticClientE2E: + """End-to-end tests with a real OAuth server using pre-registered clients.""" + + async def test_static_client_with_dcr_disabled(self): + """Static client_id should work when the server has DCR disabled.""" + port = find_available_port() + callback_port = find_available_port() + issuer_url = f"http://127.0.0.1:{port}" + + provider = InMemoryOAuthProvider( + base_url=issuer_url, + client_registration_options=ClientRegistrationOptions( + enabled=False, # DCR disabled + valid_scopes=["read", "write"], + ), + ) + + server = FastMCP("TestServer", auth=provider) + + @server.tool + def greet(name: str) -> str: + return f"Hello, {name}!" + + # Pre-register a client directly in the provider. + # The redirect_uri must match what the OAuth client will use. + pre_registered = OAuthClientInformationFull( + client_id="pre-registered-client", + client_secret="pre-registered-secret", + redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="client_secret_post", + scope="read write", + ) + await provider.register_client(pre_registered) + + async with run_server_async(server, port=port, transport="http") as url: + oauth = HeadlessOAuth( + mcp_url=url, + client_id="pre-registered-client", + client_secret="pre-registered-secret", + scopes=["read", "write"], + callback_port=callback_port, + ) + + async with Client( + transport=StreamableHttpTransport(url), + auth=oauth, + ) as client: + assert await client.ping() + tools = await client.list_tools() + assert any(t.name == "greet" for t in tools) + + async def test_static_client_with_dcr_enabled(self): + """Static client_id should also work when DCR is enabled (skips DCR).""" + port = find_available_port() + callback_port = find_available_port() + issuer_url = f"http://127.0.0.1:{port}" + + provider = InMemoryOAuthProvider( + base_url=issuer_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=["read"], + ), + ) + + server = FastMCP("TestServer", auth=provider) + + @server.tool + def add(a: int, b: int) -> int: + return a + b + + pre_registered = OAuthClientInformationFull( + client_id="my-app", + client_secret="my-secret", + redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="client_secret_post", + scope="read", + ) + await provider.register_client(pre_registered) + + async with run_server_async(server, port=port, transport="http") as url: + oauth = HeadlessOAuth( + mcp_url=url, + client_id="my-app", + client_secret="my-secret", + scopes=["read"], + callback_port=callback_port, + ) + + async with Client( + transport=StreamableHttpTransport(url), + auth=oauth, + ) as client: + result = await client.call_tool("add", {"a": 3, "b": 4}) + assert result.data == 7 From 5bab18810641ed2322a5c3ff4f72d077407ced49 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Mon, 9 Feb 2026 19:43:53 -0600 Subject: [PATCH 42/63] Add concurrent tool execution with sequential flag (#3022) Co-authored-by: Bill Easton Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- docs/servers/sampling.mdx | 47 ++ src/fastmcp/server/context.py | 18 + src/fastmcp/server/sampling/run.py | 145 ++++-- src/fastmcp/server/sampling/sampling_tool.py | 7 + tests/client/test_sampling.py | 486 +++++++++++++++++++ 5 files changed, 650 insertions(+), 53 deletions(-) diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx index f15d50aa3..8ea479eb0 100644 --- a/docs/servers/sampling.mdx +++ b/docs/servers/sampling.mdx @@ -289,6 +289,45 @@ def search(query: str) -> str: `ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle. +### Concurrent Tool Execution + +By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`: + +```python +result = await ctx.sample( + messages="Research these three topics", + tools=[search, fetch_url], + tool_concurrency=0, # Unlimited parallel execution +) +``` + +The `tool_concurrency` parameter controls how many tools run at once: + +- **`None`** (default): Sequential execution +- **`0`**: Unlimited parallel execution +- **`N > 0`**: Execute at most N tools concurrently + +For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`: + +```python +from fastmcp.server.sampling import SamplingTool + +db_writer = SamplingTool.from_function( + write_to_db, + sequential=True, # Forces all tools in the batch to run sequentially +) + +result = await ctx.sample( + messages="Process this data", + tools=[search, db_writer], + tool_concurrency=0, # Would be parallel, but db_writer forces sequential +) +``` + + +When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it. + + ### Client Requirements @@ -463,6 +502,10 @@ tool_result = ToolResultContent( If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM. + + Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless. + + @@ -511,6 +554,10 @@ tool_result = ToolResultContent( If True, mask detailed error messages from tool execution. + + + Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. + diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index a9245fa2b..31e759c98 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -754,6 +754,7 @@ class Context: tool_choice: ToolChoiceOption | str | None = None, execute_tools: bool = True, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SampleStep: """ Make a single LLM sampling call. @@ -777,6 +778,12 @@ class Context: mask_error_details: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. Returns: SampleStep containing: @@ -810,6 +817,7 @@ class Context: tool_choice=tool_choice, auto_execute_tools=execute_tools, mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, ) @overload @@ -824,6 +832,7 @@ class Context: tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT], mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[ResultT]: """Overload: With result_type, returns SamplingResult[ResultT].""" @@ -839,6 +848,7 @@ class Context: tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: None = None, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[str]: """Overload: Without result_type, returns SamplingResult[str].""" @@ -853,6 +863,7 @@ class Context: tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT] | None = None, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[ResultT] | SamplingResult[str]: """ Send a sampling request to the client and await the response. @@ -883,6 +894,12 @@ class Context: mask_error_details: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. Returns: SamplingResult[T] containing: @@ -906,6 +923,7 @@ class Context: tools=tools, result_type=result_type, mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, ) @overload diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py index 729968916..c9aa94a76 100644 --- a/src/fastmcp/server/sampling/run.py +++ b/src/fastmcp/server/sampling/run.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, Literal, cast +import anyio from mcp.types import ( ClientCapabilities, CreateMessageResult, @@ -31,6 +32,7 @@ from typing_extensions import TypeVar from fastmcp import settings from fastmcp.exceptions import ToolError from fastmcp.server.sampling.sampling_tool import SamplingTool +from fastmcp.utilities.async_utils import gather from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter @@ -239,6 +241,7 @@ async def execute_tools( tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, + tool_concurrency: int | None = None, ) -> list[ToolResultContent]: """Execute tool calls and return results. @@ -249,66 +252,96 @@ async def execute_tools( When masked, only generic error messages are returned to the LLM. Tools can explicitly raise ToolError to bypass masking when they want to provide specific error messages to the LLM. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. Returns: - List of tool result content blocks. + List of tool result content blocks in the same order as tool_calls. """ - tool_results: list[ToolResultContent] = [] + if tool_concurrency is not None and tool_concurrency < 0: + raise ValueError( + f"tool_concurrency must be None, 0 (unlimited), or a positive integer, " + f"got {tool_concurrency}" + ) - for tool_use in tool_calls: + async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent: + """Execute a single tool and return its result.""" tool = tool_map.get(tool_use.name) if tool is None: - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[ - TextContent( - type="text", - text=f"Error: Unknown tool '{tool_use.name}'", - ) - ], - isError=True, - ) + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[ + TextContent( + type="text", + text=f"Error: Unknown tool '{tool_use.name}'", + ) + ], + isError=True, ) - else: - try: - result_value = await tool.run(tool_use.input) - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=str(result_value))], - ) - ) - except ToolError as e: - # ToolError is the escape hatch - always pass message through - logger.exception(f"Error calling sampling tool '{tool_use.name}'") - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=str(e))], - isError=True, - ) - ) - except Exception as e: - # Generic exceptions - mask based on setting - logger.exception(f"Error calling sampling tool '{tool_use.name}'") - if mask_error_details: - error_text = f"Error executing tool '{tool_use.name}'" - else: - error_text = f"Error executing tool '{tool_use.name}': {e}" - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=error_text)], - isError=True, - ) - ) - return tool_results + try: + result_value = await tool.run(tool_use.input) + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[TextContent(type="text", text=str(result_value))], + ) + except ToolError as e: + # ToolError is the escape hatch - always pass message through + logger.exception(f"Error calling sampling tool '{tool_use.name}'") + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[TextContent(type="text", text=str(e))], + isError=True, + ) + except Exception as e: + # Generic exceptions - mask based on setting + logger.exception(f"Error calling sampling tool '{tool_use.name}'") + if mask_error_details: + error_text = f"Error executing tool '{tool_use.name}'" + else: + error_text = f"Error executing tool '{tool_use.name}': {e}" + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[TextContent(type="text", text=error_text)], + isError=True, + ) + + # Check if any tool requires sequential execution + requires_sequential = any( + tool.sequential + for tool_use in tool_calls + if (tool := tool_map.get(tool_use.name)) is not None + ) + + # Execute sequentially if required or if concurrency is None (default) + if tool_concurrency is None or requires_sequential: + tool_results: list[ToolResultContent] = [] + for tool_use in tool_calls: + result = await _execute_single_tool(tool_use) + tool_results.append(result) + return tool_results + + # Execute in parallel + if tool_concurrency == 0: + # Unlimited parallel execution + return await gather(*[_execute_single_tool(tc) for tc in tool_calls]) + else: + # Bounded parallel execution with semaphore + semaphore = anyio.Semaphore(tool_concurrency) + + async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent: + async with semaphore: + return await _execute_single_tool(tool_use) + + return await gather(*[bounded_execute(tc) for tc in tool_calls]) # --- Helper functions for sampling --- @@ -412,6 +445,7 @@ async def sample_step_impl( tool_choice: ToolChoiceOption | str | None = None, auto_execute_tools: bool = True, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SampleStep: """Implementation of Context.sample_step(). @@ -498,7 +532,10 @@ async def sample_step_impl( else settings.mask_error_details ) tool_results: list[ToolResultContent] = await execute_tools( - step_tool_calls, tool_map, mask_error_details=effective_mask + step_tool_calls, + tool_map, + mask_error_details=effective_mask, + tool_concurrency=tool_concurrency, ) if tool_results: @@ -523,6 +560,7 @@ async def sample_impl( tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT] | None = None, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[ResultT]: """Implementation of Context.sample(). @@ -561,6 +599,7 @@ async def sample_impl( tools=sampling_tools, tool_choice=tool_choice, mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, ) # Check for final_response tool call for structured output diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 106c55fc6..877be71c5 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -40,6 +40,7 @@ class SamplingTool(FastMCPBaseModel): description: str | None = None parameters: dict[str, Any] fn: Callable[..., Any] + sequential: bool = False model_config = ConfigDict(arbitrary_types_allowed=True) @@ -79,6 +80,7 @@ class SamplingTool(FastMCPBaseModel): *, name: str | None = None, description: str | None = None, + sequential: bool = False, ) -> SamplingTool: """Create a SamplingTool from a function. @@ -89,6 +91,10 @@ class SamplingTool(FastMCPBaseModel): fn: The function to create a tool from. name: Optional name override. Defaults to the function's name. description: Optional description override. Defaults to the function's docstring. + sequential: If True, this tool requires sequential execution and prevents + parallel execution of all tools in the batch. Set to True for tools + with shared state, file writes, or other operations that cannot run + concurrently. Defaults to False. Returns: A SamplingTool wrapping the function. @@ -106,4 +112,5 @@ class SamplingTool(FastMCPBaseModel): description=description or parsed.description, parameters=parsed.input_schema, fn=parsed.fn, + sequential=sequential, ) diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py index e379d6707..e5a45adc7 100644 --- a/tests/client/test_sampling.py +++ b/tests/client/test_sampling.py @@ -563,6 +563,492 @@ class TestAutomaticToolLoop: assert "Tool failed intentionally" in error_text assert result.data == "Handled error" + async def test_concurrent_tool_execution_default_sequential(self): + """Test that tools execute sequentially by default.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def slow_tool_a(x: int) -> int: + """Slow tool A.""" + start = time.time() + execution_order.append(("tool_a_start", start)) + await asyncio.sleep(0.1) + execution_order.append(("tool_a_end", time.time())) + return x * 2 + + async def slow_tool_b(y: int) -> int: + """Slow tool B.""" + start = time.time() + execution_order.append(("tool_b_start", start)) + await asyncio.sleep(0.1) + execution_order.append(("tool_b_end", time.time())) + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_a", + name="slow_tool_a", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_b", + name="slow_tool_b", + input={"y": 3}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool_a, slow_tool_b], + # Default: tool_concurrency=None (sequential) + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify sequential execution: tool_a must complete before tool_b starts + events = [e[0] for e in execution_order] + assert events == ["tool_a_start", "tool_a_end", "tool_b_start", "tool_b_end"] + + async def test_concurrent_tool_execution_unlimited(self): + """Test unlimited parallel tool execution with tool_concurrency=0.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_times: dict[str, dict[str, float]] = {} + + async def slow_tool_a(x: int) -> int: + """Slow tool A.""" + execution_times["tool_a"] = {"start": time.time()} + await asyncio.sleep(0.1) + execution_times["tool_a"]["end"] = time.time() + return x * 2 + + async def slow_tool_b(y: int) -> int: + """Slow tool B.""" + execution_times["tool_b"] = {"start": time.time()} + await asyncio.sleep(0.1) + execution_times["tool_b"]["end"] = time.time() + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_a", + name="slow_tool_a", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_b", + name="slow_tool_b", + input={"y": 3}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool_a, slow_tool_b], + tool_concurrency=0, # Unlimited parallel + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify parallel execution: both tools should overlap in time + assert "tool_a" in execution_times + assert "tool_b" in execution_times + # tool_b should start before tool_a finishes (overlap) + assert execution_times["tool_b"]["start"] < execution_times["tool_a"]["end"] + + async def test_concurrent_tool_execution_bounded(self): + """Test bounded parallel execution with tool_concurrency=2.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def slow_tool(name: str, duration: float = 0.1) -> str: + """Generic slow tool.""" + execution_order.append((f"{name}_start", time.time())) + await asyncio.sleep(duration) + execution_order.append((f"{name}_end", time.time())) + return f"{name} done" + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd) + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="slow_tool", + input={"name": "tool_1", "duration": 0.1}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="slow_tool", + input={"name": "tool_2", "duration": 0.1}, + ), + ToolUseContent( + type="tool_use", + id="call_3", + name="slow_tool", + input={"name": "tool_3", "duration": 0.05}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool], + tool_concurrency=2, # Max 2 concurrent + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify that at most 2 tools run concurrently + events = [e[0] for e in execution_order] + # First 2 tools should start before either ends + assert events[0] in ["tool_1_start", "tool_2_start"] + assert events[1] in ["tool_1_start", "tool_2_start"] + # Third tool should start after at least one of the first two finishes + tool_3_start_idx = events.index("tool_3_start") + assert ( + "tool_1_end" in events[:tool_3_start_idx] + or "tool_2_end" in events[:tool_3_start_idx] + ) + + async def test_sequential_tool_forces_sequential_execution(self): + """Test that sequential=True forces all tools to execute sequentially.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def normal_tool(x: int) -> int: + """Normal tool.""" + execution_order.append(("normal_start", time.time())) + await asyncio.sleep(0.05) + execution_order.append(("normal_end", time.time())) + return x * 2 + + async def sequential_tool(y: int) -> int: + """Sequential tool.""" + execution_order.append(("sequential_start", time.time())) + await asyncio.sleep(0.05) + execution_order.append(("sequential_end", time.time())) + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="normal_tool", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="sequential_tool", + input={"y": 3}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + # Create tools with sequential=True for one of them + normal = SamplingTool.from_function(normal_tool, sequential=False) + sequential = SamplingTool.from_function(sequential_tool, sequential=True) + + result = await context.sample( + messages="Run tools", + tools=[normal, sequential], + tool_concurrency=0, # Request unlimited, but sequential tool forces sequential + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify sequential execution: first tool must complete before second starts + events = [e[0] for e in execution_order] + assert events[0] in ["normal_start", "sequential_start"] + assert events[1] in ["normal_end", "sequential_end"] + # Ensure the second tool starts after the first ends + if events[0] == "normal_start": + assert events[1] == "normal_end" + assert events[2] == "sequential_start" + else: + assert events[1] == "sequential_end" + assert events[2] == "normal_start" + + async def test_concurrent_tool_execution_error_handling(self): + """Test that errors are captured per-tool in parallel execution.""" + from mcp.types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + def good_tool() -> str: + return "success" + + def bad_tool() -> str: + raise ValueError("Tool error") + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", id="call_1", name="good_tool", input={} + ), + ToolUseContent( + type="tool_use", id="call_2", name="bad_tool", input={} + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Handled errors")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[good_tool, bad_tool], + tool_concurrency=0, # Parallel execution + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Handled errors" + # Check that tool results include both success and error + tool_result_message = messages_received[1][-1] + assert tool_result_message.role == "user" + tool_results = cast(list[ToolResultContent], tool_result_message.content) + assert len(tool_results) == 2 + # One should be success, one should be error + assert any(not r.isError for r in tool_results) + assert any(r.isError for r in tool_results) + + async def test_concurrent_tool_result_order_preserved(self): + """Test that tool results maintain the same order as tool calls.""" + import asyncio + + from mcp.types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + async def tool_with_delay(value: int, delay: float) -> int: + """Tool that takes variable time.""" + await asyncio.sleep(delay) + return value + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + # Tools with different delays - later tools finish first + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="tool_with_delay", + input={"value": 1, "delay": 0.15}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="tool_with_delay", + input={"value": 2, "delay": 0.05}, + ), + ToolUseContent( + type="tool_use", + id="call_3", + name="tool_with_delay", + input={"value": 3, "delay": 0.1}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[tool_with_delay], + tool_concurrency=0, # Parallel execution + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1) + tool_result_message = messages_received[1][-1] + tool_results = cast(list[ToolResultContent], tool_result_message.content) + assert len(tool_results) == 3 + assert tool_results[0].toolUseId == "call_1" + assert tool_results[1].toolUseId == "call_2" + assert tool_results[2].toolUseId == "call_3" + # Check values are correct + result_texts = [cast(TextContent, r.content[0]).text for r in tool_results] + assert result_texts == ["1", "2", "3"] + class TestSamplingResultType: """Tests for result_type parameter (structured output).""" From 81a7c83c67764540e8ae60b4e4898ee422ee1231 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:45:28 -0500 Subject: [PATCH 43/63] CI: Commit generated artifacts back to PR branch instead of opening separate PRs (#3128) --- .github/workflows/update-config-schema.yml | 70 +++++++--------------- .github/workflows/update-sdk-docs.yml | 58 ++++++++---------- 2 files changed, 47 insertions(+), 81 deletions(-) diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index 06f63234a..e5d0509f4 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -1,28 +1,28 @@ name: Update MCPServerConfig Schema -# This workflow runs on merges to main to automatically update the config schema -# by creating a PR when changes are needed. +# Regenerates config schema on PRs and commits it back to the branch, +# so the PR is self-contained and main is correct after merge. on: - push: + pull_request: branches: ["main"] paths: - "src/fastmcp/utilities/mcp_server_config/**" - - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" # Exclude the local schema file + - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" workflow_dispatch: permissions: contents: write - pull-requests: write jobs: update-config-schema: timeout-minutes: 5 runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@v6 - - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v2 @@ -30,6 +30,11 @@ jobs: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref || github.ref }} + token: ${{ steps.marvin-token.outputs.token }} + - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -41,51 +46,22 @@ jobs: - name: Generate config schema run: | - echo "🔄 Generating fastmcp.json schema..." - - # Generate schema in docs/public for web access uv run python -c " from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/latest.json') - print('✅ Latest schema generated in docs/public') - " - - # Also update the v1 schema in docs/public - uv run python -c " - from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/v1.json') - print('✅ v1 schema generated in docs/public') - " - - # Generate schema in the source directory for local development - uv run python -c " - from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json') - print('✅ Schema generated in utilities/mcp_server_config/v1/') " - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ steps.marvin-token.outputs.token }} - commit-message: "chore: Update fastmcp.json schema" - title: "chore: Update fastmcp.json schema" - body: | - This PR updates the fastmcp.json schema files to match the current source code. - - The schema is automatically generated from `src/fastmcp/utilities/mcp_server_config/` to ensure consistency. - - **Note:** This PR is fully automated and will update itself with any subsequent changes to the schema, or close automatically if the schema becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. - - 🤖 Generated by Marvin - branch: marvin/update-config-schema - labels: | - ignore in release notes - delete-branch: true - author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - - - name: Summary + - name: Commit and push if changed run: | - echo "✅ Config schema generation workflow completed" - echo "PR will be created if there are changes, or closed if schema is already up to date" + git config user.name "marvin-context-protocol[bot]" + git config user.email "225465937+marvin-context-protocol[bot]@users.noreply.github.com" + git add docs/public/schemas/ src/fastmcp/utilities/mcp_server_config/v1/schema.json + if git diff --cached --quiet; then + echo "Config schema is up to date" + else + git commit -m "chore: Update fastmcp.json schema" + git push + echo "Config schema updated and pushed" + fi diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index 10baa1e3f..122f6ddfc 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -1,10 +1,10 @@ name: Update SDK Documentation -# This workflow runs on merges to main to automatically update SDK docs -# by creating a PR when changes are needed. +# Regenerates SDK docs on PRs and commits them back to the branch, +# so the PR is self-contained and main is correct after merge. on: - push: + pull_request: branches: ["main"] paths: - "src/**" @@ -13,16 +13,16 @@ on: permissions: contents: write - pull-requests: write jobs: update-sdk-docs: timeout-minutes: 5 runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@v6 - - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v2 @@ -30,6 +30,11 @@ jobs: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref || github.ref }} + token: ${{ steps.marvin-token.outputs.token }} + - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -43,32 +48,17 @@ jobs: uses: extractions/setup-just@v3 - name: Generate SDK documentation + run: just api-ref-all + + - name: Commit and push if changed run: | - echo "🔄 Generating SDK documentation..." - just api-ref-all - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ steps.marvin-token.outputs.token }} - commit-message: "chore: Update SDK documentation" - title: "chore: Update SDK documentation" - body: | - This PR updates the auto-generated SDK documentation to reflect the latest source code changes. - - 📚 Documentation is automatically generated from the source code docstrings and type annotations. - - **Note:** This PR is fully automated and will update itself with any subsequent changes to the SDK, or close automatically if the documentation becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. - - 🤖 Generated by Marvin - branch: marvin/update-sdk-docs - labels: | - ignore in release notes - delete-branch: true - author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - - - name: Summary - run: | - echo "✅ SDK documentation generation workflow completed" - echo "PR will be created if there are changes, or closed if documentation is already up to date" + git config user.name "marvin-context-protocol[bot]" + git config user.email "225465937+marvin-context-protocol[bot]@users.noreply.github.com" + git add docs/python-sdk/ + if git diff --cached --quiet; then + echo "SDK documentation is up to date" + else + git commit -m "chore: Update SDK documentation" + git push + echo "SDK documentation updated and pushed" + fi From a1cf2aff6e862e024a99eecc5e0290621adf6dba Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:45:52 -0500 Subject: [PATCH 44/63] chore: Update SDK documentation (#3116) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/python-sdk/fastmcp-cli-generate.mdx | 18 ++- docs/python-sdk/fastmcp-client-auth-oauth.mdx | 6 +- docs/python-sdk/fastmcp-server-apps.mdx | 23 ++-- .../fastmcp-server-auth-providers-azure.mdx | 59 +++++++++- docs/python-sdk/fastmcp-server-context.mdx | 44 +++++--- .../fastmcp-server-dependencies.mdx | 95 ++++++++++------ ...viders-local_provider-decorators-tools.mdx | 6 +- .../fastmcp-server-sampling-run.mdx | 38 ++++--- .../fastmcp-server-sampling-sampling_tool.mdx | 8 +- docs/python-sdk/fastmcp-server-server.mdx | 104 +++++++++--------- .../fastmcp-tools-function_tool.mdx | 14 +-- 11 files changed, 261 insertions(+), 154 deletions(-) diff --git a/docs/python-sdk/fastmcp-cli-generate.mdx b/docs/python-sdk/fastmcp-cli-generate.mdx index 28bd3ea7f..027894e38 100644 --- a/docs/python-sdk/fastmcp-cli-generate.mdx +++ b/docs/python-sdk/fastmcp-cli-generate.mdx @@ -6,7 +6,7 @@ sidebarTitle: generate # `fastmcp.cli.generate` -Generate a standalone CLI script from an MCP server's capabilities. +Generate a standalone CLI script and agent skill from an MCP server. ## Functions @@ -33,7 +33,17 @@ generate_cli_script(server_name: str, server_spec: str, transport_code: str, ext Generate the full CLI script source code. -### `generate_cli_command` +### `generate_skill_content` + +```python +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. + + +### `generate_cli_command` ```python generate_cli_command(server_spec: Annotated[str, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, discovered name, or .js file')], output: Annotated[str, cyclopts.Parameter(help='Output file path (default: cli.py)')] = 'cli.py') -> None @@ -43,7 +53,8 @@ generate_cli_command(server_spec: Annotated[str, cyclopts.Parameter(help='Server 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:** @@ -51,4 +62,5 @@ 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 diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index b9d06beb7..15ce6e8af 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -73,7 +73,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -82,7 +82,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -91,7 +91,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-server-apps.mdx b/docs/python-sdk/fastmcp-server-apps.mdx index c72052339..ad006b492 100644 --- a/docs/python-sdk/fastmcp-server-apps.mdx +++ b/docs/python-sdk/fastmcp-server-apps.mdx @@ -15,17 +15,17 @@ UI metadata for clients that support interactive app rendering. ## Functions -### `ui_to_meta_dict` +### `app_config_to_meta_dict` ```python -ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any] +app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] ``` -Convert a UI model or dict to the wire-format dict for ``meta["ui"]``. +Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``. -### `resolve_ui_mime_type` +### `resolve_ui_mime_type` ```python resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None @@ -70,18 +70,17 @@ iframe. Hosts MAY honour these; apps should use JS feature detection as a fallback. -### `ToolUI` +### `AppConfig` -Typed ``_meta.ui`` for tools — links a tool to its UI resource. +Configuration for MCP App tools and resources. + +Controls how a tool or resource participates in the MCP Apps extension. +On tools, ``resource_uri`` and ``visibility`` specify which UI resource +to render and where the tool appears. On resources, those fields must +be left unset (the resource itself is the UI). All fields use ``exclude_none`` serialization so only explicitly-set values appear on the wire. Aliases match the MCP Apps wire format (camelCase). - -### `ResourceUI` - - -Typed ``_meta.ui`` for resources — rendering hints for UI-capable clients. - diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 2e403a611..e5773c6fd 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -12,9 +12,38 @@ This provider implements Azure/Microsoft Entra ID OAuth authentication using the OAuth Proxy pattern for non-DCR OAuth flows. +## Functions + +### `EntraOBOToken` + +```python +EntraOBOToken(scopes: list[str]) -> str +``` + + +Exchange the user's Entra token for a downstream API token via OBO. + +This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, +allowing your MCP server to call downstream APIs (like Microsoft Graph) on +behalf of the authenticated user. + +**Args:** +- `scopes`: The scopes to request for the downstream API. For Microsoft Graph, +use scopes like ["https\://graph.microsoft.com/Mail.Read"] or +["https\://graph.microsoft.com/.default"]. + +**Returns:** +- A dependency that resolves to the downstream API access token string + +**Raises:** +- `ImportError`: If fastmcp[azure] is not installed +- `RuntimeError`: If no access token is available, provider is not Azure, +or OBO exchange fails + + ## Classes -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. @@ -49,7 +78,7 @@ Setup: **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -69,7 +98,29 @@ scopes to determine the resource/audience instead of a separate parameter. - Authorization URL to redirect the user to Azure AD -### `AzureJWTVerifier` +#### `create_obo_credential` + +```python +create_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential +``` + +Create an OnBehalfOfCredential for OBO token exchange. + +Uses the AzureProvider's configuration (client_id, client_secret, +tenant_id, authority) to create a credential that can exchange the +user's token for downstream API tokens. + +**Args:** +- `user_assertion`: The user's access token to exchange via OBO. + +**Returns:** +- A configured OnBehalfOfCredential ready for get_token() calls. + +**Raises:** +- `ImportError`: If azure-identity is not installed (requires fastmcp[azure]). + + +### `AzureJWTVerifier` JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -106,7 +157,7 @@ Example:: **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 060f0c4d5..19d67e5d6 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -465,6 +465,12 @@ in the step for manual execution. - `mask_error_details`: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. +- `tool_concurrency`: Controls parallel execution of tools\: +- None (default)\: Sequential execution (one at a time) +- 0\: Unlimited parallel execution +- N > 0\: Execute at most N tools concurrently +If any tool has sequential=True, all tools execute sequentially +regardless of this setting. **Returns:** - SampleStep containing: @@ -475,7 +481,7 @@ Tools can raise ToolError to bypass masking. - - .text: The text content (if any) -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -484,7 +490,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: With result_type, returns SamplingResult[ResultT]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str] @@ -493,7 +499,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: Without result_type, returns SamplingResult[str]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str] @@ -527,6 +533,12 @@ response is validated against this type. - `mask_error_details`: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. +- `tool_concurrency`: Controls parallel execution of tools\: +- None (default)\: Sequential execution (one at a time) +- 0\: Unlimited parallel execution +- N > 0\: Execute at most N tools concurrently +If any tool has sequential=True, all tools execute sequentially +regardless of this setting. **Returns:** - SamplingResult[T] containing: @@ -535,43 +547,43 @@ Tools can raise ToolError to bypass masking. - - .history: All messages exchanged during sampling -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation @@ -600,7 +612,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -613,7 +625,7 @@ The key is automatically prefixed with the session identifier. State expires after 1 day to prevent unbounded memory growth. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any @@ -624,7 +636,7 @@ Get a value from the session-scoped state store. Returns None if the key is not found. -#### `delete_state` +#### `delete_state` ```python delete_state(self, key: str) -> None @@ -633,7 +645,7 @@ delete_state(self, key: str) -> None Delete a value from the session-scoped state store. -#### `enable_components` +#### `enable_components` ```python enable_components(self) -> None @@ -657,7 +669,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `disable_components` +#### `disable_components` ```python disable_components(self) -> None @@ -681,7 +693,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `reset_visibility` +#### `reset_visibility` ```python reset_visibility(self) -> None diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index b066c8c8e..496a19e8c 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -15,7 +15,7 @@ CurrentWorker) and background task execution require fastmcp[tasks]. ## Functions -### `get_task_context` +### `get_task_context` ```python get_task_context() -> TaskContextInfo | None @@ -31,7 +31,7 @@ Returns None if not running in a task context (e.g., foreground execution). - TaskContextInfo with task_id and session_id, or None if not in a task. -### `register_task_session` +### `register_task_session` ```python register_task_session(session_id: str, session: ServerSession) -> None @@ -49,7 +49,7 @@ client disconnects. - `session`: The ServerSession instance -### `get_task_session` +### `get_task_session` ```python get_task_session(session_id: str) -> ServerSession | None @@ -65,7 +65,7 @@ Get a registered session by ID if still alive. - The ServerSession if found and alive, None otherwise -### `is_docket_available` +### `is_docket_available` ```python is_docket_available() -> bool @@ -75,7 +75,7 @@ is_docket_available() -> bool Check if pydocket is installed. -### `require_docket` +### `require_docket` ```python require_docket(feature: str) -> None @@ -89,7 +89,7 @@ Raise ImportError with install instructions if docket not available. "CurrentDocket()"). Will be included in the error message. -### `transform_context_annotations` +### `transform_context_annotations` ```python transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] @@ -115,7 +115,7 @@ allows them to have defaults in any order. - Function with modified signature (same function object, updated __signature__) -### `get_context` +### `get_context` ```python get_context() -> Context @@ -125,7 +125,7 @@ get_context() -> Context Get the current FastMCP Context instance directly. -### `get_server` +### `get_server` ```python get_server() -> FastMCP @@ -141,7 +141,7 @@ Get the current FastMCP server instance directly. - `RuntimeError`: If no server in context -### `get_http_request` +### `get_http_request` ```python get_http_request() -> Request @@ -153,7 +153,7 @@ Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. -### `get_http_headers` +### `get_http_headers` ```python get_http_headers(include_all: bool = False) -> dict[str, str] @@ -169,7 +169,7 @@ By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients. If `include_all` is True, all headers are returned. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -187,7 +187,7 @@ request is available. - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -212,7 +212,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -238,7 +238,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -257,7 +257,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -277,7 +277,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -297,7 +297,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -315,7 +315,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -335,7 +335,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -352,7 +352,7 @@ safe to use in code that might run over any transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -371,9 +371,32 @@ authenticated request. Raises an error if no authentication is present. - `RuntimeError`: If no authenticated user (use get_access_token() for optional) +### `TokenClaim` + +```python +TokenClaim(name: str) -> str +``` + + +Get a specific claim from the access token. + +This dependency extracts a single claim value from the current access token. +It's useful for getting user identifiers, roles, or other token claims +without needing the full token object. + +**Args:** +- `name`: The name of the claim to extract (e.g., "oid", "sub", "email") + +**Returns:** +- A dependency that resolves to the claim value as a string + +**Raises:** +- `RuntimeError`: If no access token is available or claim is missing + + ## Classes -### `TaskContextInfo` +### `TaskContextInfo` Information about the current background task context. @@ -382,7 +405,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -393,7 +416,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -402,7 +425,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -411,7 +434,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -420,7 +443,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -429,7 +452,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -438,7 +461,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -447,7 +470,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -459,25 +482,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -486,7 +509,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -495,7 +518,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -504,7 +527,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. diff --git a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx index fd501251e..bfb3ccea1 100644 --- a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx +++ b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx @@ -37,19 +37,19 @@ Add a tool to this provider's storage. Accepts either a Tool object or a decorated function with __fastmcp__ metadata. -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] diff --git a/docs/python-sdk/fastmcp-server-sampling-run.mdx b/docs/python-sdk/fastmcp-server-sampling-run.mdx index a251946ff..a28542f28 100644 --- a/docs/python-sdk/fastmcp-server-sampling-run.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-run.mdx @@ -10,7 +10,7 @@ Sampling types and helper functions for FastMCP servers. ## Functions -### `determine_handler_mode` +### `determine_handler_mode` ```python determine_handler_mode(context: Context, needs_tools: bool) -> bool @@ -30,7 +30,7 @@ Determine whether to use fallback handler or client for sampling. - `ValueError`: If client lacks required capability and no fallback configured. -### `call_sampling_handler` +### `call_sampling_handler` ```python call_sampling_handler(context: Context, messages: list[SamplingMessage]) -> CreateMessageResult | CreateMessageResultWithTools @@ -44,10 +44,10 @@ sampling_handler is set via determine_handler_mode(). The checks below are safeguards against internal misuse. -### `execute_tools` +### `execute_tools` ```python -execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False) -> list[ToolResultContent] +execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, tool_concurrency: int | None = None) -> list[ToolResultContent] ``` @@ -60,12 +60,18 @@ Execute tool calls and return results. When masked, only generic error messages are returned to the LLM. Tools can explicitly raise ToolError to bypass masking when they want to provide specific error messages to the LLM. +- `tool_concurrency`: Controls parallel execution of tools\: +- None (default)\: Sequential execution (one at a time) +- 0\: Unlimited parallel execution +- N > 0\: Execute at most N tools concurrently +If any tool has sequential=True, all tools execute sequentially +regardless of this setting. **Returns:** -- List of tool result content blocks. +- List of tool result content blocks in the same order as tool_calls. -### `prepare_messages` +### `prepare_messages` ```python prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[SamplingMessage] @@ -75,7 +81,7 @@ prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[Sampli Convert various message formats to a list of SamplingMessage objects. -### `prepare_tools` +### `prepare_tools` ```python prepare_tools(tools: Sequence[SamplingTool | Callable[..., Any]] | None) -> list[SamplingTool] | None @@ -85,7 +91,7 @@ prepare_tools(tools: Sequence[SamplingTool | Callable[..., Any]] | None) -> list Convert tools to SamplingTool objects. -### `extract_tool_calls` +### `extract_tool_calls` ```python extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) -> list[ToolUseContent] @@ -95,7 +101,7 @@ extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) Extract tool calls from a response. -### `create_final_response_tool` +### `create_final_response_tool` ```python create_final_response_tool(result_type: type) -> SamplingTool @@ -108,7 +114,7 @@ This tool is used to capture structured responses from the LLM. The tool's schema is derived from the result_type. -### `sample_step_impl` +### `sample_step_impl` ```python sample_step_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -121,7 +127,7 @@ Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. -### `sample_impl` +### `sample_impl` ```python sample_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -137,7 +143,7 @@ provides a final text response. ## Classes -### `SamplingResult` +### `SamplingResult` Result of a sampling operation. @@ -148,7 +154,7 @@ Result of a sampling operation. - `history`: All messages exchanged during sampling. -### `SampleStep` +### `SampleStep` Result of a single sampling call. @@ -158,7 +164,7 @@ Represents what the LLM returned in this step plus the message history. **Methods:** -#### `is_tool_use` +#### `is_tool_use` ```python is_tool_use(self) -> bool @@ -167,7 +173,7 @@ is_tool_use(self) -> bool True if the LLM is requesting tool execution. -#### `text` +#### `text` ```python text(self) -> str | None @@ -176,7 +182,7 @@ text(self) -> str | None Extract text from the response, if available. -#### `tool_calls` +#### `tool_calls` ```python tool_calls(self) -> list[ToolUseContent] diff --git a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx index 1231624f5..15941b0dc 100644 --- a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx @@ -37,7 +37,7 @@ Create a SamplingTool explicitly when you need custom name/description: **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any] | None = None) -> Any @@ -52,7 +52,7 @@ Execute the tool with the given arguments. - The result of executing the tool function. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> SamplingTool @@ -67,6 +67,10 @@ the tool's parameters. Type hints are used to determine parameter types. - `fn`: The function to create a tool from. - `name`: Optional name override. Defaults to the function's name. - `description`: Optional description override. Defaults to the function's docstring. +- `sequential`: If True, this tool requires sequential execution and prevents +parallel execution of all tools in the batch. Set to True for tools +with shared state, file writes, or other operations that cannot run +concurrently. Defaults to False. **Returns:** - A SamplingTool wrapping the function. diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 004fc53c8..56bfd7696 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -54,65 +54,65 @@ use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.pr ## Classes -### `StateValue` +### `StateValue` Wrapper for stored context state values. -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -132,7 +132,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -144,7 +144,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -159,7 +159,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -171,7 +171,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -183,7 +183,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -196,7 +196,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -216,7 +216,7 @@ session transforms can override provider-level disables. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -229,7 +229,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -248,7 +248,7 @@ transforms (including session-level) have been applied. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -261,7 +261,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -280,7 +280,7 @@ all transforms (including session-level) have been applied. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -293,7 +293,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -312,19 +312,19 @@ transforms (including session-level) have been applied. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -354,19 +354,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -395,19 +395,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -437,7 +437,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -455,7 +455,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -471,19 +471,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -539,7 +539,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -554,7 +554,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -569,7 +569,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction] @@ -628,7 +628,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -643,19 +643,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -732,7 +732,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -779,7 +779,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -820,7 +820,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -845,7 +845,7 @@ server URL from the OpenAPI spec with a 30-second timeout. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -869,7 +869,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -887,7 +887,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-tools-function_tool.mdx b/docs/python-sdk/fastmcp-tools-function_tool.mdx index bd66818a0..4f910a354 100644 --- a/docs/python-sdk/fastmcp-tools-function_tool.mdx +++ b/docs/python-sdk/fastmcp-tools-function_tool.mdx @@ -10,7 +10,7 @@ Standalone @tool decorator for FastMCP. ## Functions -### `tool` +### `tool` ```python tool(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -37,11 +37,11 @@ Protocol for functions decorated with @tool. Metadata attached to functions by the @tool decorator. -### `FunctionTool` +### `FunctionTool` **Methods:** -#### `to_mcp_tool` +#### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> mcp.types.Tool @@ -52,7 +52,7 @@ Convert the FastMCP tool to an MCP tool. Extends the base implementation to add task execution mode if enabled. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool @@ -68,7 +68,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -77,7 +77,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Run the tool with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -89,7 +89,7 @@ FunctionTool registers the underlying function, which has the user's Depends parameters for docket to resolve. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution From eeb17855a6dc159619b84416422687422edb4a04 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:48:31 -0500 Subject: [PATCH 45/63] docs: add pre-registered OAuth clients to v3-features (#3129) --- docs/development/v3-notes/v3-features.mdx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 90d72f416..2a286961f 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -120,6 +120,29 @@ Key details: Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) +### Pre-Registered OAuth Clients + +The `OAuth` client helper now accepts `client_id` and `client_secret` parameters for servers where the client is already registered ([#3086](https://github.com/jlowin/fastmcp/pull/3086)). This bypasses Dynamic Client Registration entirely — useful when DCR is disabled, or when the server has pre-provisioned credentials for your application. + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_id="my-registered-app", + client_secret="my-secret", + scopes=["read", "write"], + ), +) as client: + await client.ping() +``` + +The static credentials are injected before the OAuth flow begins, so the client never attempts DCR. If the server rejects the credentials, the error surfaces immediately rather than retrying with fresh registration (which can't help for fixed credentials). Public clients can omit `client_secret`. + +Documentation: [Pre-Registered Clients](/clients/auth/oauth#pre-registered-clients) + ### 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. From cdad99583ef7bafae77c8089b3404a784ac5b4fb Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Mon, 9 Feb 2026 19:54:16 -0600 Subject: [PATCH 46/63] Fix Windows test timeouts in OAuth proxy provider tests (#3123) Co-authored-by: Bill Easton Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- tests/server/auth/providers/test_azure.py | 170 +++++++++++++++----- tests/server/auth/providers/test_discord.py | 21 ++- tests/server/auth/providers/test_github.py | 19 ++- tests/server/auth/providers/test_google.py | 32 +++- tests/server/auth/providers/test_workos.py | 25 ++- 5 files changed, 209 insertions(+), 58 deletions(-) diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 9542c19f9..0ea6166bf 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -2,6 +2,8 @@ from urllib.parse import parse_qs, urlparse +import pytest +from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl @@ -14,10 +16,16 @@ from fastmcp.server.auth.providers.azure import ( from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestAzureProvider: """Test Azure OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test AzureProvider initialization with explicit parameters.""" provider = AzureProvider( client_id="12345678-1234-1234-1234-123456789012", @@ -26,6 +34,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read", "write"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "12345678-1234-1234-1234-123456789012" @@ -37,7 +46,7 @@ class TestAzureProvider: parsed_token = urlparse(provider._upstream_token_endpoint) assert "87654321-4321-4321-4321-210987654321" in parsed_token.path - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = AzureProvider( client_id="test_client", @@ -46,13 +55,14 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" # Azure provider defaults are set but we can't easily verify them without accessing internals - def test_offline_access_automatically_included(self): + def test_offline_access_automatically_included(self, memory_storage: MemoryStore): """Test that offline_access is automatically added to get refresh tokens.""" # Without specifying offline_access provider = AzureProvider( @@ -62,11 +72,12 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert "offline_access" in provider.additional_authorize_scopes - def test_offline_access_not_duplicated(self): + def test_offline_access_not_duplicated(self, memory_storage: MemoryStore): """Test that offline_access is not duplicated if already specified.""" provider = AzureProvider( client_id="test_client", @@ -76,13 +87,14 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["User.Read", "offline_access"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Should appear exactly once assert provider.additional_authorize_scopes.count("offline_access") == 1 assert "User.Read" in provider.additional_authorize_scopes - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = AzureProvider( client_id="test_client", @@ -91,6 +103,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test_secret", + client_storage=memory_storage, ) # Check that endpoints use the correct Azure OAuth2 v2.0 endpoints with tenant @@ -106,7 +119,7 @@ class TestAzureProvider: provider._upstream_revocation_endpoint is None ) # Azure doesn't support revocation - def test_special_tenant_values(self): + def test_special_tenant_values(self, memory_storage: MemoryStore): """Test that special tenant values are accepted.""" # Test with "organizations" provider1 = AzureProvider( @@ -116,6 +129,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider1._upstream_authorization_endpoint) assert "/organizations/" in parsed.path @@ -128,11 +142,12 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider2._upstream_authorization_endpoint) assert "/consumers/" in parsed.path - def test_azure_specific_scopes(self): + def test_azure_specific_scopes(self, memory_storage: MemoryStore): """Test handling of custom API scope formats.""" # Test that the provider accepts custom API scopes without error provider = AzureProvider( @@ -146,6 +161,7 @@ class TestAzureProvider: "admin", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Provider should initialize successfully with these scopes @@ -158,7 +174,9 @@ class TestAzureProvider: "admin", ] - def test_init_does_not_require_api_client_id_anymore(self): + def test_init_does_not_require_api_client_id_anymore( + self, memory_storage: MemoryStore + ): """API client ID is no longer required; audience is client_id.""" provider = AzureProvider( client_id="test_client", @@ -167,10 +185,13 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider is not None - def test_init_with_custom_audience_uses_jwt_verifier(self): + def test_init_with_custom_audience_uses_jwt_verifier( + self, memory_storage: MemoryStore + ): """When audience is provided, JWTVerifier is configured with JWKS and issuer.""" from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -182,6 +203,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=[".default"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._token_validator is not None @@ -197,7 +219,9 @@ class TestAzureProvider: # (Azure returns unprefixed scopes like ".default" in JWT tokens) assert verifier.required_scopes == [".default"] - async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self): + async def test_authorize_filters_resource_and_stores_unprefixed_scopes( + self, memory_storage: MemoryStore + ): """authorize() should drop resource parameter and store unprefixed scopes for MCP clients.""" provider = AzureProvider( client_id="test_client", @@ -207,6 +231,7 @@ class TestAzureProvider: required_scopes=["read", "write"], base_url="https://srv.example", jwt_signing_key="test-secret", + client_storage=memory_storage, ) await provider.register_client( @@ -264,7 +289,9 @@ class TestAzureProvider: or "api://my-api/write" in upstream_url ) - async def test_authorize_appends_additional_scopes(self): + async def test_authorize_appends_additional_scopes( + self, memory_storage: MemoryStore + ): """authorize() should append additional_authorize_scopes to the authorization request.""" provider = AzureProvider( client_id="test_client", @@ -275,6 +302,7 @@ class TestAzureProvider: base_url="https://srv.example", additional_authorize_scopes=["Mail.Read", "User.Read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) await provider.register_client( @@ -326,7 +354,7 @@ class TestAzureProvider: assert "Mail.Read" in upstream_url assert "User.Read" in upstream_url - def test_base_authority_defaults_to_public_cloud(self): + def test_base_authority_defaults_to_public_cloud(self, memory_storage: MemoryStore): """Test that base_authority defaults to login.microsoftonline.com.""" provider = AzureProvider( client_id="test_client", @@ -335,6 +363,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert ( @@ -355,7 +384,7 @@ class TestAzureProvider: == "https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys" ) - def test_base_authority_azure_government(self): + def test_base_authority_azure_government(self, memory_storage: MemoryStore): """Test Azure Government endpoints with login.microsoftonline.us.""" provider = AzureProvider( client_id="test_client", @@ -365,6 +394,7 @@ class TestAzureProvider: required_scopes=["read"], base_authority="login.microsoftonline.us", jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert ( @@ -385,7 +415,7 @@ class TestAzureProvider: == "https://login.microsoftonline.us/gov-tenant-id/discovery/v2.0/keys" ) - def test_base_authority_from_parameter(self): + def test_base_authority_from_parameter(self, memory_storage: MemoryStore): """Test that base_authority can be set via parameter.""" provider = AzureProvider( client_id="env-client-id", @@ -395,6 +425,7 @@ class TestAzureProvider: required_scopes=["read"], base_authority="login.microsoftonline.us", jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert ( @@ -415,7 +446,9 @@ class TestAzureProvider: == "https://login.microsoftonline.us/env-tenant-id/discovery/v2.0/keys" ) - def test_base_authority_with_special_tenant_values(self): + def test_base_authority_with_special_tenant_values( + self, memory_storage: MemoryStore + ): """Test that base_authority works with special tenant values like 'organizations'.""" provider = AzureProvider( client_id="test_client", @@ -425,13 +458,16 @@ class TestAzureProvider: required_scopes=["read"], base_authority="login.microsoftonline.us", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider._upstream_authorization_endpoint) assert parsed.netloc == "login.microsoftonline.us" assert "/organizations/" in parsed.path - def test_prepare_scopes_for_upstream_refresh_basic_prefixing(self): + def test_prepare_scopes_for_upstream_refresh_basic_prefixing( + self, memory_storage: MemoryStore + ): """Test that unprefixed scopes are correctly prefixed for Azure token refresh.""" provider = AzureProvider( client_id="test_client", @@ -441,6 +477,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read", "write"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Unprefixed scopes from storage should be prefixed @@ -451,7 +488,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included for refresh tokens assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_already_prefixed(self): + def test_prepare_scopes_for_upstream_refresh_already_prefixed( + self, memory_storage: MemoryStore + ): """Test that already-prefixed scopes remain unchanged.""" provider = AzureProvider( client_id="test_client", @@ -461,6 +500,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Already prefixed scopes should pass through unchanged @@ -473,7 +513,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included for refresh tokens assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_with_additional_scopes(self): + def test_prepare_scopes_for_upstream_refresh_with_additional_scopes( + self, memory_storage: MemoryStore + ): """Test that only OIDC scopes from additional_authorize_scopes are added. Azure only allows ONE resource per token request (AADSTS28000), so @@ -493,6 +535,7 @@ class TestAzureProvider: "offline_access", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Base scopes should be prefixed, only OIDC scopes appended @@ -508,6 +551,7 @@ class TestAzureProvider: def test_prepare_scopes_for_upstream_refresh_filters_duplicate_additional_scopes( self, + memory_storage: MemoryStore, ): """Test that accidentally stored additional_authorize_scopes are filtered out.""" provider = AzureProvider( @@ -519,6 +563,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["User.Read", "openid"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # If additional scopes were accidentally stored, they should be filtered @@ -535,7 +580,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included and is OIDC assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_mixed_scopes(self): + def test_prepare_scopes_for_upstream_refresh_mixed_scopes( + self, memory_storage: MemoryStore + ): """Test mixed scenario with both prefixed and unprefixed scopes.""" provider = AzureProvider( client_id="test_client", @@ -546,6 +593,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["openid"], # OIDC scope jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Mix of prefixed and unprefixed scopes @@ -560,7 +608,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included assert len(result) == 5 - def test_prepare_scopes_for_upstream_refresh_scope_with_slash(self): + def test_prepare_scopes_for_upstream_refresh_scope_with_slash( + self, memory_storage: MemoryStore + ): """Test that scopes containing '/' are not prefixed.""" provider = AzureProvider( client_id="test_client", @@ -570,6 +620,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Scopes with "/" should not be prefixed (already fully qualified) @@ -582,7 +633,9 @@ class TestAzureProvider: "https://graph.microsoft.com/.default" in result ) # Not prefixed (contains ://) - def test_prepare_scopes_for_upstream_refresh_empty_scopes(self): + def test_prepare_scopes_for_upstream_refresh_empty_scopes( + self, memory_storage: MemoryStore + ): """Test behavior with empty scopes list.""" provider = AzureProvider( client_id="test_client", @@ -593,6 +646,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["User.Read", "openid"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Empty scopes should still add OIDC scopes (not User.Read) @@ -603,7 +657,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included assert len(result) == 2 # Only OIDC scopes: openid + offline_access - def test_prepare_scopes_for_upstream_refresh_no_additional_scopes(self): + def test_prepare_scopes_for_upstream_refresh_no_additional_scopes( + self, memory_storage: MemoryStore + ): """Test behavior when no additional_authorize_scopes are configured.""" provider = AzureProvider( client_id="test_client", @@ -613,6 +669,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Should prefix base scopes, plus auto-added offline_access @@ -623,7 +680,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_deduplicates_scopes(self): + def test_prepare_scopes_for_upstream_refresh_deduplicates_scopes( + self, memory_storage: MemoryStore + ): """Test that duplicate scopes are deduplicated while preserving order.""" provider = AzureProvider( client_id="test_client", @@ -634,6 +693,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["openid", "profile"], # OIDC scopes only jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Test with duplicate base scopes @@ -651,7 +711,9 @@ class TestAzureProvider: ] assert len(result) == 5 - def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants(self): + def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants( + self, memory_storage: MemoryStore + ): """Test that both prefixed and unprefixed variants are deduplicated.""" provider = AzureProvider( client_id="test_client", @@ -661,6 +723,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Test with both prefixed and unprefixed variants of same scope @@ -688,11 +751,13 @@ class TestOIDCScopeHandling: 3. OIDC scopes are still advertised to clients via valid_scopes """ - def test_oidc_scopes_constant(self): + def test_oidc_scopes_constant(self, memory_storage: MemoryStore): """Verify OIDC_SCOPES contains the standard OIDC scopes.""" assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"} - def test_prefix_scopes_does_not_prefix_oidc_scopes(self): + def test_prefix_scopes_does_not_prefix_oidc_scopes( + self, memory_storage: MemoryStore + ): """Test that _prefix_scopes_for_azure never prefixes OIDC scopes.""" provider = AzureProvider( client_id="test_client", @@ -702,6 +767,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # All OIDC scopes should pass through unchanged @@ -711,7 +777,7 @@ class TestOIDCScopeHandling: assert result == ["openid", "profile", "email", "offline_access"] - def test_prefix_scopes_mixed_oidc_and_custom(self): + def test_prefix_scopes_mixed_oidc_and_custom(self, memory_storage: MemoryStore): """Test prefixing with a mix of OIDC and custom scopes.""" provider = AzureProvider( client_id="test_client", @@ -721,6 +787,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) result = provider._prefix_scopes_for_azure( @@ -736,7 +803,9 @@ class TestOIDCScopeHandling: assert "api://my-api/openid" not in result assert "api://my-api/profile" not in result - def test_prefix_scopes_dot_notation_gets_prefixed(self): + def test_prefix_scopes_dot_notation_gets_prefixed( + self, memory_storage: MemoryStore + ): """Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph).""" provider = AzureProvider( client_id="test_client", @@ -746,6 +815,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph @@ -754,7 +824,9 @@ class TestOIDCScopeHandling: assert result == ["api://my-api/my.scope", "api://my-api/admin.read"] - def test_prefix_scopes_fully_qualified_graph_not_prefixed(self): + def test_prefix_scopes_fully_qualified_graph_not_prefixed( + self, memory_storage: MemoryStore + ): """Test that fully-qualified Graph scopes are not prefixed.""" provider = AzureProvider( client_id="test_client", @@ -764,6 +836,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) result = provider._prefix_scopes_for_azure( @@ -779,7 +852,9 @@ class TestOIDCScopeHandling: "https://graph.microsoft.com/Mail.Send", ] - def test_required_scopes_with_oidc_filters_validation(self): + def test_required_scopes_with_oidc_filters_validation( + self, memory_storage: MemoryStore + ): """Test that OIDC scopes in required_scopes are filtered from token validation.""" provider = AzureProvider( client_id="test_client", @@ -789,12 +864,15 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read", "openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Token validator should only require non-OIDC scopes assert provider._token_validator.required_scopes == ["read"] - def test_required_scopes_all_oidc_results_in_no_validation(self): + def test_required_scopes_all_oidc_results_in_no_validation( + self, memory_storage: MemoryStore + ): """Test that if all required_scopes are OIDC, no scope validation occurs.""" provider = AzureProvider( client_id="test_client", @@ -804,12 +882,13 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Token validator should have empty required scopes (all were OIDC) assert provider._token_validator.required_scopes == [] - def test_valid_scopes_includes_oidc_scopes(self): + def test_valid_scopes_includes_oidc_scopes(self, memory_storage: MemoryStore): """Test that valid_scopes advertises OIDC scopes to clients.""" provider = AzureProvider( client_id="test_client", @@ -819,6 +898,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read", "openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # required_scopes (used for validation) excludes OIDC scopes @@ -831,7 +911,9 @@ class TestOIDCScopeHandling: "profile", ] - def test_prepare_scopes_for_refresh_handles_oidc_scopes(self): + def test_prepare_scopes_for_refresh_handles_oidc_scopes( + self, memory_storage: MemoryStore + ): """Test that token refresh correctly handles OIDC scopes.""" provider = AzureProvider( client_id="test_client", @@ -841,6 +923,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Simulate stored scopes that include OIDC scopes @@ -864,7 +947,7 @@ class TestAzureTokenExchangeScopes: properly prefixed scopes. """ - def test_prepare_scopes_returns_prefixed_scopes(self): + def test_prepare_scopes_returns_prefixed_scopes(self, memory_storage: MemoryStore): """Test that _prepare_scopes_for_token_exchange returns prefixed scopes.""" provider = AzureProvider( client_id="test_client", @@ -874,6 +957,7 @@ class TestAzureTokenExchangeScopes: identifier_uri="api://my-api", required_scopes=["read", "write"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) scopes = provider._prepare_scopes_for_token_exchange(["read", "write"]) @@ -881,7 +965,9 @@ class TestAzureTokenExchangeScopes: assert "api://my-api/read" in scopes assert "api://my-api/write" in scopes - def test_prepare_scopes_includes_additional_oidc_scopes(self): + def test_prepare_scopes_includes_additional_oidc_scopes( + self, memory_storage: MemoryStore + ): """Test that _prepare_scopes_for_token_exchange includes OIDC scopes.""" provider = AzureProvider( client_id="test_client", @@ -892,6 +978,7 @@ class TestAzureTokenExchangeScopes: required_scopes=["read"], additional_authorize_scopes=["openid", "profile", "offline_access"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) scopes = provider._prepare_scopes_for_token_exchange(["read"]) @@ -901,7 +988,9 @@ class TestAzureTokenExchangeScopes: assert "profile" in scopes assert "offline_access" in scopes - def test_prepare_scopes_excludes_other_api_scopes(self): + def test_prepare_scopes_excludes_other_api_scopes( + self, memory_storage: MemoryStore + ): """Test token exchange excludes other API scopes (Azure AADSTS28000). Azure only allows ONE resource per token exchange. Other API scopes @@ -921,6 +1010,7 @@ class TestAzureTokenExchangeScopes: "api://11111111-2222-3333-4444-555555555555/user_impersonation", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) scopes = provider._prepare_scopes_for_token_exchange(["user_impersonation"]) @@ -935,7 +1025,7 @@ class TestAzureTokenExchangeScopes: assert not any("api://aaaaaaaa" in s for s in scopes) assert not any("api://11111111" in s for s in scopes) - def test_prepare_scopes_deduplicates_scopes(self): + def test_prepare_scopes_deduplicates_scopes(self, memory_storage: MemoryStore): """Test that duplicate scopes are deduplicated.""" provider = AzureProvider( client_id="test_client", @@ -946,6 +1036,7 @@ class TestAzureTokenExchangeScopes: required_scopes=["read"], additional_authorize_scopes=["api://my-api/read", "openid"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Pass a scope that will be prefixed to match one in additional_authorize_scopes @@ -955,7 +1046,9 @@ class TestAzureTokenExchangeScopes: assert scopes.count("api://my-api/read") == 1 assert "openid" in scopes - def test_extra_token_params_does_not_contain_scope(self): + def test_extra_token_params_does_not_contain_scope( + self, memory_storage: MemoryStore + ): """Test that extra_token_params doesn't contain scope to avoid TypeError. Previously, Azure provider set extra_token_params={"scope": ...} during init. @@ -974,6 +1067,7 @@ class TestAzureTokenExchangeScopes: required_scopes=["read", "write"], additional_authorize_scopes=["openid", "profile", "offline_access"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # extra_token_params should NOT contain "scope" to avoid TypeError during refresh diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py index 8d79265e6..509eb0826 100644 --- a/tests/server/auth/providers/test_discord.py +++ b/tests/server/auth/providers/test_discord.py @@ -1,12 +1,21 @@ """Tests for Discord OAuth provider.""" +import pytest +from key_value.aio.stores.memory import MemoryStore + from fastmcp.server.auth.providers.discord import DiscordProvider +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestDiscordProvider: """Test Discord OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test DiscordProvider initialization with explicit parameters.""" provider = DiscordProvider( client_id="env_client_id", @@ -14,31 +23,34 @@ class TestDiscordProvider: base_url="https://myserver.com", required_scopes=["email", "identify"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "env_client_id" assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" assert str(provider.base_url) == "https://myserver.com/" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = DiscordProvider( client_id="env_client_id", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = DiscordProvider( client_id="env_client_id", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that endpoints use Discord's OAuth2 endpoints @@ -52,7 +64,7 @@ class TestDiscordProvider: # Discord provider doesn't currently set a revocation endpoint assert provider._upstream_revocation_endpoint is None - def test_discord_specific_scopes(self): + def test_discord_specific_scopes(self, memory_storage: MemoryStore): """Test handling of Discord-specific scope formats.""" # Just test that the provider accepts Discord-specific scopes without error provider = DiscordProvider( @@ -64,6 +76,7 @@ class TestDiscordProvider: "email", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Provider should initialize successfully with these scopes diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index e2fcdaa25..fe2bbf031 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -2,16 +2,25 @@ from unittest.mock import MagicMock, patch +import pytest +from key_value.aio.stores.memory import MemoryStore + from fastmcp.server.auth.providers.github import ( GitHubProvider, GitHubTokenVerifier, ) +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestGitHubProvider: """Test GitHubProvider initialization.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test initialization with explicit parameters.""" provider = GitHubProvider( client_id="test_client", @@ -21,6 +30,7 @@ class TestGitHubProvider: required_scopes=["user", "repo"], timeout_seconds=30, jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that the provider was initialized correctly @@ -31,13 +41,14 @@ class TestGitHubProvider: ) # URLs get normalized with trailing slash assert provider._redirect_path == "/custom/callback" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = GitHubProvider( client_id="test_client", client_secret="test_secret", base_url="https://example.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults @@ -49,7 +60,7 @@ class TestGitHubProvider: class TestGitHubTokenVerifier: """Test GitHubTokenVerifier.""" - def test_init_with_custom_scopes(self): + def test_init_with_custom_scopes(self, memory_storage: MemoryStore): """Test initialization with custom required scopes.""" verifier = GitHubTokenVerifier( required_scopes=["user", "repo"], @@ -59,7 +70,7 @@ class TestGitHubTokenVerifier: assert verifier.required_scopes == ["user", "repo"] assert verifier.timeout_seconds == 30 - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test initialization with defaults.""" verifier = GitHubTokenVerifier() diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index d578c7056..0f6bd6c89 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -1,12 +1,21 @@ """Tests for Google OAuth provider.""" +import pytest +from key_value.aio.stores.memory import MemoryStore + from fastmcp.server.auth.providers.google import GoogleProvider +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestGoogleProvider: """Test Google OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test GoogleProvider initialization with explicit parameters.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", @@ -14,32 +23,35 @@ class TestGoogleProvider: base_url="https://myserver.com", required_scopes=["openid", "email", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "123456789.apps.googleusercontent.com" assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" assert str(provider.base_url) == "https://myserver.com/" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" # Google provider has ["openid"] as default but we can't easily verify without accessing internals - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that endpoints use Google's OAuth2 endpoints @@ -53,7 +65,7 @@ class TestGoogleProvider: # Google provider doesn't currently set a revocation endpoint assert provider._upstream_revocation_endpoint is None - def test_google_specific_scopes(self): + def test_google_specific_scopes(self, memory_storage: MemoryStore): """Test handling of Google-specific scope formats.""" # Just test that the provider accepts Google-specific scopes without error provider = GoogleProvider( @@ -66,18 +78,20 @@ class TestGoogleProvider: "https://www.googleapis.com/auth/userinfo.profile", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Provider should initialize successfully with these scopes assert provider is not None - def test_extra_authorize_params_defaults(self): + def test_extra_authorize_params_defaults(self, memory_storage: MemoryStore): """Test that Google-specific defaults are set for refresh token support.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Should have Google-specific defaults for refresh token support @@ -86,7 +100,9 @@ class TestGoogleProvider: "prompt": "consent", } - def test_extra_authorize_params_override_defaults(self): + def test_extra_authorize_params_override_defaults( + self, memory_storage: MemoryStore + ): """Test that user can override default extra authorize params.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", @@ -94,6 +110,7 @@ class TestGoogleProvider: base_url="https://myserver.com", jwt_signing_key="test-secret", extra_authorize_params={"prompt": "select_account"}, + client_storage=memory_storage, ) # User override should replace the default @@ -101,7 +118,7 @@ class TestGoogleProvider: # But other defaults should remain assert provider._extra_authorize_params["access_type"] == "offline" - def test_extra_authorize_params_add_new_params(self): + def test_extra_authorize_params_add_new_params(self, memory_storage: MemoryStore): """Test that user can add additional authorize params.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", @@ -109,6 +126,7 @@ class TestGoogleProvider: base_url="https://myserver.com", jwt_signing_key="test-secret", extra_authorize_params={"login_hint": "user@example.com"}, + client_storage=memory_storage, ) # New param should be added diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index 69ee18012..594f2e5b5 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -4,6 +4,7 @@ from urllib.parse import urlparse import httpx import pytest +from key_value.aio.stores.memory import MemoryStore from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport @@ -11,10 +12,16 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestWorkOSProvider: """Test WorkOS OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test WorkOSProvider initialization with explicit parameters.""" provider = WorkOSProvider( client_id="client_test123", @@ -23,13 +30,14 @@ class TestWorkOSProvider: base_url="https://myserver.com", required_scopes=["openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "client_test123" assert provider._upstream_client_secret.get_secret_value() == "secret_test456" assert str(provider.base_url) == "https://myserver.com/" - def test_authkit_domain_https_prefix_handling(self): + def test_authkit_domain_https_prefix_handling(self, memory_storage: MemoryStore): """Test that authkit_domain handles missing https:// prefix.""" # Without https:// - should add it provider1 = WorkOSProvider( @@ -38,6 +46,7 @@ class TestWorkOSProvider: authkit_domain="test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider1._upstream_authorization_endpoint) assert parsed.scheme == "https" @@ -51,6 +60,7 @@ class TestWorkOSProvider: authkit_domain="https://test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider2._upstream_authorization_endpoint) assert parsed.scheme == "https" @@ -64,13 +74,14 @@ class TestWorkOSProvider: authkit_domain="http://localhost:8080", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider3._upstream_authorization_endpoint) assert parsed.scheme == "http" assert parsed.netloc == "localhost:8080" assert parsed.path == "/oauth2/authorize" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = WorkOSProvider( client_id="test_client", @@ -78,13 +89,14 @@ class TestWorkOSProvider: authkit_domain="https://test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" # WorkOS provider has no default scopes but we can't easily verify without accessing internals - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = WorkOSProvider( client_id="test_client", @@ -92,6 +104,7 @@ class TestWorkOSProvider: authkit_domain="https://test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that endpoints use the authkit domain @@ -135,7 +148,9 @@ def client_with_headless_oauth(mcp_server_url: str) -> Client: class TestAuthKitProvider: - async def test_unauthorized_access(self, mcp_server_url: str): + async def test_unauthorized_access( + self, memory_storage: MemoryStore, mcp_server_url: str + ): with pytest.raises(httpx.HTTPStatusError) as exc_info: async with Client(mcp_server_url) as client: tools = await client.list_tools() # noqa: F841 From 40e80d60e5c66b01cf85ff230f11c8807ca5b461 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:42:43 -0500 Subject: [PATCH 47/63] Fix session visibility marks leaking across sessions (#3132) --- src/fastmcp/server/transforms/visibility.py | 16 +-- tests/server/test_session_visibility.py | 148 ++++++++++++++++++++ tests/server/transforms/test_visibility.py | 60 ++++---- 3 files changed, 188 insertions(+), 36 deletions(-) diff --git a/src/fastmcp/server/transforms/visibility.py b/src/fastmcp/server/transforms/visibility.py index 41061a651..5a3588886 100644 --- a/src/fastmcp/server/transforms/visibility.py +++ b/src/fastmcp/server/transforms/visibility.py @@ -171,23 +171,23 @@ class Visibility(Transform): return self.tags is None or bool(component.tags & self.tags) def _mark_component(self, component: T) -> T: - """Set visibility state in component metadata if rule matches.""" + """Set visibility state in component metadata if rule matches. + + Returns a copy of the component with updated metadata to avoid + mutating shared objects cached in providers. + """ if not self._matches(component): return component - # Create new dicts to avoid mutating shared dicts - # (e.g., when Tool.from_tool shares the meta dict between tools) if component.meta is None: - component.meta = { - _FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}} - } + new_meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}} else: old_fastmcp = component.meta.get(_FASTMCP_KEY, {}) old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) new_internal = {**old_internal, "visibility": self._enabled} new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} - component.meta = {**component.meta, _FASTMCP_KEY: new_fastmcp} - return component + new_meta = {**component.meta, _FASTMCP_KEY: new_fastmcp} + return component.model_copy(update={"meta": new_meta}) # ------------------------------------------------------------------------- # Transform methods (mark components, don't filter) diff --git a/tests/server/test_session_visibility.py b/tests/server/test_session_visibility.py index ae307dfc6..887f4f330 100644 --- a/tests/server/test_session_visibility.py +++ b/tests/server/test_session_visibility.py @@ -618,3 +618,151 @@ class TestConcurrentSessionIsolation: assert results[f"non_activated_{i}"] is False, ( f"Non-activated session {i} should NOT see premium tool" ) + + +class TestSessionVisibilityResetBug: + """Regression tests for #3034: visibility marks leak via shared component mutation.""" + + async def test_disable_then_reset_restores_tools(self): + """After disable + reset within the same session, tools should reappear.""" + from fastmcp import Client + + mcp = FastMCP("test") + + @mcp.tool(tags={"system"}) + def my_tool() -> str: + return "hello" + + @mcp.tool(tags={"env"}) + async def enter_env(ctx: Context) -> str: + await ctx.disable_components(tags={"system"}) + return "entered" + + @mcp.tool(tags={"env"}) + async def exit_env(ctx: Context) -> str: + await ctx.reset_visibility() + return "exited" + + async with Client(mcp) as client: + # Tool visible initially + tools = await client.list_tools() + assert any(t.name == "my_tool" for t in tools) + + # Disable it + await client.call_tool("enter_env", {}) + tools = await client.list_tools() + assert not any(t.name == "my_tool" for t in tools) + + # Reset — tool should come back + await client.call_tool("exit_env", {}) + tools = await client.list_tools() + assert any(t.name == "my_tool" for t in tools), ( + "Tool should be visible again after reset_visibility" + ) + + async def test_disable_reset_loop(self): + """Repeated disable/reset cycles should work every time (the exact bug from #3034).""" + from fastmcp import Client + + mcp = FastMCP("test") + + @mcp.tool(tags={"system"}) + def create_project() -> str: + return "created" + + @mcp.tool(tags={"env"}) + async def enter_env(ctx: Context) -> str: + await ctx.disable_components(tags={"system"}) + return "entered" + + @mcp.tool(tags={"env"}) + async def exit_env(ctx: Context) -> str: + await ctx.reset_visibility() + return "exited" + + async with Client(mcp) as client: + for i in range(3): + # create_project should be visible + tools = await client.list_tools() + assert any(t.name == "create_project" for t in tools), ( + f"Iteration {i}: create_project should be visible before enter_env" + ) + + # Enter env — disables system tools + await client.call_tool("enter_env", {}) + tools = await client.list_tools() + assert not any(t.name == "create_project" for t in tools), ( + f"Iteration {i}: create_project should be hidden after enter_env" + ) + + # Exit env — reset + await client.call_tool("exit_env", {}) + + async def test_session_disable_does_not_leak_to_concurrent_session(self): + """Disabling tools in one session must not affect a concurrent session.""" + from fastmcp import Client + + mcp = FastMCP("test") + + @mcp.tool(tags={"system"}) + def shared_tool() -> str: + return "shared" + + @mcp.tool + async def disable_system(ctx: Context) -> str: + await ctx.disable_components(tags={"system"}) + return "disabled" + + session_b_sees_tool = False + ready = anyio.Event() + check_done = anyio.Event() + + async def session_a(): + async with Client(mcp) as client: + await client.call_tool("disable_system", {}) + ready.set() + await check_done.wait() + + async def session_b(): + nonlocal session_b_sees_tool + await ready.wait() + async with Client(mcp) as client: + tools = await client.list_tools() + session_b_sees_tool = any(t.name == "shared_tool" for t in tools) + check_done.set() + + async with anyio.create_task_group() as tg: + tg.start_soon(session_a) + tg.start_soon(session_b) + + assert session_b_sees_tool is True, ( + "Session B should still see shared_tool despite Session A disabling it" + ) + + async def test_session_disable_does_not_leak_to_sequential_session(self): + """Disabling tools in one session must not affect a later session.""" + from fastmcp import Client + + mcp = FastMCP("test") + + @mcp.tool(tags={"system"}) + def shared_tool() -> str: + return "shared" + + @mcp.tool + async def disable_system(ctx: Context) -> str: + await ctx.disable_components(tags={"system"}) + return "disabled" + + # Session A disables the tool (no reset) + async with Client(mcp) as client_a: + await client_a.call_tool("disable_system", {}) + tools = await client_a.list_tools() + assert not any(t.name == "shared_tool" for t in tools) + + # Session B should see it fresh + async with Client(mcp) as client_b: + tools = await client_b.list_tools() + assert any(t.name == "shared_tool" for t in tools), ( + "New session should see shared_tool regardless of previous session" + ) diff --git a/tests/server/transforms/test_visibility.py b/tests/server/transforms/test_visibility.py index cf7b9f1b7..a784af1f4 100644 --- a/tests/server/transforms/test_visibility.py +++ b/tests/server/transforms/test_visibility.py @@ -101,36 +101,39 @@ class TestMarking: def test_disable_marks_as_disabled(self): """Visibility(False, ...) marks matching components as disabled.""" tool = Tool(name="foo", parameters={}) - Visibility(False, names={"foo"})._mark_component(tool) - assert is_enabled(tool) is False + marked = Visibility(False, names={"foo"})._mark_component(tool) + assert is_enabled(marked) is False def test_enable_marks_as_enabled(self): """Visibility(True, ...) marks matching components as enabled.""" tool = Tool(name="foo", parameters={}) - Visibility(True, names={"foo"})._mark_component(tool) - assert is_enabled(tool) is True - assert tool.meta is not None - assert tool.meta["fastmcp"]["_internal"]["visibility"] is True + marked = Visibility(True, names={"foo"})._mark_component(tool) + assert is_enabled(marked) is True + assert marked.meta is not None + assert marked.meta["fastmcp"]["_internal"]["visibility"] is True def test_non_matching_unchanged(self): """Non-matching components are not modified.""" tool = Tool(name="bar", parameters={}) - Visibility(False, names={"foo"})._mark_component(tool) + result = Visibility(False, names={"foo"})._mark_component(tool) # No _internal key added - assert tool.meta is None or "_internal" not in tool.meta.get("fastmcp", {}) - assert is_enabled(tool) is True + assert result.meta is None or "_internal" not in result.meta.get("fastmcp", {}) + assert is_enabled(result) is True - def test_mutates_in_place(self): - """Marking mutates the component in place.""" + def test_returns_copy_for_matching(self): + """Marking returns a copy to avoid mutating shared provider objects.""" tool = Tool(name="foo", parameters={}) result = Visibility(False, names={"foo"})._mark_component(tool) - assert result is tool + assert result is not tool + assert is_enabled(result) is False + # Original is untouched + assert is_enabled(tool) is True def test_disable_all(self): """match_all=True disables all components.""" tool = Tool(name="anything", parameters={}) - Visibility(False, match_all=True)._mark_component(tool) - assert is_enabled(tool) is False + marked = Visibility(False, match_all=True)._mark_component(tool) + assert is_enabled(marked) is False class TestOverride: @@ -139,20 +142,20 @@ class TestOverride: def test_enable_overrides_disable(self): """An enable after disable results in enabled.""" tool = Tool(name="foo", parameters={}) - Visibility(False, names={"foo"})._mark_component(tool) - assert is_enabled(tool) is False + marked = Visibility(False, names={"foo"})._mark_component(tool) + assert is_enabled(marked) is False - Visibility(True, names={"foo"})._mark_component(tool) - assert is_enabled(tool) is True + marked = Visibility(True, names={"foo"})._mark_component(marked) + assert is_enabled(marked) is True def test_disable_overrides_enable(self): """A disable after enable results in disabled.""" tool = Tool(name="foo", parameters={}) - Visibility(True, names={"foo"})._mark_component(tool) - assert is_enabled(tool) is True + marked = Visibility(True, names={"foo"})._mark_component(tool) + assert is_enabled(marked) is True - Visibility(False, names={"foo"})._mark_component(tool) - assert is_enabled(tool) is False + marked = Visibility(False, names={"foo"})._mark_component(marked) + assert is_enabled(marked) is False class TestHelperFunctions: @@ -169,9 +172,10 @@ class TestHelperFunctions: Tool(name="enabled", parameters={}), Tool(name="disabled", parameters={}), ] - Visibility(False, names={"disabled"})._mark_component(tools[1]) + vis = Visibility(False, names={"disabled"}) + marked_tools = [vis._mark_component(t) for t in tools] - visible = [t for t in tools if is_enabled(t)] + visible = [t for t in marked_tools if is_enabled(t)] assert [t.name for t in visible] == ["enabled"] @@ -181,14 +185,14 @@ class TestMetadata: def test_internal_metadata_stripped_by_get_meta(self): """Internal metadata is stripped when calling get_meta().""" tool = Tool(name="foo", parameters={}) - Visibility(True, names={"foo"})._mark_component(tool) + marked = Visibility(True, names={"foo"})._mark_component(tool) # Raw meta has _internal - assert tool.meta is not None - assert "_internal" in tool.meta.get("fastmcp", {}) + assert marked.meta is not None + assert "_internal" in marked.meta.get("fastmcp", {}) # get_meta() strips it - output = tool.get_meta() + output = marked.get_meta() assert "_internal" not in output.get("fastmcp", {}) def test_user_metadata_preserved(self): From a71517649938c8de6c5f1d4759c3fbdbaedfc2fe Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:13:45 -0500 Subject: [PATCH 48/63] Fix unhandled exceptions in OpenAPI POST tool calls (#3133) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- ...cp-server-providers-openapi-components.mdx | 8 +- .../server/providers/openapi/components.py | 13 +- src/fastmcp/utilities/openapi/director.py | 12 +- .../providers/openapi/test_comprehensive.py | 195 ++++++++++++++++++ 4 files changed, 217 insertions(+), 11 deletions(-) diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index bc0b40d3c..64fba8da3 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx @@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Execute the HTTP request using RequestDirector. -### `OpenAPIResource` +### `OpenAPIResource` Resource implementation for OpenAPI endpoints. @@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -44,7 +44,7 @@ read(self) -> ResourceResult Fetch the resource data by making an HTTP request. -### `OpenAPIResourceTemplate` +### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. @@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 4e6d18f1e..aa6ef8082 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -159,23 +159,28 @@ class OpenAPITool(Tool): async def run(self, arguments: dict[str, Any]) -> ToolResult: """Execute the HTTP request using RequestDirector.""" + # Build the request — errors here are programming/schema issues, + # not HTTP failures, so we catch them separately. try: base_url = str(self._client.base_url) or "http://localhost" - - # Build the request using RequestDirector request = self._director.build(self._route, arguments, base_url) - # Add client headers (lowest precedence) if self._client.headers: for key, value in self._client.headers.items(): if key not in request.headers: request.headers[key] = value - # Add MCP transport headers (highest precedence) mcp_headers = get_http_headers() if mcp_headers: request.headers.update(mcp_headers) + except Exception as e: + raise ValueError( + f"Error building request for {self._route.method.upper()} " + f"{self._route.path}: {type(e).__name__}: {e}" + ) from e + # Send the request and process the response. + try: logger.debug(f"run - sending request; headers: {request.headers}") response = await self._client.send(request) diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 2efc8e74c..58e941ba7 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -166,12 +166,18 @@ class RequestDirector: body = None if body_props: # If we have body properties, construct the body object - if route.request_body and route.request_body.content_schema: - # Check if the request body expects an object with properties + if ( + route.request_body + and route.request_body.content_schema + and len(route.request_body.content_schema) > 0 + ): content_type = next(iter(route.request_body.content_schema)) body_schema = route.request_body.content_schema[content_type] - if body_schema.get("type") == "object": + if ( + isinstance(body_schema, dict) + and body_schema.get("type") == "object" + ): body = body_props elif len(body_props) == 1: # If body schema is not an object and we have exactly one property, diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py index f55786e65..a21a764db 100644 --- a/tests/server/providers/openapi/test_comprehensive.py +++ b/tests/server/providers/openapi/test_comprehensive.py @@ -763,3 +763,198 @@ class TestOpenAPIComprehensive: error_message = str(exc_info.value) assert "timed out" in error_message assert "ReadTimeout" in error_message + + +class TestOpenAPIPostEdgeCases: + """Tests for POST request edge cases that could cause unhandled errors.""" + + @pytest.fixture + def post_spec_with_empty_content_schema(self): + """OpenAPI spec where a POST endpoint has an empty content_schema.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/items": { + "post": { + "operationId": "create_item", + "summary": "Create an item", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "integer"}, + }, + "required": ["name"], + } + } + }, + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + } + }, + } + }, + } + }, + "/items/{item_id}": { + "post": { + "operationId": "update_item", + "summary": "Update an item", + "parameters": [ + { + "name": "item_id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "integer"}, + }, + } + } + }, + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + } + }, + } + }, + } + }, + }, + } + + async def test_post_with_body_params(self, post_spec_with_empty_content_schema): + """POST with body parameters should build the request correctly.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + mock_response = Mock(spec=Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": 1, "name": "Test"} + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + server = create_openapi_server( + openapi_spec=post_spec_with_empty_content_schema, + client=mock_client, + ) + + async with Client(server) as mcp_client: + result = await mcp_client.call_tool( + "create_item", {"name": "Test", "value": 42} + ) + + mock_client.send.assert_called_once() + request = mock_client.send.call_args[0][0] + assert request.method == "POST" + body_data = json.loads(request.content) + assert body_data["name"] == "Test" + assert body_data["value"] == 42 + assert result is not None + + async def test_post_with_path_params_and_body( + self, post_spec_with_empty_content_schema + ): + """POST with both path parameters and body should route args correctly.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + mock_response = Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": 5, "name": "Updated"} + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + server = create_openapi_server( + openapi_spec=post_spec_with_empty_content_schema, + client=mock_client, + ) + + async with Client(server) as mcp_client: + result = await mcp_client.call_tool( + "update_item", + {"item_id": 5, "name": "Updated", "value": 99}, + ) + + mock_client.send.assert_called_once() + request = mock_client.send.call_args[0][0] + assert request.method == "POST" + assert "/items/5" in str(request.url) + body_data = json.loads(request.content) + assert body_data["name"] == "Updated" + assert body_data["value"] == 99 + assert "item_id" not in body_data + assert result is not None + + async def test_unexpected_error_in_request_building_gives_useful_message(self): + """Unexpected exceptions during request building should produce useful errors.""" + from fastmcp.server.providers.openapi.components import OpenAPITool + from fastmcp.utilities.openapi.director import RequestDirector + from fastmcp.utilities.openapi.models import HTTPRoute + + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + route = HTTPRoute( + path="/test", + method="POST", + operation_id="test_op", + parameters=[], + responses={}, + response_schemas={}, + ) + + mock_director = Mock(spec=RequestDirector) + mock_director.build.side_effect = KeyError("missing_param") + + tool = OpenAPITool( + client=mock_client, + route=route, + director=mock_director, + name="test_tool", + description="test", + parameters={}, + ) + + with pytest.raises(ValueError, match="Error building request for POST /test"): + await tool.run({"some_arg": "value"}) From 1d0c0adeabad1e6c2a166f468e1b27f966ac2b10 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:29:45 -0500 Subject: [PATCH 49/63] Add validate_output option for OpenAPI tools (#3134) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- ...cp-server-providers-openapi-components.mdx | 8 +- ...tmcp-server-providers-openapi-provider.mdx | 4 +- docs/python-sdk/fastmcp-server-server.mdx | 14 +- .../server/providers/openapi/components.py | 6 + .../server/providers/openapi/provider.py | 17 ++ src/fastmcp/server/server.py | 6 + .../openapi/test_openapi_features.py | 213 ++++++++++++++++++ 7 files changed, 257 insertions(+), 11 deletions(-) diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index 64fba8da3..568d3d8d3 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx @@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Execute the HTTP request using RequestDirector. -### `OpenAPIResource` +### `OpenAPIResource` Resource implementation for OpenAPI endpoints. @@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -44,7 +44,7 @@ read(self) -> ResourceResult Fetch the resource data by making an HTTP request. -### `OpenAPIResourceTemplate` +### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. @@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx index 6892d1174..69c2b0dae 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx @@ -21,7 +21,7 @@ spec. Each component makes HTTP calls to the described API endpoints. **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -30,7 +30,7 @@ lifespan(self) -> AsyncIterator[None] Manage the lifecycle of the auto-created httpx client. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 56bfd7696..80b45257b 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -823,7 +823,7 @@ objects are imported with their original names. #### `from_openapi` ```python -from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> Self +from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self ``` Create a FastMCP server from an OpenAPI specification. @@ -839,13 +839,17 @@ server URL from the OpenAPI spec with a 30-second timeout. - `mcp_component_fn`: Optional callable for component customization - `mcp_names`: Optional dictionary mapping operationId to component names - `tags`: Optional set of tags to add to all components +- `validate_output`: If True (default), tools use the output schema +extracted from the OpenAPI spec for response validation. If +False, a permissive schema is used instead, allowing any +response structure while still returning structured JSON. - `**settings`: Additional settings passed to FastMCP **Returns:** - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -869,7 +873,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -887,7 +891,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index aa6ef8082..6b942d22e 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -201,6 +201,12 @@ class OpenAPITool(Tool): else: structured_output = result + # Structured content must be a dict for the MCP protocol. + # Wrap non-dict values that slipped through (e.g. a backend + # returning an array when the schema declared an object). + if not isinstance(structured_output, dict): + structured_output = {"result": structured_output} + return ToolResult(structured_content=structured_output) except json.JSONDecodeError: return ToolResult(content=response.text) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index ac79af400..68979c0c1 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -79,6 +79,7 @@ class OpenAPIProvider(Provider): mcp_component_fn: ComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, + validate_output: bool = True, ): """Initialize provider by parsing OpenAPI spec and creating components. @@ -93,6 +94,10 @@ class OpenAPIProvider(Provider): mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components + validate_output: If True (default), tools use the output schema + extracted from the OpenAPI spec for response validation. If + False, a permissive schema is used instead, allowing any + response structure while still returning structured JSON. """ super().__init__() @@ -101,6 +106,7 @@ class OpenAPIProvider(Provider): client = self._create_default_client(openapi_spec) self._client = client self._mcp_component_fn = mcp_component_fn + self._validate_output = validate_output # Keep track of names to detect collisions self._used_names: dict[str, Counter[str]] = { @@ -232,6 +238,17 @@ class OpenAPIProvider(Provider): route.openapi_version, ) + if not self._validate_output and output_schema is not None: + # Use a permissive schema that accepts any object, preserving + # the wrap-result flag so non-object responses still get wrapped + permissive: dict[str, Any] = { + "type": "object", + "additionalProperties": True, + } + if output_schema.get("x-fastmcp-wrap-result"): + permissive["x-fastmcp-wrap-result"] = True + output_schema = permissive + tool_name = self._get_unique_name(name, "tool") base_description = ( route.description diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 7dc747821..35d717e8c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -2029,6 +2029,7 @@ class FastMCP( mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, + validate_output: bool = True, **settings: Any, ) -> Self: """ @@ -2045,6 +2046,10 @@ class FastMCP( mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components + validate_output: If True (default), tools use the output schema + extracted from the OpenAPI spec for response validation. If + False, a permissive schema is used instead, allowing any + response structure while still returning structured JSON. **settings: Additional settings passed to FastMCP Returns: @@ -2060,6 +2065,7 @@ class FastMCP( mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, + validate_output=validate_output, ) return cls(name=name, providers=[provider], **settings) diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index f55a1e038..ef4aeb002 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -1,7 +1,10 @@ """Tests for OpenAPI feature support in OpenAPIProvider.""" +from unittest.mock import AsyncMock, Mock + import httpx import pytest +from httpx import Response from fastmcp import FastMCP from fastmcp.client import Client @@ -769,3 +772,213 @@ class TestResourceMimeType: resources = await mcp_client.list_resources() assert len(resources) == 1 assert resources[0].mimeType == "text/plain" + + +class TestValidateOutput: + """Tests for the validate_output option on OpenAPIProvider.""" + + @pytest.fixture + def spec_with_output_schema(self): + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "summary": "Get a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ], + "responses": { + "200": { + "description": "A user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "email": {"type": "string"}, + }, + "required": ["id", "name"], + } + } + }, + } + }, + } + }, + "/items": { + "get": { + "operationId": "list_items", + "summary": "List items", + "responses": { + "200": { + "description": "An array of items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + }, + } + } + }, + } + }, + } + }, + }, + } + + async def test_validate_output_true_preserves_extracted_schema( + self, spec_with_output_schema + ): + """Default validate_output=True uses the real extracted schema.""" + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=client, + ) + + tool = provider._tools["get_user"] + assert tool.output_schema is not None + assert tool.output_schema.get("type") == "object" + assert "properties" in tool.output_schema + assert "id" in tool.output_schema["properties"] + + async def test_validate_output_false_uses_permissive_schema( + self, spec_with_output_schema + ): + """validate_output=False replaces the schema with a permissive one.""" + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=client, + validate_output=False, + ) + + tool = provider._tools["get_user"] + assert tool.output_schema is not None + assert tool.output_schema == { + "type": "object", + "additionalProperties": True, + } + + async def test_validate_output_false_preserves_wrap_result_flag( + self, spec_with_output_schema + ): + """validate_output=False preserves x-fastmcp-wrap-result for array responses.""" + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=client, + validate_output=False, + ) + + # The list_items endpoint returns an array, so the extracted schema + # would have had x-fastmcp-wrap-result=True + tool = provider._tools["list_items"] + assert tool.output_schema is not None + assert tool.output_schema.get("x-fastmcp-wrap-result") is True + assert tool.output_schema.get("additionalProperties") is True + + async def test_validate_output_false_allows_nonconforming_response( + self, spec_with_output_schema + ): + """With validate_output=False, responses that don't match the spec succeed.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + # Return extra fields not in the schema + mock_response = Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "unexpected_field": "surprise", + "nested": {"deep": True}, + } + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=mock_client, + validate_output=False, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool("get_user", {"id": 1}) + assert result is not None + # Structured content should have the full response including extra fields + assert result.structured_content is not None + assert result.structured_content["unexpected_field"] == "surprise" + + async def test_validate_output_false_wraps_non_dict_response( + self, spec_with_output_schema + ): + """Non-dict responses are wrapped even when schema says object and validate_output=False.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + # Backend returns an array even though schema says object + mock_response = Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": 1}, {"id": 2}] + mock_response.raise_for_status = Mock() + mock_client.send = AsyncMock(return_value=mock_response) + + provider = OpenAPIProvider( + openapi_spec=spec_with_output_schema, + client=mock_client, + validate_output=False, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool("get_user", {"id": 1}) + assert result is not None + # Non-dict should be wrapped so structured_content is always a dict + assert result.structured_content is not None + assert isinstance(result.structured_content, dict) + assert result.structured_content["result"] == [{"id": 1}, {"id": 2}] + + async def test_from_openapi_threads_validate_output(self, spec_with_output_schema): + """FastMCP.from_openapi() correctly passes validate_output to the provider.""" + mock_client = Mock(spec=httpx.AsyncClient) + mock_client.base_url = "https://api.example.com" + mock_client.headers = None + + server = FastMCP.from_openapi( + openapi_spec=spec_with_output_schema, + client=mock_client, + validate_output=False, + ) + + async with Client(server) as mcp_client: + tools = await mcp_client.list_tools() + get_user = next(t for t in tools if t.name == "get_user") + # With validate_output=False, the outputSchema should be permissive + assert get_user.outputSchema is not None + assert get_user.outputSchema.get("additionalProperties") is True + # Should NOT have specific properties from the original schema + assert "properties" not in get_user.outputSchema From 361eb08f429c1fe243a07d6b4241b733213c4814 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 10 Feb 2026 14:58:19 -0500 Subject: [PATCH 50/63] Relay task elicitation through standard MCP protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a background task calls ctx.elicit(), the notification subscriber now detects the input_required notification and sends a standard elicitation/create request to the client via session.elicit(). The client's elicitation_handler fires, and the relay pushes the response to Redis for the blocked worker. This means clients can respond to background task elicitation using the same elicitation_handler they'd use for any other elicitation — no need to interact with Redis or call handle_task_input() directly. Co-Authored-By: Claude Opus 4.6 --- examples/task_elicitation.py | 82 ++++++++ src/fastmcp/server/context.py | 2 +- src/fastmcp/server/tasks/elicitation.py | 2 +- src/fastmcp/server/tasks/notifications.py | 103 +++++++++- .../tasks/test_context_background_task.py | 107 ++-------- tests/server/tasks/test_notifications.py | 101 ++++----- .../tasks/test_task_elicitation_relay.py | 191 ++++++++++++++++++ 7 files changed, 435 insertions(+), 153 deletions(-) create mode 100644 examples/task_elicitation.py create mode 100644 tests/server/tasks/test_task_elicitation_relay.py diff --git a/examples/task_elicitation.py b/examples/task_elicitation.py new file mode 100644 index 000000000..51f7b046a --- /dev/null +++ b/examples/task_elicitation.py @@ -0,0 +1,82 @@ +""" +Background task elicitation demo. + +A background task (Docket) that pauses mid-execution to ask the user a +question, waits for the answer, then resumes and finishes. + +Works with both in-memory and Redis backends: + + # In-memory (single process, no Redis needed) + FASTMCP_DOCKET_URL=memory:// uv run python examples/task_elicitation.py + + # Redis (distributed, needs a worker running separately) + # Terminal 1: docker compose -f examples/tasks/docker-compose.yml up -d + # Terminal 2: FASTMCP_DOCKET_URL=redis://localhost:24242/0 \ + # uv run fastmcp tasks worker examples/task_elicitation.py + # Terminal 3: FASTMCP_DOCKET_URL=redis://localhost:24242/0 \ + # uv run python examples/task_elicitation.py + +Requires the `docket` extra (included in dev dependencies). +""" + +import asyncio +from dataclasses import dataclass + +from mcp.types import TextContent + +from fastmcp import Context, FastMCP +from fastmcp.client import Client +from fastmcp.server.elicitation import AcceptedElicitation + +mcp = FastMCP("Task Elicitation Demo") + + +@dataclass +class DinnerPrefs: + cuisine: str + vegetarian: bool + + +@mcp.tool(task=True) +async def plan_dinner(ctx: Context) -> str: + """Plan a dinner menu, asking the user what they're in the mood for.""" + + await ctx.report_progress(0, 2, "Asking what you'd like...") + + result = await ctx.elicit( + "What kind of dinner are you in the mood for?", + response_type=DinnerPrefs, + ) + + if not isinstance(result, AcceptedElicitation): + return "Dinner cancelled!" + + prefs = result.data + await ctx.report_progress(1, 2, "Planning your menu...") + await asyncio.sleep(1) + await ctx.report_progress(2, 2, "Done!") + + veg = "vegetarian " if prefs.vegetarian else "" + return f"Tonight's menu: a lovely {veg}{prefs.cuisine} dinner!" + + +async def handle_elicitation(message, response_type, params, context): + """Handle elicitation requests from background tasks.""" + print(f" Server asks: {message}") + print(" Responding with: cuisine=Thai, vegetarian=True") + return DinnerPrefs(cuisine="Thai", vegetarian=True) + + +async def main(): + async with Client(mcp, elicitation_handler=handle_elicitation) as client: + print("Starting background task...") + task = await client.call_tool("plan_dinner", {}, task=True) + print(f" task_id = {task.task_id}\n") + + result = await task.result() + assert isinstance(result.content[0], TextContent) + print(f"\nResult: {result.content[0].text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index a39817e58..d65e87c43 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -1133,7 +1133,7 @@ class Context: return await elicit_for_task( task_id=self._task_id, # type: ignore[arg-type] - session=self.session, + session=self._session, message=message, schema=schema, fastmcp=self.fastmcp, diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py index 299382df6..1a85da9f8 100644 --- a/src/fastmcp/server/tasks/elicitation.py +++ b/src/fastmcp/server/tasks/elicitation.py @@ -41,7 +41,7 @@ ELICIT_TTL_SECONDS = 3600 async def elicit_for_task( task_id: str, - session: ServerSession, + session: ServerSession | None, message: str, schema: dict[str, Any], fastmcp: FastMCP, diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py index 2c65e31e1..c47f8e484 100644 --- a/src/fastmcp/server/tasks/notifications.py +++ b/src/fastmcp/server/tasks/notifications.py @@ -117,7 +117,9 @@ async def notification_subscriber_loop( try: # Reconstruct and send MCP notification - await _send_mcp_notification(session, notification_dict) + await _send_mcp_notification( + session, notification_dict, session_id, docket + ) logger.debug( "Delivered notification to session %s (attempt %d)", session_id, @@ -159,12 +161,20 @@ async def notification_subscriber_loop( async def _send_mcp_notification( session: ServerSession, notification_dict: dict[str, Any], + session_id: str, + docket: Docket, ) -> None: """Reconstruct MCP notification from dict and send to session. + For input_required notifications with elicitation metadata, also sends + a standard elicitation/create request to the client and relays the + response back to the worker via Redis. + Args: session: MCP ServerSession notification_dict: Notification as dict (method, params, _meta) + session_id: Session identifier (for elicitation relay) + docket: Docket instance (for elicitation relay) """ method = notification_dict.get("method", "notifications/tasks/status") if method != "notifications/tasks/status": @@ -181,6 +191,97 @@ async def _send_mcp_notification( await session.send_notification(server_notification) + # If this is an input_required notification with elicitation metadata, + # relay the elicitation to the client via standard elicitation/create + params = notification_dict.get("params", {}) + if params.get("status") == "input_required": + meta = notification_dict.get("_meta", {}) + related_task = meta.get("modelcontextprotocol.io/related-task", {}) + elicitation = related_task.get("elicitation") + if elicitation: + task_id = params["taskId"] + asyncio.create_task( # noqa: RUF006 + _relay_elicitation(session, session_id, task_id, elicitation, docket), + name=f"elicitation-relay-{task_id[:8]}", + ) + + +async def _relay_elicitation( + session: ServerSession, + session_id: str, + task_id: str, + elicitation: dict[str, Any], + docket: Docket, +) -> None: + """Relay elicitation from a background task worker to the client. + + Sends a standard elicitation/create request to the client session, then + pushes the response to Redis so the blocked worker can resume. + + Args: + session: MCP ServerSession + session_id: Session identifier + task_id: Background task ID + elicitation: Elicitation metadata (message, requestedSchema) + docket: Docket instance for Redis access + """ + from fastmcp.server.tasks.elicitation import ( + ELICIT_RESPONSE_KEY, + ELICIT_STATUS_KEY, + ELICIT_TTL_SECONDS, + ) + + try: + result = await session.elicit( + message=elicitation["message"], + requestedSchema=elicitation["requestedSchema"], + ) + + response_key = docket.key( + ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) + ) + status_key = docket.key( + ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) + ) + + response = { + "action": result.action, + "content": result.content, + } + + async with docket.redis() as redis: + await redis.lpush( # type: ignore[invalid-await] + response_key, json.dumps(response) + ) + await redis.expire(response_key, ELICIT_TTL_SECONDS) + await redis.set(status_key, "responded", ex=ELICIT_TTL_SECONDS) + + logger.debug( + "Relayed elicitation response for task %s (action=%s)", + task_id, + result.action, + ) + except Exception as e: + logger.warning("Failed to relay elicitation for task %s: %s", task_id, e) + # Push a cancel response so the worker's BLPOP doesn't block forever + try: + response_key = docket.key( + ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) + ) + cancel = {"action": "cancel", "content": None} + async with docket.redis() as redis: + await redis.lpush( # type: ignore[invalid-await] + response_key, json.dumps(cancel) + ) + await redis.expire(response_key, ELICIT_TTL_SECONDS) + except Exception as cancel_error: + logger.warning( + "Failed to push cancel response for task %s " + "(worker may block until TTL): %s", + task_id, + cancel_error, + ) + # ============================================================================= # Subscriber Management diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py index 219a7ae21..2b5a1efa9 100644 --- a/tests/server/tasks/test_context_background_task.py +++ b/tests/server/tasks/test_context_background_task.py @@ -13,6 +13,7 @@ from mcp import ServerSession from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.client.elicitation import ElicitResult from fastmcp.server.context import Context from fastmcp.server.elicitation import AcceptedElicitation, DeclinedElicitation from fastmcp.server.tasks.elicitation import handle_task_input @@ -227,67 +228,31 @@ class TestBackgroundTaskIntegration: assert captured["is_background"] is True async def test_elicit_accept_flow(self): - """E2E: tool elicits input, client accepts, tool receives value. - - Flow: - 1. Tool calls ctx.elicit("name?", str) — blocks waiting for input - 2. Client polls handle_task_input(action="accept", content={"value":"Bob"}) - 3. Tool resumes with AcceptedElicitation(data="Bob") - """ + """E2E: tool elicits input, client accepts via elicitation_handler.""" mcp = FastMCP("elicit-accept-test") - elicit_started = asyncio.Event() - captured: dict[str, str | None] = {"task_id": None, "session_id": None} @mcp.tool(task=True) async def ask_name(ctx: Context) -> str: - captured["task_id"] = ctx.task_id - captured["session_id"] = ctx.session_id - elicit_started.set() - result = await ctx.elicit("What is your name?", str) if isinstance(result, AcceptedElicitation): return f"Hello, {result.data}!" return "No name provided" - async with Client(mcp) as client: + async def handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"value": "Bob"}) + + async with Client(mcp, elicitation_handler=handler) as client: task = await client.call_tool("ask_name", {}, task=True) - await asyncio.wait_for(elicit_started.wait(), timeout=5.0) - - assert captured["task_id"] is not None - assert captured["session_id"] is not None - - # Poll until the "waiting" status is stored in Redis - success = False - for _ in range(40): - success = await handle_task_input( - task_id=captured["task_id"], - session_id=captured["session_id"], - action="accept", - content={"value": "Bob"}, - fastmcp=mcp, - ) - if success: - break - await asyncio.sleep(0.05) - - assert success is True, "handle_task_input should succeed within 2s" - await task.wait(timeout=10.0) result = await task.result() assert result.data == "Hello, Bob!" async def test_elicit_decline_flow(self): - """E2E: tool elicits input, client declines, tool gets DeclinedElicitation.""" + """E2E: tool elicits input, client declines via elicitation_handler.""" mcp = FastMCP("elicit-decline-test") - elicit_started = asyncio.Event() - captured: dict[str, str | None] = {"task_id": None, "session_id": None} @mcp.tool(task=True) async def optional_input(ctx: Context) -> str: - captured["task_id"] = ctx.task_id - captured["session_id"] = ctx.session_id - elicit_started.set() - result = await ctx.elicit("Want to provide a name?", str) if isinstance(result, DeclinedElicitation): return "User declined" @@ -295,34 +260,17 @@ class TestBackgroundTaskIntegration: return f"Got: {result.data}" return "Cancelled" - async with Client(mcp) as client: + async def handler(message, response_type, params, ctx): + return ElicitResult(action="decline") + + async with Client(mcp, elicitation_handler=handler) as client: task = await client.call_tool("optional_input", {}, task=True) - await asyncio.wait_for(elicit_started.wait(), timeout=5.0) - - assert captured["task_id"] is not None - assert captured["session_id"] is not None - - success = False - for _ in range(40): - success = await handle_task_input( - task_id=captured["task_id"], - session_id=captured["session_id"], - action="decline", - content=None, - fastmcp=mcp, - ) - if success: - break - await asyncio.sleep(0.05) - - assert success is True - await task.wait(timeout=10.0) result = await task.result() assert result.data == "User declined" async def test_elicit_with_pydantic_model(self): - """E2E: tool elicits structured Pydantic input, data round-trips correctly.""" + """E2E: tool elicits structured Pydantic input via elicitation_handler.""" from pydantic import BaseModel class UserInfo(BaseModel): @@ -330,43 +278,20 @@ class TestBackgroundTaskIntegration: age: int mcp = FastMCP("elicit-pydantic-test") - elicit_started = asyncio.Event() - captured: dict[str, str | None] = {"task_id": None, "session_id": None} @mcp.tool(task=True) async def get_user_info(ctx: Context) -> str: - captured["task_id"] = ctx.task_id - captured["session_id"] = ctx.session_id - elicit_started.set() - result = await ctx.elicit("Provide user info", UserInfo) if isinstance(result, AcceptedElicitation): assert isinstance(result.data, UserInfo) return f"{result.data.name} is {result.data.age}" return "No info" - async with Client(mcp) as client: + async def handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"name": "Alice", "age": 30}) + + async with Client(mcp, elicitation_handler=handler) as client: task = await client.call_tool("get_user_info", {}, task=True) - await asyncio.wait_for(elicit_started.wait(), timeout=5.0) - - assert captured["task_id"] is not None - assert captured["session_id"] is not None - - success = False - for _ in range(40): - success = await handle_task_input( - task_id=captured["task_id"], - session_id=captured["session_id"], - action="accept", - content={"name": "Alice", "age": 30}, - fastmcp=mcp, - ) - if success: - break - await asyncio.sleep(0.05) - - assert success is True - await task.wait(timeout=10.0) result = await task.result() assert result.data == "Alice is 30" diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py index 07b2b7f3b..34b169bb5 100644 --- a/tests/server/tasks/test_notifications.py +++ b/tests/server/tasks/test_notifications.py @@ -7,14 +7,14 @@ No mocking of Redis, sessions, or Docket internals. import asyncio -import mcp.types +import mcp.types as mcp_types from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.client.elicitation import ElicitResult from fastmcp.client.messages import MessageHandler from fastmcp.server.context import Context from fastmcp.server.elicitation import AcceptedElicitation -from fastmcp.server.tasks.elicitation import handle_task_input from fastmcp.server.tasks.notifications import ( get_subscriber_count, ) @@ -25,12 +25,12 @@ class NotificationCaptureHandler(MessageHandler): def __init__(self) -> None: super().__init__() - self.notifications: list[mcp.types.ServerNotification] = [] + self.notifications: list[mcp_types.ServerNotification] = [] - async def on_notification(self, message: mcp.types.ServerNotification) -> None: + async def on_notification(self, message: mcp_types.ServerNotification) -> None: self.notifications.append(message) - def for_method(self, method: str) -> list[mcp.types.ServerNotification]: + def for_method(self, method: str) -> list[mcp_types.ServerNotification]: return [ notification for notification in self.notifications @@ -41,58 +41,60 @@ class NotificationCaptureHandler(MessageHandler): class TestNotificationIntegration: """Integration tests for the notification queue using real Docket memory backend. - The elicitation flow implicitly validates the full notification pipeline: - 1. Tool calls ctx.elicit() → stores request in Redis → pushes notification - 2. Subscriber picks up notification → sends MCP notification to client - 3. Client calls handle_task_input() → LPUSH response → BLPOP wakes tool + The elicitation flow validates the full notification pipeline: + 1. Tool calls ctx.elicit() -> stores request in Redis -> pushes notification + 2. Subscriber picks up notification -> sends MCP notification to client + 3. Subscriber relays elicitation/create to client -> handler responds + 4. Relay pushes response to Redis -> BLPOP wakes tool """ async def test_notification_delivered_during_elicitation(self): - """Full E2E: notification queue delivers input_required metadata to client.""" + """Full E2E: notification queue delivers input_required metadata to client. + + The elicitation relay handles the response via the client's + elicitation_handler. We verify both the notification metadata + structure and the end-to-end elicitation flow. + """ mcp = FastMCP("notification-test") notification_handler = NotificationCaptureHandler() - elicit_started = asyncio.Event() - captured: dict[str, str | None] = {"task_id": None, "session_id": None} @mcp.tool(task=True) async def elicit_tool(ctx: Context) -> str: - captured["task_id"] = ctx.task_id - captured["session_id"] = ctx.session_id - elicit_started.set() - result = await ctx.elicit("Enter value", str) if isinstance(result, AcceptedElicitation): return f"got: {result.data}" return "no value" - async with Client(mcp, message_handler=notification_handler) as client: + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"value": "hello"}) + + async with Client( + mcp, + message_handler=notification_handler, + elicitation_handler=elicitation_handler, + ) as client: task = await client.call_tool("elicit_tool", {}, task=True) - await asyncio.wait_for(elicit_started.wait(), timeout=5.0) - assert captured["task_id"] is not None - assert captured["session_id"] is not None + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "got: hello" - notification: mcp.types.ServerNotification | None = None - for _ in range(40): - candidates = notification_handler.for_method( - "notifications/tasks/status" + # Verify the input_required notification was delivered with metadata + notification: mcp_types.ServerNotification | None = None + candidates = notification_handler.for_method("notifications/tasks/status") + for candidate in reversed(candidates): + candidate_meta = getattr(candidate.root, "_meta", None) + related_task = ( + candidate_meta.get("modelcontextprotocol.io/related-task") + if isinstance(candidate_meta, dict) + else None ) - for candidate in reversed(candidates): - candidate_meta = getattr(candidate.root, "_meta", None) - related_task = ( - candidate_meta.get("modelcontextprotocol.io/related-task") - if isinstance(candidate_meta, dict) - else None - ) - if ( - isinstance(related_task, dict) - and related_task.get("status") == "input_required" - ): - notification = candidate - break - if notification is not None: + if ( + isinstance(related_task, dict) + and related_task.get("status") == "input_required" + ): + notification = candidate break - await asyncio.sleep(0.05) assert notification is not None, "expected notifications/tasks/status" task_meta = getattr(notification.root, "_meta", None) @@ -100,7 +102,7 @@ class TestNotificationIntegration: related_task = task_meta.get("modelcontextprotocol.io/related-task") assert isinstance(related_task, dict) - assert related_task.get("taskId") == captured["task_id"] + assert related_task.get("taskId") == task.task_id assert related_task.get("status") == "input_required" elicitation = related_task.get("elicitation") @@ -109,25 +111,6 @@ class TestNotificationIntegration: assert isinstance(elicitation.get("requestId"), str) assert isinstance(elicitation.get("requestedSchema"), dict) - success = False - for _ in range(40): - success = await handle_task_input( - task_id=captured["task_id"], - session_id=captured["session_id"], - action="accept", - content={"value": "hello"}, - fastmcp=mcp, - ) - if success: - break - await asyncio.sleep(0.05) - - assert success is True - - await task.wait(timeout=10.0) - result = await task.result() - assert result.data == "got: hello" - async def test_subscriber_started_and_cleaned_up(self): """Subscriber starts during background task and stops when client disconnects.""" mcp = FastMCP("subscriber-test") diff --git a/tests/server/tasks/test_task_elicitation_relay.py b/tests/server/tasks/test_task_elicitation_relay.py new file mode 100644 index 000000000..42362edd2 --- /dev/null +++ b/tests/server/tasks/test_task_elicitation_relay.py @@ -0,0 +1,191 @@ +"""Tests for background task elicitation relay (notifications.py). + +The relay bridges distributed background tasks to clients via the standard +MCP elicitation/create protocol. When a worker calls ctx.elicit(), the +notification subscriber detects the input_required notification and sends +an elicitation/create request to the client session. The client's +elicitation_handler fires, and the relay pushes the response to Redis +for the blocked worker. + +These tests use Client(mcp) with the real memory:// Docket backend. +""" + +import asyncio +from dataclasses import dataclass + +from pydantic import BaseModel + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.elicitation import ElicitResult +from fastmcp.server.context import Context +from fastmcp.server.elicitation import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, +) + + +class TestElicitationRelay: + """E2E tests for elicitation flowing through the standard MCP protocol.""" + + async def test_accept_via_elicitation_handler(self): + """Tool elicits, client handler accepts, tool gets the value.""" + mcp = FastMCP("relay-accept") + + @mcp.tool(task=True) + async def ask_name(ctx: Context) -> str: + result = await ctx.elicit("What is your name?", str) + if isinstance(result, AcceptedElicitation): + return f"Hello, {result.data}!" + return "No name" + + async def handler(message, response_type, params, ctx): + assert message == "What is your name?" + return ElicitResult(action="accept", content={"value": "Alice"}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("ask_name", {}, task=True) + result = await task.result() + assert result.data == "Hello, Alice!" + + async def test_decline_via_elicitation_handler(self): + """Tool elicits, client handler declines, tool gets DeclinedElicitation.""" + mcp = FastMCP("relay-decline") + + @mcp.tool(task=True) + async def optional_input(ctx: Context) -> str: + result = await ctx.elicit("Provide a name?", str) + if isinstance(result, DeclinedElicitation): + return "User declined" + if isinstance(result, AcceptedElicitation): + return f"Got: {result.data}" + return "Cancelled" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="decline") + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("optional_input", {}, task=True) + result = await task.result() + assert result.data == "User declined" + + async def test_cancel_via_elicitation_handler(self): + """Tool elicits, client handler cancels, tool gets CancelledElicitation.""" + mcp = FastMCP("relay-cancel") + + @mcp.tool(task=True) + async def cancellable(ctx: Context) -> str: + result = await ctx.elicit("Input?", str) + if isinstance(result, CancelledElicitation): + return "Cancelled" + return "Not cancelled" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="cancel") + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("cancellable", {}, task=True) + result = await task.result() + assert result.data == "Cancelled" + + async def test_dataclass_round_trips_through_relay(self): + """Structured dataclass type round-trips through the relay.""" + mcp = FastMCP("relay-dataclass") + + @dataclass + class UserInfo: + name: str + age: int + + @mcp.tool(task=True) + async def get_user(ctx: Context) -> str: + result = await ctx.elicit("Provide user info", UserInfo) + if isinstance(result, AcceptedElicitation): + assert isinstance(result.data, UserInfo) + return f"{result.data.name} is {result.data.age}" + return "No info" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"name": "Bob", "age": 30}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("get_user", {}, task=True) + result = await task.result() + assert result.data == "Bob is 30" + + async def test_pydantic_model_round_trips_through_relay(self): + """Structured Pydantic model round-trips through the relay.""" + mcp = FastMCP("relay-pydantic") + + class Config(BaseModel): + host: str + port: int + + @mcp.tool(task=True) + async def get_config(ctx: Context) -> str: + result = await ctx.elicit("Server config?", Config) + if isinstance(result, AcceptedElicitation): + assert isinstance(result.data, Config) + return f"{result.data.host}:{result.data.port}" + return "No config" + + async def handler(message, response_type, params, ctx): + return ElicitResult( + action="accept", content={"host": "localhost", "port": 8080} + ) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("get_config", {}, task=True) + result = await task.result() + assert result.data == "localhost:8080" + + async def test_multiple_sequential_elicitations(self): + """Tool calls ctx.elicit() twice, both go through the relay.""" + mcp = FastMCP("relay-multi") + + @mcp.tool(task=True) + async def two_questions(ctx: Context) -> str: + r1 = await ctx.elicit("First name?", str) + r2 = await ctx.elicit("Last name?", str) + if isinstance(r1, AcceptedElicitation) and isinstance( + r2, AcceptedElicitation + ): + return f"{r1.data} {r2.data}" + return "Incomplete" + + call_count = 0 + + async def handler(message, response_type, params, ctx): + nonlocal call_count + call_count += 1 + if call_count == 1: + assert message == "First name?" + return ElicitResult(action="accept", content={"value": "Jane"}) + else: + assert message == "Last name?" + return ElicitResult(action="accept", content={"value": "Doe"}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("two_questions", {}, task=True) + result = await task.result() + assert result.data == "Jane Doe" + assert call_count == 2 + + async def test_no_elicitation_handler_returns_cancel(self): + """Without an elicitation_handler, the relay fails and task gets cancel.""" + mcp = FastMCP("relay-no-handler") + + @mcp.tool(task=True) + async def needs_input(ctx: Context) -> str: + result = await ctx.elicit("Input?", str) + if isinstance(result, CancelledElicitation): + return "Cancelled as expected" + if isinstance(result, AcceptedElicitation): + return f"Got: {result.data}" + return "Other" + + async with Client(mcp) as client: + task = await client.call_tool("needs_input", {}, task=True) + result = await asyncio.wait_for(task.result(), timeout=15.0) + assert result.data == "Cancelled as expected" From aa4db3d00eb76aaeb718ae437992ac4d93e6c6fe Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 10 Feb 2026 15:35:50 -0500 Subject: [PATCH 51/63] Address review: set status key on cancel fallback, prevent relay task GC Co-Authored-By: Claude Opus 4.6 --- src/fastmcp/server/tasks/notifications.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py index c47f8e484..01f049dae 100644 --- a/src/fastmcp/server/tasks/notifications.py +++ b/src/fastmcp/server/tasks/notifications.py @@ -199,11 +199,18 @@ async def _send_mcp_notification( related_task = meta.get("modelcontextprotocol.io/related-task", {}) elicitation = related_task.get("elicitation") if elicitation: - task_id = params["taskId"] - asyncio.create_task( # noqa: RUF006 + task_id = params.get("taskId") + if not task_id: + logger.warning( + "input_required notification missing taskId, skipping relay" + ) + return + task = asyncio.create_task( _relay_elicitation(session, session_id, task_id, elicitation, docket), name=f"elicitation-relay-{task_id[:8]}", ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) async def _relay_elicitation( @@ -268,12 +275,16 @@ async def _relay_elicitation( response_key = docket.key( ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) ) + status_key = docket.key( + ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) + ) cancel = {"action": "cancel", "content": None} async with docket.redis() as redis: await redis.lpush( # type: ignore[invalid-await] response_key, json.dumps(cancel) ) await redis.expire(response_key, ELICIT_TTL_SECONDS) + await redis.set(status_key, "responded", ex=ELICIT_TTL_SECONDS) except Exception as cancel_error: logger.warning( "Failed to push cancel response for task %s " @@ -287,6 +298,9 @@ async def _relay_elicitation( # Subscriber Management # ============================================================================= +# Strong references to fire-and-forget relay tasks (prevent GC mid-flight) +_background_tasks: set[asyncio.Task[None]] = set() + # Registry of active subscribers per session (prevents duplicates) # Uses weakref to session to detect disconnects _active_subscribers: dict[ From 95b4271b3b007bbab07d5e883a4f4520ebcd29b8 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:36:17 +0000 Subject: [PATCH 52/63] chore: Update SDK documentation --- docs/python-sdk/fastmcp-server-context.mdx | 74 ++++++------ .../fastmcp-server-tasks-elicitation.mdx | 6 +- .../fastmcp-server-tasks-handlers.mdx | 2 +- .../fastmcp-server-tasks-notifications.mdx | 111 ++++++++++++++++++ 4 files changed, 154 insertions(+), 39 deletions(-) create mode 100644 docs/python-sdk/fastmcp-server-tasks-notifications.mdx diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 19d67e5d6..2873100b7 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -189,12 +189,16 @@ report_progress(self, progress: float, total: float | None = None, message: str Report progress for the current operation. +Works in both foreground (MCP progress notifications) and background +(Docket task execution) contexts. + **Args:** - `progress`: Current progress value e.g. 24 - `total`: Optional total value e.g. 100 +- `message`: Optional status message describing current progress -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[SDKResource] @@ -206,7 +210,7 @@ List all available resources from the server. - List of Resource objects available on the server -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[SDKPrompt] @@ -218,7 +222,7 @@ List all available prompts from the server. - List of Prompt objects available on the server -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult @@ -234,7 +238,7 @@ Get a prompt by name with optional arguments. - The prompt result -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> ResourceResult @@ -249,7 +253,7 @@ Read a resource by URI. - ResourceResult with contents -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -267,7 +271,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien - `extra`: Optional mapping for additional arguments -#### `transport` +#### `transport` ```python transport(self) -> TransportType | None @@ -279,7 +283,7 @@ Returns the transport type used to run this server: "stdio", "sse", or "streamable-http". Returns None if called outside of a server context. -#### `client_supports_extension` +#### `client_supports_extension` ```python client_supports_extension(self, extension_id: str) -> bool @@ -304,7 +308,7 @@ Example:: return "text-only client" -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -313,7 +317,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -324,7 +328,7 @@ Get the unique ID for this request. Raises RuntimeError if MCP request context is not available. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -341,7 +345,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -355,7 +359,7 @@ In background task mode: Returns the session stored at Context creation. Raises RuntimeError if no session is available. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -366,7 +370,7 @@ Send a `DEBUG`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -377,7 +381,7 @@ Send a `INFO`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -388,7 +392,7 @@ Send a `WARNING`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -399,7 +403,7 @@ Send a `ERROR`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -408,7 +412,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_notification` +#### `send_notification` ```python send_notification(self, notification: mcp.types.ServerNotificationType) -> None @@ -420,7 +424,7 @@ Send a notification to the client immediately. - `notification`: An MCP notification instance (e.g., ToolListChangedNotification()) -#### `close_sse_stream` +#### `close_sse_stream` ```python close_sse_stream(self) -> None @@ -438,7 +442,7 @@ Instead of holding a connection open for minutes, you can periodically close and let the client reconnect. -#### `sample_step` +#### `sample_step` ```python sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -481,7 +485,7 @@ regardless of this setting. - - .text: The text content (if any) -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -490,7 +494,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: With result_type, returns SamplingResult[ResultT]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str] @@ -499,7 +503,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: Without result_type, returns SamplingResult[str]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str] @@ -547,43 +551,43 @@ regardless of this setting. - - .history: All messages exchanged during sampling -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation @@ -612,7 +616,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -625,7 +629,7 @@ The key is automatically prefixed with the session identifier. State expires after 1 day to prevent unbounded memory growth. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any @@ -636,7 +640,7 @@ Get a value from the session-scoped state store. Returns None if the key is not found. -#### `delete_state` +#### `delete_state` ```python delete_state(self, key: str) -> None @@ -645,7 +649,7 @@ delete_state(self, key: str) -> None Delete a value from the session-scoped state store. -#### `enable_components` +#### `enable_components` ```python enable_components(self) -> None @@ -669,7 +673,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `disable_components` +#### `disable_components` ```python disable_components(self) -> None @@ -693,7 +697,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `reset_visibility` +#### `reset_visibility` ```python reset_visibility(self) -> None diff --git a/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx index 1b06baa8e..f30b084ca 100644 --- a/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx @@ -13,7 +13,7 @@ in Docket workers. Unlike regular MCP requests, background tasks don't have an active request context, so elicitation requires special handling: 1. Set task status to "input_required" via Redis -2. Send notifications/tasks/updated with elicitation metadata +2. Send notifications/tasks/status with elicitation metadata 3. Wait for client to send input via tasks/sendInput 4. Resume task execution with the provided input @@ -26,7 +26,7 @@ internal APIs for background task coordination. ### `elicit_for_task` ```python -elicit_for_task(task_id: str, session: ServerSession, message: str, schema: dict[str, Any], fastmcp: FastMCP) -> mcp.types.ElicitResult +elicit_for_task(task_id: str, session: ServerSession | None, message: str, schema: dict[str, Any], fastmcp: FastMCP) -> mcp.types.ElicitResult ``` @@ -50,7 +50,7 @@ in a Docket worker context where there's no active MCP request. - `McpError`: If the elicitation request fails -### `handle_task_input` +### `handle_task_input` ```python handle_task_input(task_id: str, session_id: str, action: str, content: dict[str, Any] | None, fastmcp: FastMCP) -> bool diff --git a/docs/python-sdk/fastmcp-server-tasks-handlers.mdx b/docs/python-sdk/fastmcp-server-tasks-handlers.mdx index bee4b0b93..94e094174 100644 --- a/docs/python-sdk/fastmcp-server-tasks-handlers.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-handlers.mdx @@ -13,7 +13,7 @@ Handles queuing tool/prompt/resource executions to Docket as background tasks. ## Functions -### `submit_to_docket` +### `submit_to_docket` ```python submit_to_docket(task_type: Literal['tool', 'resource', 'template', 'prompt'], key: str, component: Tool | Resource | ResourceTemplate | Prompt, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None) -> mcp.types.CreateTaskResult diff --git a/docs/python-sdk/fastmcp-server-tasks-notifications.mdx b/docs/python-sdk/fastmcp-server-tasks-notifications.mdx new file mode 100644 index 000000000..a69f97f7b --- /dev/null +++ b/docs/python-sdk/fastmcp-server-tasks-notifications.mdx @@ -0,0 +1,111 @@ +--- +title: notifications +sidebarTitle: notifications +--- + +# `fastmcp.server.tasks.notifications` + + +Distributed notification queue for background task events (SEP-1686). + +Enables distributed Docket workers to send MCP notifications to clients +without holding session references. Workers push to a Redis queue, +the MCP server process subscribes and forwards to the client's session. + +Pattern: Fire-and-forward with retry +- One queue per session_id +- LPUSH/BRPOP for reliable ordered delivery +- Retry up to 3 times on delivery failure, then discard +- TTL-based expiration for stale messages + +Note: Docket's execution.subscribe() handles task state/progress events via +Redis Pub/Sub. This module handles elicitation-specific notifications that +require reliable delivery (input_required prompts, cancel signals). + + +## Functions + +### `push_notification` + +```python +push_notification(session_id: str, notification: dict[str, Any], docket: Docket) -> None +``` + + +Push notification to session's queue (called from Docket worker). + +Used for elicitation-specific notifications (input_required, cancel) +that need reliable delivery across distributed processes. + +**Args:** +- `session_id`: Target session's identifier +- `notification`: MCP notification dict (method, params, _meta) +- `docket`: Docket instance for Redis access + + +### `notification_subscriber_loop` + +```python +notification_subscriber_loop(session_id: str, session: ServerSession, docket: Docket) -> None +``` + + +Subscribe to notification queue and forward to session. + +Runs in the MCP server process. Bridges distributed workers to clients. + +This loop: +1. Maintains a heartbeat (active subscriber marker for debugging) +2. Blocks on BRPOP waiting for notifications +3. Forwards notifications to the client's session +4. Retries failed deliveries, then discards (no dead-letter queue) + +**Args:** +- `session_id`: Session identifier to subscribe to +- `session`: MCP ServerSession for sending notifications +- `docket`: Docket instance for Redis access + + +### `ensure_subscriber_running` + +```python +ensure_subscriber_running(session_id: str, session: ServerSession, docket: Docket) -> None +``` + + +Start notification subscriber if not already running (idempotent). + +Subscriber is created on first task submission and cleaned up on disconnect. +Safe to call multiple times for the same session. + +**Args:** +- `session_id`: Session identifier +- `session`: MCP ServerSession +- `docket`: Docket instance + + +### `stop_subscriber` + +```python +stop_subscriber(session_id: str) -> None +``` + + +Stop notification subscriber for a session. + +Called when session disconnects. Pending messages remain in queue +for delivery if client reconnects (with TTL expiration). + +**Args:** +- `session_id`: Session identifier + + +### `get_subscriber_count` + +```python +get_subscriber_count() -> int +``` + + +Get number of active subscribers (for monitoring). + From 6e229143609c8c062e4adac4e1f42282d81fdd83 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 10 Feb 2026 16:00:01 -0500 Subject: [PATCH 53/63] Move relay logic to elicitation.py, fix related-task metadata key Moves relay_elicitation() into elicitation.py so it can reuse handle_task_input() for the Redis push instead of duplicating that logic. notifications.py just detects the trigger and calls it. Also fixes the related-task metadata key from modelcontextprotocol.io/ to io.modelcontextprotocol/ to match the current spec: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks Co-Authored-By: Claude Opus 4.6 --- src/fastmcp/server/tasks/__init__.py | 7 +- src/fastmcp/server/tasks/elicitation.py | 58 ++++++++++++- src/fastmcp/server/tasks/handlers.py | 4 +- src/fastmcp/server/tasks/notifications.py | 101 ++++------------------ src/fastmcp/server/tasks/requests.py | 4 +- tests/server/tasks/test_notifications.py | 4 +- tests/server/tasks/test_task_metadata.py | 8 +- 7 files changed, 88 insertions(+), 98 deletions(-) diff --git a/src/fastmcp/server/tasks/__init__.py b/src/fastmcp/server/tasks/__init__.py index 20dd733a8..008332db5 100644 --- a/src/fastmcp/server/tasks/__init__.py +++ b/src/fastmcp/server/tasks/__init__.py @@ -5,7 +5,11 @@ This module implements protocol-level background task execution for MCP servers. from fastmcp.server.tasks.capabilities import get_task_capabilities from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode -from fastmcp.server.tasks.elicitation import elicit_for_task, handle_task_input +from fastmcp.server.tasks.elicitation import ( + elicit_for_task, + handle_task_input, + relay_elicitation, +) from fastmcp.server.tasks.keys import ( build_task_key, get_client_task_id_from_key, @@ -29,5 +33,6 @@ __all__ = [ "handle_task_input", "parse_task_key", "push_notification", + "relay_elicitation", "stop_subscriber", ] diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py index 1a85da9f8..cb148cfc7 100644 --- a/src/fastmcp/server/tasks/elicitation.py +++ b/src/fastmcp/server/tasks/elicitation.py @@ -134,7 +134,7 @@ async def elicit_for_task( "ttl": ELICIT_TTL_SECONDS * 1000, }, "_meta": { - "modelcontextprotocol.io/related-task": { + "io.modelcontextprotocol/related-task": { "taskId": task_id, "status": "input_required", "statusMessage": message, @@ -231,6 +231,62 @@ async def elicit_for_task( return mcp.types.ElicitResult(action="cancel", content=None) +async def relay_elicitation( + session: ServerSession, + session_id: str, + task_id: str, + elicitation: dict[str, Any], + fastmcp: FastMCP, +) -> None: + """Relay elicitation from a background task worker to the client. + + Called by the notification subscriber when it detects an input_required + notification with elicitation metadata. Sends a standard elicitation/create + request to the client session, then uses handle_task_input() to push the + response to Redis so the blocked worker can resume. + + Args: + session: MCP ServerSession + session_id: Session identifier + task_id: Background task ID + elicitation: Elicitation metadata (message, requestedSchema) + fastmcp: FastMCP server instance + """ + try: + result = await session.elicit( + message=elicitation["message"], + requestedSchema=elicitation["requestedSchema"], + ) + await handle_task_input( + task_id=task_id, + session_id=session_id, + action=result.action, + content=result.content, + fastmcp=fastmcp, + ) + logger.debug( + "Relayed elicitation response for task %s (action=%s)", + task_id, + result.action, + ) + except Exception as e: + logger.warning("Failed to relay elicitation for task %s: %s", task_id, e) + # Push a cancel response so the worker's BLPOP doesn't block forever + success = await handle_task_input( + task_id=task_id, + session_id=session_id, + action="cancel", + content=None, + fastmcp=fastmcp, + ) + if not success: + logger.warning( + "Failed to push cancel response for task %s " + "(worker may block until TTL)", + task_id, + ) + + async def handle_task_input( task_id: str, session_id: str, diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index 494fce87f..fa8ba3ce4 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -127,7 +127,7 @@ async def submit_to_docket( "pollInterval": poll_interval_ms, }, "_meta": { - "modelcontextprotocol.io/related-task": { + "io.modelcontextprotocol/related-task": { "taskId": server_task_id, } }, @@ -173,7 +173,7 @@ async def submit_to_docket( ) try: - await ensure_subscriber_running(session_id, ctx.session, docket) + await ensure_subscriber_running(session_id, ctx.session, docket, ctx.fastmcp) # Register cleanup callback on session exit (once per session) # This ensures subscriber is stopped when the session disconnects diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py index 01f049dae..67417bd62 100644 --- a/src/fastmcp/server/tasks/notifications.py +++ b/src/fastmcp/server/tasks/notifications.py @@ -31,6 +31,8 @@ if TYPE_CHECKING: from docket import Docket from mcp.server.session import ServerSession + from fastmcp.server.server import FastMCP + logger = logging.getLogger(__name__) # Redis key patterns @@ -75,6 +77,7 @@ async def notification_subscriber_loop( session_id: str, session: ServerSession, docket: Docket, + fastmcp: FastMCP, ) -> None: """Subscribe to notification queue and forward to session. @@ -90,6 +93,7 @@ async def notification_subscriber_loop( session_id: Session identifier to subscribe to session: MCP ServerSession for sending notifications docket: Docket instance for Redis access + fastmcp: FastMCP server instance (for elicitation relay) """ queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id)) @@ -118,7 +122,7 @@ async def notification_subscriber_loop( try: # Reconstruct and send MCP notification await _send_mcp_notification( - session, notification_dict, session_id, docket + session, notification_dict, session_id, docket, fastmcp ) logger.debug( "Delivered notification to session %s (attempt %d)", @@ -163,6 +167,7 @@ async def _send_mcp_notification( notification_dict: dict[str, Any], session_id: str, docket: Docket, + fastmcp: FastMCP, ) -> None: """Reconstruct MCP notification from dict and send to session. @@ -174,7 +179,8 @@ async def _send_mcp_notification( session: MCP ServerSession notification_dict: Notification as dict (method, params, _meta) session_id: Session identifier (for elicitation relay) - docket: Docket instance (for elicitation relay) + docket: Docket instance (for notification delivery) + fastmcp: FastMCP server instance (for elicitation relay) """ method = notification_dict.get("method", "notifications/tasks/status") if method != "notifications/tasks/status": @@ -196,7 +202,7 @@ async def _send_mcp_notification( params = notification_dict.get("params", {}) if params.get("status") == "input_required": meta = notification_dict.get("_meta", {}) - related_task = meta.get("modelcontextprotocol.io/related-task", {}) + related_task = meta.get("io.modelcontextprotocol/related-task", {}) elicitation = related_task.get("elicitation") if elicitation: task_id = params.get("taskId") @@ -205,95 +211,16 @@ async def _send_mcp_notification( "input_required notification missing taskId, skipping relay" ) return + from fastmcp.server.tasks.elicitation import relay_elicitation + task = asyncio.create_task( - _relay_elicitation(session, session_id, task_id, elicitation, docket), + relay_elicitation(session, session_id, task_id, elicitation, fastmcp), name=f"elicitation-relay-{task_id[:8]}", ) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) -async def _relay_elicitation( - session: ServerSession, - session_id: str, - task_id: str, - elicitation: dict[str, Any], - docket: Docket, -) -> None: - """Relay elicitation from a background task worker to the client. - - Sends a standard elicitation/create request to the client session, then - pushes the response to Redis so the blocked worker can resume. - - Args: - session: MCP ServerSession - session_id: Session identifier - task_id: Background task ID - elicitation: Elicitation metadata (message, requestedSchema) - docket: Docket instance for Redis access - """ - from fastmcp.server.tasks.elicitation import ( - ELICIT_RESPONSE_KEY, - ELICIT_STATUS_KEY, - ELICIT_TTL_SECONDS, - ) - - try: - result = await session.elicit( - message=elicitation["message"], - requestedSchema=elicitation["requestedSchema"], - ) - - response_key = docket.key( - ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) - ) - status_key = docket.key( - ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) - ) - - response = { - "action": result.action, - "content": result.content, - } - - async with docket.redis() as redis: - await redis.lpush( # type: ignore[invalid-await] - response_key, json.dumps(response) - ) - await redis.expire(response_key, ELICIT_TTL_SECONDS) - await redis.set(status_key, "responded", ex=ELICIT_TTL_SECONDS) - - logger.debug( - "Relayed elicitation response for task %s (action=%s)", - task_id, - result.action, - ) - except Exception as e: - logger.warning("Failed to relay elicitation for task %s: %s", task_id, e) - # Push a cancel response so the worker's BLPOP doesn't block forever - try: - response_key = docket.key( - ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) - ) - status_key = docket.key( - ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) - ) - cancel = {"action": "cancel", "content": None} - async with docket.redis() as redis: - await redis.lpush( # type: ignore[invalid-await] - response_key, json.dumps(cancel) - ) - await redis.expire(response_key, ELICIT_TTL_SECONDS) - await redis.set(status_key, "responded", ex=ELICIT_TTL_SECONDS) - except Exception as cancel_error: - logger.warning( - "Failed to push cancel response for task %s " - "(worker may block until TTL): %s", - task_id, - cancel_error, - ) - - # ============================================================================= # Subscriber Management # ============================================================================= @@ -312,6 +239,7 @@ async def ensure_subscriber_running( session_id: str, session: ServerSession, docket: Docket, + fastmcp: FastMCP, ) -> None: """Start notification subscriber if not already running (idempotent). @@ -322,6 +250,7 @@ async def ensure_subscriber_running( session_id: Session identifier session: MCP ServerSession docket: Docket instance + fastmcp: FastMCP server instance (for elicitation relay) """ # Check if subscriber already running for this session if session_id in _active_subscribers: @@ -339,7 +268,7 @@ async def ensure_subscriber_running( # Start new subscriber task task = asyncio.create_task( - notification_subscriber_loop(session_id, session, docket), + notification_subscriber_loop(session_id, session, docket, fastmcp), name=f"notification-subscriber-{session_id[:8]}", ) _active_subscribers[session_id] = (task, weakref.ref(session)) diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py index 61286d831..fae63c08d 100644 --- a/src/fastmcp/server/tasks/requests.py +++ b/src/fastmcp/server/tasks/requests.py @@ -300,7 +300,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: content=[mcp.types.TextContent(type="text", text=str(error))], isError=True, _meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field - "modelcontextprotocol.io/related-task": { + "io.modelcontextprotocol/related-task": { "taskId": client_task_id, } }, @@ -342,7 +342,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: # Build related-task metadata related_task_meta = { - "modelcontextprotocol.io/related-task": { + "io.modelcontextprotocol/related-task": { "taskId": client_task_id, } } diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py index 34b169bb5..f0e144fd4 100644 --- a/tests/server/tasks/test_notifications.py +++ b/tests/server/tasks/test_notifications.py @@ -85,7 +85,7 @@ class TestNotificationIntegration: for candidate in reversed(candidates): candidate_meta = getattr(candidate.root, "_meta", None) related_task = ( - candidate_meta.get("modelcontextprotocol.io/related-task") + candidate_meta.get("io.modelcontextprotocol/related-task") if isinstance(candidate_meta, dict) else None ) @@ -100,7 +100,7 @@ class TestNotificationIntegration: task_meta = getattr(notification.root, "_meta", None) assert isinstance(task_meta, dict) - related_task = task_meta.get("modelcontextprotocol.io/related-task") + related_task = task_meta.get("io.modelcontextprotocol/related-task") assert isinstance(related_task, dict) assert related_task.get("taskId") == task.task_id assert related_task.get("status") == "input_required" diff --git a/tests/server/tasks/test_task_metadata.py b/tests/server/tasks/test_task_metadata.py index c603ff6a6..32ce2b849 100644 --- a/tests/server/tasks/test_task_metadata.py +++ b/tests/server/tasks/test_task_metadata.py @@ -2,7 +2,7 @@ Tests for SEP-1686 related-task metadata in protocol responses. Per the spec, all task-related responses MUST include -modelcontextprotocol.io/related-task in _meta. +io.modelcontextprotocol/related-task in _meta. """ import pytest @@ -24,7 +24,7 @@ async def metadata_server(): async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP): - """tasks/get response includes modelcontextprotocol.io/related-task in _meta.""" + """tasks/get response includes io.modelcontextprotocol/related-task in _meta.""" async with Client(metadata_server) as client: # Submit a task task = await client.call_tool("test_tool", {"value": 5}, task=True) @@ -40,7 +40,7 @@ async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP async def test_tasks_result_includes_related_task_metadata(metadata_server: FastMCP): - """tasks/result response includes modelcontextprotocol.io/related-task in _meta.""" + """tasks/result response includes io.modelcontextprotocol/related-task in _meta.""" async with Client(metadata_server) as client: # Submit and complete a task task = await client.call_tool("test_tool", {"value": 7}, task=True) @@ -53,7 +53,7 @@ async def test_tasks_result_includes_related_task_metadata(metadata_server: Fast async def test_tasks_list_includes_related_task_metadata(metadata_server: FastMCP): - """tasks/list response includes modelcontextprotocol.io/related-task in _meta.""" + """tasks/list response includes io.modelcontextprotocol/related-task in _meta.""" async with Client(metadata_server) as client: # List tasks via client (which uses protocol properly) result = await client.list_tasks() From a53030a806e91d4c0ac0bff474228e52a09937b6 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:00:34 +0000 Subject: [PATCH 54/63] chore: Update SDK documentation --- .../fastmcp-server-tasks-elicitation.mdx | 24 ++++++++++++++++++- .../fastmcp-server-tasks-notifications.mdx | 16 +++++++------ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx index f30b084ca..3bdd697ef 100644 --- a/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx @@ -50,7 +50,29 @@ in a Docket worker context where there's no active MCP request. - `McpError`: If the elicitation request fails -### `handle_task_input` +### `relay_elicitation` + +```python +relay_elicitation(session: ServerSession, session_id: str, task_id: str, elicitation: dict[str, Any], fastmcp: FastMCP) -> None +``` + + +Relay elicitation from a background task worker to the client. + +Called by the notification subscriber when it detects an input_required +notification with elicitation metadata. Sends a standard elicitation/create +request to the client session, then uses handle_task_input() to push the +response to Redis so the blocked worker can resume. + +**Args:** +- `session`: MCP ServerSession +- `session_id`: Session identifier +- `task_id`: Background task ID +- `elicitation`: Elicitation metadata (message, requestedSchema) +- `fastmcp`: FastMCP server instance + + +### `handle_task_input` ```python handle_task_input(task_id: str, session_id: str, action: str, content: dict[str, Any] | None, fastmcp: FastMCP) -> bool diff --git a/docs/python-sdk/fastmcp-server-tasks-notifications.mdx b/docs/python-sdk/fastmcp-server-tasks-notifications.mdx index a69f97f7b..6652d7600 100644 --- a/docs/python-sdk/fastmcp-server-tasks-notifications.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-notifications.mdx @@ -25,7 +25,7 @@ require reliable delivery (input_required prompts, cancel signals). ## Functions -### `push_notification` +### `push_notification` ```python push_notification(session_id: str, notification: dict[str, Any], docket: Docket) -> None @@ -43,10 +43,10 @@ that need reliable delivery across distributed processes. - `docket`: Docket instance for Redis access -### `notification_subscriber_loop` +### `notification_subscriber_loop` ```python -notification_subscriber_loop(session_id: str, session: ServerSession, docket: Docket) -> None +notification_subscriber_loop(session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP) -> None ``` @@ -64,12 +64,13 @@ This loop: - `session_id`: Session identifier to subscribe to - `session`: MCP ServerSession for sending notifications - `docket`: Docket instance for Redis access +- `fastmcp`: FastMCP server instance (for elicitation relay) -### `ensure_subscriber_running` +### `ensure_subscriber_running` ```python -ensure_subscriber_running(session_id: str, session: ServerSession, docket: Docket) -> None +ensure_subscriber_running(session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP) -> None ``` @@ -82,9 +83,10 @@ Safe to call multiple times for the same session. - `session_id`: Session identifier - `session`: MCP ServerSession - `docket`: Docket instance +- `fastmcp`: FastMCP server instance (for elicitation relay) -### `stop_subscriber` +### `stop_subscriber` ```python stop_subscriber(session_id: str) -> None @@ -100,7 +102,7 @@ for delivery if client reconnects (with TTL expiration). - `session_id`: Session identifier -### `get_subscriber_count` +### `get_subscriber_count` ```python get_subscriber_count() -> int From 8e1f662d93e04da47735ff7fae4ba1cb897644ee Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Wed, 11 Feb 2026 09:03:45 -0600 Subject: [PATCH 55/63] Bump py-key-value-aio to >=0.4.0,<0.5.0 (#3143) Co-authored-by: Bill Easton Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- .../fastmcp-server-middleware-caching.mdx | 14 +++++------ pyproject.toml | 2 +- src/fastmcp/server/middleware/caching.py | 14 +++++------ uv.lock | 23 ++++--------------- 4 files changed, 19 insertions(+), 34 deletions(-) diff --git a/docs/python-sdk/fastmcp-server-middleware-caching.mdx b/docs/python-sdk/fastmcp-server-middleware-caching.mdx index 8a997b6df..8b3f691a1 100644 --- a/docs/python-sdk/fastmcp-server-middleware-caching.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-caching.mdx @@ -151,7 +151,7 @@ Notes: **Methods:** -#### `on_list_tools` +#### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] @@ -161,7 +161,7 @@ List tools from the cache, if caching is enabled, and the result is in the cache otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_list_resources` +#### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mcp.types.ListResourcesRequest], call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] @@ -171,7 +171,7 @@ List resources from the cache, if caching is enabled, and the result is in the c otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_list_prompts` +#### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mcp.types.ListPromptsRequest], call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] @@ -181,7 +181,7 @@ List prompts from the cache, if caching is enabled, and the result is in the cac otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_call_tool` +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult]) -> ToolResult @@ -191,7 +191,7 @@ Call a tool from the cache, if caching is enabled, and the result is in the cach otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_read_resource` +#### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult]) -> ResourceResult @@ -201,7 +201,7 @@ Read a resource from the cache, if caching is enabled, and the result is in the otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_get_prompt` +#### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mcp.types.GetPromptRequestParams], call_next: CallNext[mcp.types.GetPromptRequestParams, PromptResult]) -> PromptResult @@ -211,7 +211,7 @@ Get a prompt from the cache, if caching is enabled, and the result is in the cac otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `statistics` +#### `statistics` ```python statistics(self) -> ResponseCachingStatistics diff --git a/pyproject.toml b/pyproject.toml index 4c0eb22f9..57954634e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyyaml>=6.0,<7.0", "pyperclip>=1.9.0", - "py-key-value-aio[disk,keyring,memory]>=0.3.0,<0.4.0", + "py-key-value-aio[disk,keyring,memory]>=0.4.0,<0.5.0", "uvicorn>=0.35", "websockets>=15.0.1", "jsonschema-path>=0.3.4", diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index d4ac15929..670c30a44 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -243,43 +243,41 @@ class ResponseCachingMiddleware(Middleware): call_tool_settings or CallToolSettings() ) - # PydanticAdapter type signature will be fixed to accept generic aliases - # See: https://github.com/strawgate/py-key-value/pull/250 self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter( key_value=self._stats, - pydantic_model=list[Tool], # type: ignore[arg-type] + pydantic_model=list[Tool], default_collection="tools/list", ) self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter( key_value=self._stats, - pydantic_model=list[Resource], # type: ignore[arg-type] + pydantic_model=list[Resource], default_collection="resources/list", ) self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter( key_value=self._stats, - pydantic_model=list[Prompt], # type: ignore[arg-type] + pydantic_model=list[Prompt], default_collection="prompts/list", ) self._read_resource_cache: PydanticAdapter[CachableResourceResult] = ( PydanticAdapter( key_value=self._stats, - pydantic_model=CachableResourceResult, # type: ignore[arg-type] + pydantic_model=CachableResourceResult, default_collection="resources/read", ) ) self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter( key_value=self._stats, - pydantic_model=CachablePromptResult, # type: ignore[arg-type] + pydantic_model=CachablePromptResult, default_collection="prompts/get", ) self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter( key_value=self._stats, - pydantic_model=CachableToolResult, # type: ignore[arg-type] + pydantic_model=CachableToolResult, default_collection="tools/call", ) diff --git a/uv.lock b/uv.lock index 3f329e1f3..8571b60fd 100644 --- a/uv.lock +++ b/uv.lock @@ -792,7 +792,7 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.0.0" }, - { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" }, + { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.4.0,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.17.2" }, { name = "pyperclip", specifier = ">=1.9.0" }, @@ -1772,15 +1772,15 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/42/4397b26c564a7428fbb424c353fc416c5954609c149b6d629255f65e6dc9/py_key_value_aio-0.4.0.tar.gz", hash = "sha256:55be4942bf5d5a40aa9d6eae443425096fe1bec6af7571502e54240ce3597189", size = 89104, upload-time = "2026-02-10T23:05:51.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/83fb1612bfdd68ef6a47b036dd0f906f943dac10844b43242a5c39395013/py_key_value_aio-0.4.0-py3-none-any.whl", hash = "sha256:962fe40cb763b2853a8f7484e9271dcbd8bf41679f4c391e54bfee4a7ca89c84", size = 148756, upload-time = "2026-02-10T23:05:50.342Z" }, ] [package.optional-dependencies] @@ -1798,19 +1798,6 @@ redis = [ { name = "redis" }, ] -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] - [[package]] name = "pycparser" version = "3.0" From f3d33f830cc38693c700e02693fb56202e3987ed Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:16:54 -0500 Subject: [PATCH 56/63] docs: add v3.0.0rc1 section to v3-features tracking (#3145) --- docs/development/v3-notes/v3-features.mdx | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 2a286961f..3d118fab0 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -4,6 +4,72 @@ title: v3.0 Feature Tracking This document tracks major features in FastMCP v3.0 for release notes preparation. +## 3.0.0rc1 + +### Concurrent Tool Execution in Sampling + +When an LLM returns multiple tool calls in a single sampling response, they can now be executed concurrently ([#3022](https://github.com/jlowin/fastmcp/pull/3022)). Default behavior remains sequential; opt in with `tool_concurrency`. Tools can declare `sequential=True` to force sequential execution even when concurrency is enabled. + +```python +result = await context.sample( + messages="Fetch weather for NYC and LA", + tools=[fetch_weather], + tool_concurrency=0, # Unlimited parallel execution +) +``` + +### OpenAPI `validate_output` Option + +`OpenAPIProvider` and `FastMCP.from_openapi()` now accept `validate_output=False` to skip output schema validation ([#3134](https://github.com/jlowin/fastmcp/pull/3134)). Useful when backends don't conform to their own OpenAPI response schemas — structured JSON still flows through, only the strict schema checking is disabled. + +```python +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=client, + validate_output=False, +) +``` + +### Auth Token Injection and Azure OBO Dependencies + +New dependency injection for accessing the authenticated user's token directly in tool parameters ([#2918](https://github.com/jlowin/fastmcp/pull/2918)). Works with any auth provider. + +```python +from fastmcp.server.dependencies import CurrentAccessToken, TokenClaim +from fastmcp.server.auth import AccessToken + +@mcp.tool() +async def my_tool( + token: AccessToken = CurrentAccessToken, + user_id: str = TokenClaim("oid"), +): ... +``` + +For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken` and `MSALApp` dependencies that handle the On-Behalf-Of token exchange declaratively: + +```python +from fastmcp.server.auth.providers.azure import EntraOBOToken + +@mcp.tool() +async def get_emails( + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), +): + # graph_token is ready — OBO exchange happened automatically + ... +``` + +### `generate-cli` Agent Skill Generation + +`fastmcp generate-cli` now produces a `SKILL.md` alongside the CLI script ([#3115](https://github.com/jlowin/fastmcp/pull/3115)) — a Claude Code agent skill with pre-computed invocation syntax for every tool. Agents reading the skill can call tools immediately without running `--help`. On by default; pass `--no-skill` to opt out. + +### Background Task Notification Queue + +Background tasks now use a distributed Redis notification queue for reliable delivery ([#2906](https://github.com/jlowin/fastmcp/pull/2906)). Elicitation switches from polling to BLPOP (single blocking call instead of ~7,200 round-trips/hour), and notification delivery retries up to 3x with TTL-based expiration. + +### Breaking: `ui=` Renamed to `app=` + +The MCP Apps decorator parameter has been renamed from `ui=ToolUI(...)` / `ui=ResourceUI(...)` to `app=AppConfig(...)` ([#3117](https://github.com/jlowin/fastmcp/pull/3117)). `ToolUI` and `ResourceUI` are consolidated into a single `AppConfig` class. Wire format is unchanged. See the MCP Apps section under beta2 for full details. + ## 3.0.0beta2 ### CLI: `fastmcp list` and `fastmcp call` From 3e795726601bb47198a905849b7b8c925465d9db Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:19:02 -0500 Subject: [PATCH 57/63] docs: remove nonexistent MSALApp from rc1 notes (#3146) --- docs/development/v3-notes/v3-features.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 3d118fab0..690390e39 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -45,7 +45,7 @@ async def my_tool( ): ... ``` -For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken` and `MSALApp` dependencies that handle the On-Behalf-Of token exchange declaratively: +For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken`, which handles the On-Behalf-Of token exchange declaratively: ```python from fastmcp.server.auth.providers.azure import EntraOBOToken From f75cd05e502061503d24e5e5cffdac763a6f9f45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:19:38 -0500 Subject: [PATCH 58/63] chore(deps): bump cryptography (#3140) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/testing_demo/uv.lock | 105 ++++++++++++++++------------------ 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock index 1ad5e3870..8f07579f9 100644 --- a/examples/testing_demo/uv.lock +++ b/examples/testing_demo/uv.lock @@ -304,67 +304,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] From 263e0bf6e0262149269863e09a99b8de1f08bbf0 Mon Sep 17 00:00:00 2001 From: Guillaume FORTAINE Date: Wed, 11 Feb 2026 16:20:14 +0100 Subject: [PATCH 59/63] fix: snapshot access token for background tasks (#3095) (#3138) Co-authored-by: cristiangreco94 --- src/fastmcp/server/context.py | 9 ++ src/fastmcp/server/dependencies.py | 90 ++++++++++++- src/fastmcp/server/tasks/handlers.py | 13 +- .../tasks/test_context_background_task.py | 123 ++++++++++++++++++ 4 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index d65e87c43..9889aaee9 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -318,6 +318,10 @@ class Context: Returns an empty dict if no lifespan was configured or if the MCP session is not yet established. + In background tasks (Docket workers), where request_context is not + available, falls back to reading from the FastMCP server's lifespan + result directly. + Example: ```python @server.tool @@ -330,6 +334,11 @@ class Context: """ rc = self.request_context if rc is None: + # In background tasks, request_context is not available. + # Fall back to the server's lifespan result directly (#3095). + result = self.fastmcp._lifespan_result + if result is not None: + return result return {} return rc.lifespan_context diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 97cf4fbef..acd7fea19 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -9,11 +9,13 @@ from __future__ import annotations import contextlib import inspect +import logging import weakref from collections.abc import AsyncGenerator, Callable from contextlib import AsyncExitStack, asynccontextmanager -from contextvars import ContextVar +from contextvars import ContextVar, Token from dataclasses import dataclass +from datetime import datetime, timezone from functools import lru_cache from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable @@ -33,6 +35,8 @@ from fastmcp.server.http import _current_http_request from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type +_logger = logging.getLogger(__name__) + if TYPE_CHECKING: from docket import Docket from docket.worker import Worker @@ -166,6 +170,9 @@ _current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( ) _current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) _current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) +_task_access_token: ContextVar[AccessToken | None] = ContextVar( + "task_access_token", default=None +) # --- Docket availability check --- @@ -479,7 +486,8 @@ def get_access_token() -> AccessToken | None: This function first tries to get the token from the current HTTP request's scope, which is more reliable for long-lived connections where the SDK's auth_context_var may become stale after token refresh. Falls back to the SDK's context var if no - request is available. + request is available. In background tasks (Docket workers), falls back to the + token snapshot stored in Redis at task submission time. Returns: The access token if an authenticated user is available, None otherwise. @@ -502,6 +510,19 @@ def get_access_token() -> AccessToken | None: if access_token is None: access_token = _sdk_get_access_token() + # Fall back to background task snapshot (#3095) + # In Docket workers, neither HTTP request nor SDK context var are available. + # The token was snapshotted in Redis at submit_to_docket() time and restored + # into this ContextVar by _CurrentContext.__aenter__(). + if access_token is None: + task_token = _task_access_token.get() + if task_token is not None: + # Check expiration: if expires_at is set and past, treat as expired + if task_token.expires_at is not None: + if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()): + return None + return task_token + if access_token is None or isinstance(access_token, AccessToken): return access_token @@ -719,14 +740,49 @@ async def resolve_dependencies( # so that get_dependency_parameters can detect them. +async def _restore_task_access_token( + session_id: str, task_id: str +) -> Token[AccessToken | None] | None: + """Restore the access token snapshot from Redis into a ContextVar. + + Called when setting up context in a Docket worker. The token was stored at + submit_to_docket() time. The token is restored regardless of expiration; + get_access_token() checks expiry when reading from the ContextVar. + + Returns: + The ContextVar token for resetting, or None if nothing was restored. + """ + docket = _current_docket.get() + if docket is None: + return None + + token_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:access_token") + try: + async with docket.redis() as redis: + token_data = await redis.get(token_key) + if token_data is not None: + restored = AccessToken.model_validate_json(token_data) + return _task_access_token.set(restored) + except Exception: + _logger.warning( + "Failed to restore access token for task %s:%s", + session_id, + task_id, + exc_info=True, + ) + return None + + class _CurrentContext(Dependency): # type: ignore[misc] """Async context manager for Context dependency. In foreground (request) mode: returns the active context from _current_context. - In background (Docket worker) mode: creates a task-aware Context with task_id. + In background (Docket worker) mode: creates a task-aware Context with task_id + and restores the access token snapshot from Redis. """ _context: Context | None = None + _access_token_cv_token: Token[AccessToken | None] | None = None async def __aenter__(self) -> Context: from fastmcp.server.context import Context, _current_context @@ -751,6 +807,12 @@ class _CurrentContext(Dependency): # type: ignore[misc] ) # Enter the context to set up ContextVars await self._context.__aenter__() + + # Restore access token snapshot from Redis (#3095) + self._access_token_cv_token = await _restore_task_access_token( + task_info.session_id, task_info.task_id + ) + return self._context # Neither foreground nor background context available @@ -762,6 +824,10 @@ class _CurrentContext(Dependency): # type: ignore[misc] ) async def __aexit__(self, *args: object) -> None: + # Clean up access token ContextVar + if self._access_token_cv_token is not None: + _task_access_token.reset(self._access_token_cv_token) + self._access_token_cv_token = None # Clean up if we created a context for background task if self._context is not None: await self._context.__aexit__(*args) @@ -1130,8 +1196,22 @@ class Progress(Dependency): # type: ignore[misc] class _CurrentAccessToken(Dependency): # type: ignore[misc] """Async context manager for AccessToken dependency.""" + _access_token_cv_token: Token[AccessToken | None] | None = None + async def __aenter__(self) -> AccessToken: token = get_access_token() + + # If no token found and we're in a Docket worker, try restoring from + # Redis. This handles the case where ctx: Context is not in the + # function signature, so _CurrentContext never ran the restoration. + if token is None: + task_info = get_task_context() + if task_info is not None: + self._access_token_cv_token = await _restore_task_access_token( + task_info.session_id, task_info.task_id + ) + token = get_access_token() + if token is None: raise RuntimeError( "No access token found. Ensure authentication is configured " @@ -1140,7 +1220,9 @@ class _CurrentAccessToken(Dependency): # type: ignore[misc] return token async def __aexit__(self, *args: object) -> None: - pass + if self._access_token_cv_token is not None: + _task_access_token.reset(self._access_token_cv_token) + self._access_token_cv_token = None def CurrentAccessToken() -> AccessToken: diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index fa8ba3ce4..be7bddd61 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -14,7 +14,7 @@ import mcp.types from mcp.shared.exceptions import McpError from mcp.types import INTERNAL_ERROR, ErrorData -from fastmcp.server.dependencies import _current_docket, get_context +from fastmcp.server.dependencies import _current_docket, get_access_token, get_context from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.tasks.keys import build_task_key from fastmcp.utilities.logging import get_logger @@ -99,10 +99,21 @@ async def submit_to_docket( f"fastmcp:task:{session_id}:{server_task_id}:poll_interval" ) poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000) + + # Snapshot the current access token (if any) for background task access (#3095) + access_token = get_access_token() + access_token_key = docket.key( + f"fastmcp:task:{session_id}:{server_task_id}:access_token" + ) + async with docket.redis() as redis: await redis.set(task_meta_key, task_key, ex=ttl_seconds) await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds) await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds) + if access_token is not None: + await redis.set( + access_token_key, access_token.model_dump_json(), ex=ttl_seconds + ) # Register session for Context access in background workers (SEP-1686) # This enables elicitation/sampling from background tasks via weakref diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py index 2b5a1efa9..c7eb9e90c 100644 --- a/tests/server/tasks/test_context_background_task.py +++ b/tests/server/tasks/test_context_background_task.py @@ -14,7 +14,9 @@ from mcp import ServerSession from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.elicitation import ElicitResult +from fastmcp.server.auth import AccessToken from fastmcp.server.context import Context +from fastmcp.server.dependencies import get_access_token from fastmcp.server.elicitation import AcceptedElicitation, DeclinedElicitation from fastmcp.server.tasks.elicitation import handle_task_input @@ -317,3 +319,124 @@ class TestBackgroundTaskIntegration: fastmcp=mcp, ) assert success is False + + +class TestAccessTokenInBackgroundTasks: + """Tests for access token availability in background tasks (#3095). + + Integration tests use Client(mcp) with the real memory:// Docket backend. + The token snapshot/restore round-trip flows through actual Redis (fakeredis). + + Note: async tests run in isolated asyncio tasks, so ContextVar changes + are automatically scoped — no cleanup required. + """ + + async def test_token_round_trips_through_background_task(self): + """E2E: token set at submit time is available inside the worker.""" + from mcp.server.auth.middleware.auth_context import auth_context_var + from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + + mcp = FastMCP("token-roundtrip") + + @mcp.tool(task=True) + async def check_token(ctx: Context) -> str: + token = get_access_token() + if token is None: + return "no-token" + return f"{token.token}|{token.client_id}" + + test_token = AccessToken( + token="roundtrip-jwt", + client_id="test-client", + scopes=["read"], + claims={"sub": "user-1"}, + ) + auth_context_var.set(AuthenticatedUser(test_token)) + + async with Client(mcp) as client: + task = await client.call_tool("check_token", {}, task=True) + result = await task.result() + assert result.data == "roundtrip-jwt|test-client" + + async def test_no_token_when_unauthenticated(self): + """E2E: background task gets no token when nothing was set.""" + mcp = FastMCP("no-auth") + + @mcp.tool(task=True) + async def check_token(ctx: Context) -> str: + token = get_access_token() + return "no-token" if token is None else token.token + + async with Client(mcp) as client: + task = await client.call_tool("check_token", {}, task=True) + result = await task.result() + assert result.data == "no-token" + + async def test_expired_token_returns_none(self): + """get_access_token() returns None when task token has expired.""" + from datetime import datetime, timezone + + from fastmcp.server.dependencies import _task_access_token + + expired = AccessToken( + token="expired-jwt", + client_id="test-client", + scopes=["read"], + expires_at=int(datetime.now(timezone.utc).timestamp()) - 3600, + ) + _task_access_token.set(expired) + assert get_access_token() is None + + async def test_valid_token_with_future_expiry(self): + """get_access_token() returns token when expiry is in the future.""" + from datetime import datetime, timezone + + from fastmcp.server.dependencies import _task_access_token + + valid = AccessToken( + token="valid-jwt", + client_id="test-client", + scopes=["read"], + expires_at=int(datetime.now(timezone.utc).timestamp()) + 3600, + ) + _task_access_token.set(valid) + result = get_access_token() + assert result is not None + assert result.token == "valid-jwt" + + async def test_token_without_expiry_always_valid(self): + """get_access_token() returns token when no expires_at is set.""" + from fastmcp.server.dependencies import _task_access_token + + no_expiry = AccessToken( + token="eternal-jwt", + client_id="test-client", + scopes=["read"], + ) + _task_access_token.set(no_expiry) + result = get_access_token() + assert result is not None + assert result.token == "eternal-jwt" + + +class TestLifespanContextInBackgroundTasks: + """Tests for lifespan_context availability in background tasks (#3095).""" + + def test_lifespan_context_falls_back_to_server_result(self): + """lifespan_context reads from server when request_context is None.""" + mcp = FastMCP("test") + mcp._lifespan_result = {"db": "mock-db-connection", "cache": "mock-cache"} + + ctx = Context(mcp, task_id="test-task") + assert ctx.request_context is None + assert ctx.lifespan_context == { + "db": "mock-db-connection", + "cache": "mock-cache", + } + + def test_lifespan_context_returns_empty_dict_when_no_lifespan(self): + """lifespan_context returns {} when no lifespan is configured.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task") + assert ctx.request_context is None + assert ctx.lifespan_context == {} From 0f95ed72cdec1f0959eda2f9d2e1fe5db163e0ee Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:34:11 -0500 Subject: [PATCH 60/63] Stop duplicating path parameter descriptions into tool prose (#3149) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/python-sdk/fastmcp-server-context.mdx | 76 +++++++++--------- .../fastmcp-server-dependencies.mdx | 77 +++++++++--------- ...tmcp-server-providers-openapi-provider.mdx | 6 +- .../fastmcp-utilities-openapi-formatters.mdx | 21 +---- .../utilities/openapi/__init__.py | 2 - .../server/providers/openapi/provider.py | 22 +----- src/fastmcp/utilities/openapi/__init__.py | 2 - src/fastmcp/utilities/openapi/formatters.py | 34 -------- tests/server/test_tool_transformation.py | 78 ++++++++++++++++++- 9 files changed, 163 insertions(+), 155 deletions(-) diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 2873100b7..a87096234 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -170,6 +170,10 @@ Returns the context dict yielded by the server's lifespan function. Returns an empty dict if no lifespan was configured or if the MCP session is not yet established. +In background tasks (Docket workers), where request_context is not +available, falls back to reading from the FastMCP server's lifespan +result directly. + Example: ```python @server.tool @@ -181,7 +185,7 @@ def my_tool(ctx: Context) -> str: ``` -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -198,7 +202,7 @@ Works in both foreground (MCP progress notifications) and background - `message`: Optional status message describing current progress -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[SDKResource] @@ -210,7 +214,7 @@ List all available resources from the server. - List of Resource objects available on the server -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[SDKPrompt] @@ -222,7 +226,7 @@ List all available prompts from the server. - List of Prompt objects available on the server -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult @@ -238,7 +242,7 @@ Get a prompt by name with optional arguments. - The prompt result -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> ResourceResult @@ -253,7 +257,7 @@ Read a resource by URI. - ResourceResult with contents -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -271,7 +275,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien - `extra`: Optional mapping for additional arguments -#### `transport` +#### `transport` ```python transport(self) -> TransportType | None @@ -283,7 +287,7 @@ Returns the transport type used to run this server: "stdio", "sse", or "streamable-http". Returns None if called outside of a server context. -#### `client_supports_extension` +#### `client_supports_extension` ```python client_supports_extension(self, extension_id: str) -> bool @@ -308,7 +312,7 @@ Example:: return "text-only client" -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -317,7 +321,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -328,7 +332,7 @@ Get the unique ID for this request. Raises RuntimeError if MCP request context is not available. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -345,7 +349,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -359,7 +363,7 @@ In background task mode: Returns the session stored at Context creation. Raises RuntimeError if no session is available. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -370,7 +374,7 @@ Send a `DEBUG`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -381,7 +385,7 @@ Send a `INFO`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -392,7 +396,7 @@ Send a `WARNING`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -403,7 +407,7 @@ Send a `ERROR`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -412,7 +416,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_notification` +#### `send_notification` ```python send_notification(self, notification: mcp.types.ServerNotificationType) -> None @@ -424,7 +428,7 @@ Send a notification to the client immediately. - `notification`: An MCP notification instance (e.g., ToolListChangedNotification()) -#### `close_sse_stream` +#### `close_sse_stream` ```python close_sse_stream(self) -> None @@ -442,7 +446,7 @@ Instead of holding a connection open for minutes, you can periodically close and let the client reconnect. -#### `sample_step` +#### `sample_step` ```python sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -485,7 +489,7 @@ regardless of this setting. - - .text: The text content (if any) -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -494,7 +498,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: With result_type, returns SamplingResult[ResultT]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str] @@ -503,7 +507,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: Without result_type, returns SamplingResult[str]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str] @@ -551,43 +555,43 @@ regardless of this setting. - - .history: All messages exchanged during sampling -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation @@ -616,7 +620,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -629,7 +633,7 @@ The key is automatically prefixed with the session identifier. State expires after 1 day to prevent unbounded memory growth. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any @@ -640,7 +644,7 @@ Get a value from the session-scoped state store. Returns None if the key is not found. -#### `delete_state` +#### `delete_state` ```python delete_state(self, key: str) -> None @@ -649,7 +653,7 @@ delete_state(self, key: str) -> None Delete a value from the session-scoped state store. -#### `enable_components` +#### `enable_components` ```python enable_components(self) -> None @@ -673,7 +677,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `disable_components` +#### `disable_components` ```python disable_components(self) -> None @@ -697,7 +701,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `reset_visibility` +#### `reset_visibility` ```python reset_visibility(self) -> None diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index 496a19e8c..5660ac13a 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -15,7 +15,7 @@ CurrentWorker) and background task execution require fastmcp[tasks]. ## Functions -### `get_task_context` +### `get_task_context` ```python get_task_context() -> TaskContextInfo | None @@ -31,7 +31,7 @@ Returns None if not running in a task context (e.g., foreground execution). - TaskContextInfo with task_id and session_id, or None if not in a task. -### `register_task_session` +### `register_task_session` ```python register_task_session(session_id: str, session: ServerSession) -> None @@ -49,7 +49,7 @@ client disconnects. - `session`: The ServerSession instance -### `get_task_session` +### `get_task_session` ```python get_task_session(session_id: str) -> ServerSession | None @@ -65,7 +65,7 @@ Get a registered session by ID if still alive. - The ServerSession if found and alive, None otherwise -### `is_docket_available` +### `is_docket_available` ```python is_docket_available() -> bool @@ -75,7 +75,7 @@ is_docket_available() -> bool Check if pydocket is installed. -### `require_docket` +### `require_docket` ```python require_docket(feature: str) -> None @@ -89,7 +89,7 @@ Raise ImportError with install instructions if docket not available. "CurrentDocket()"). Will be included in the error message. -### `transform_context_annotations` +### `transform_context_annotations` ```python transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] @@ -115,7 +115,7 @@ allows them to have defaults in any order. - Function with modified signature (same function object, updated __signature__) -### `get_context` +### `get_context` ```python get_context() -> Context @@ -125,7 +125,7 @@ get_context() -> Context Get the current FastMCP Context instance directly. -### `get_server` +### `get_server` ```python get_server() -> FastMCP @@ -141,7 +141,7 @@ Get the current FastMCP server instance directly. - `RuntimeError`: If no server in context -### `get_http_request` +### `get_http_request` ```python get_http_request() -> Request @@ -153,7 +153,7 @@ Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. -### `get_http_headers` +### `get_http_headers` ```python get_http_headers(include_all: bool = False) -> dict[str, str] @@ -169,7 +169,7 @@ By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients. If `include_all` is True, all headers are returned. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -181,13 +181,14 @@ Get the FastMCP access token from the current context. This function first tries to get the token from the current HTTP request's scope, which is more reliable for long-lived connections where the SDK's auth_context_var may become stale after token refresh. Falls back to the SDK's context var if no -request is available. +request is available. In background tasks (Docket workers), falls back to the +token snapshot stored in Redis at task submission time. **Returns:** - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -212,7 +213,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -238,7 +239,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -257,7 +258,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -277,7 +278,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -297,7 +298,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -315,7 +316,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -335,7 +336,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -352,7 +353,7 @@ safe to use in code that might run over any transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -371,7 +372,7 @@ authenticated request. Raises an error if no authentication is present. - `RuntimeError`: If no authenticated user (use get_access_token() for optional) -### `TokenClaim` +### `TokenClaim` ```python TokenClaim(name: str) -> str @@ -396,7 +397,7 @@ without needing the full token object. ## Classes -### `TaskContextInfo` +### `TaskContextInfo` Information about the current background task context. @@ -405,7 +406,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -416,7 +417,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -425,7 +426,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -434,7 +435,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -443,7 +444,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -452,7 +453,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -461,7 +462,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -470,7 +471,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -482,25 +483,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -509,7 +510,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -518,7 +519,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -527,7 +528,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx index 69c2b0dae..ba3d7918b 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx @@ -10,7 +10,7 @@ OpenAPIProvider for creating MCP components from OpenAPI specifications. ## Classes -### `OpenAPIProvider` +### `OpenAPIProvider` Provider that creates MCP components from an OpenAPI specification. @@ -21,7 +21,7 @@ spec. Each component makes HTTP calls to the described API endpoints. **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -30,7 +30,7 @@ lifespan(self) -> AsyncIterator[None] Manage the lifecycle of the auto-created httpx client. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] diff --git a/docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx b/docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx index 1b725fc5a..0b9d12369 100644 --- a/docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx +++ b/docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx @@ -72,26 +72,7 @@ format_json_for_description(data: Any, indent: int = 2) -> str Formats Python data as a JSON string block for Markdown. -### `format_simple_description` - -```python -format_simple_description(base_description: str, parameters: list[ParameterInfo] | None = None, request_body: RequestBodyInfo | None = None) -> str -``` - - -Formats a simple description for MCP objects (tools, resources, prompts). -Excludes response details, examples, and verbose status codes. - -**Args:** -- `base_description`: The initial description to be formatted. -- `parameters`: A list of parameter information. -- `request_body`: Information about the request body. - -**Returns:** -- The formatted description string with minimal details. - - -### `format_description_with_responses` +### `format_description_with_responses` ```python format_description_with_responses(base_description: str, responses: dict[str, Any], parameters: list[ParameterInfo] | None = None, request_body: RequestBodyInfo | None = None) -> str diff --git a/src/fastmcp/experimental/utilities/openapi/__init__.py b/src/fastmcp/experimental/utilities/openapi/__init__.py index 51c947bf1..0c8dd15e5 100644 --- a/src/fastmcp/experimental/utilities/openapi/__init__.py +++ b/src/fastmcp/experimental/utilities/openapi/__init__.py @@ -10,7 +10,6 @@ from fastmcp.utilities.openapi import ( RequestBodyInfo, ResponseInfo, extract_output_schema_from_responses, - format_simple_description, parse_openapi_to_http_routes, _combine_schemas, ) @@ -32,6 +31,5 @@ __all__ = [ "ResponseInfo", "_combine_schemas", "extract_output_schema_from_responses", - "format_simple_description", "parse_openapi_to_http_routes", ] diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index 68979c0c1..975e0228b 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -34,7 +34,6 @@ from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import ( HTTPRoute, extract_output_schema_from_responses, - format_simple_description, parse_openapi_to_http_routes, ) from fastmcp.utilities.openapi.director import RequestDirector @@ -255,18 +254,13 @@ class OpenAPIProvider(Provider): or route.summary or f"Executes {route.method} {route.path}" ) - enhanced_description = format_simple_description( - base_description=base_description, - parameters=route.parameters, - request_body=route.request_body, - ) tool = OpenAPITool( client=self._client, route=route, director=self._director, name=tool_name, - description=enhanced_description, + description=base_description, parameters=combined_schema, output_schema=output_schema, tags=set(route.tags or []) | tags, @@ -293,11 +287,6 @@ class OpenAPIProvider(Provider): base_description = ( route.description or route.summary or f"Represents {route.path}" ) - enhanced_description = format_simple_description( - base_description=base_description, - parameters=route.parameters, - request_body=route.request_body, - ) resource = OpenAPIResource( client=self._client, @@ -305,7 +294,7 @@ class OpenAPIProvider(Provider): director=self._director, uri=resource_uri, name=resource_name, - description=enhanced_description, + description=base_description, mime_type=_extract_mime_type_from_route(route), tags=set(route.tags or []) | tags, ) @@ -338,11 +327,6 @@ class OpenAPIProvider(Provider): base_description = ( route.description or route.summary or f"Template for {route.path}" ) - enhanced_description = format_simple_description( - base_description=base_description, - parameters=route.parameters, - request_body=route.request_body, - ) template_params_schema = { "type": "object", @@ -372,7 +356,7 @@ class OpenAPIProvider(Provider): director=self._director, uri_template=uri_template_str, name=template_name, - description=enhanced_description, + description=base_description, parameters=template_params_schema, tags=set(route.tags or []) | tags, mime_type=_extract_mime_type_from_route(route), diff --git a/src/fastmcp/utilities/openapi/__init__.py b/src/fastmcp/utilities/openapi/__init__.py index f71bc7a6a..eb25666d1 100644 --- a/src/fastmcp/utilities/openapi/__init__.py +++ b/src/fastmcp/utilities/openapi/__init__.py @@ -20,7 +20,6 @@ from .formatters import ( format_deep_object_parameter, format_description_with_responses, format_json_for_description, - format_simple_description, generate_example_from_schema, ) @@ -57,7 +56,6 @@ __all__ = [ "format_deep_object_parameter", "format_description_with_responses", "format_json_for_description", - "format_simple_description", "generate_example_from_schema", "parse_openapi_to_http_routes", ] diff --git a/src/fastmcp/utilities/openapi/formatters.py b/src/fastmcp/utilities/openapi/formatters.py index 27580fcdd..a0bd75bef 100644 --- a/src/fastmcp/utilities/openapi/formatters.py +++ b/src/fastmcp/utilities/openapi/formatters.py @@ -189,39 +189,6 @@ def format_json_for_description(data: Any, indent: int = 2) -> str: return f"```\nCould not serialize to JSON: {data}\n```" -def format_simple_description( - base_description: str, - parameters: list[ParameterInfo] | None = None, - request_body: RequestBodyInfo | None = None, -) -> str: - """ - Formats a simple description for MCP objects (tools, resources, prompts). - Excludes response details, examples, and verbose status codes. - - Args: - base_description (str): The initial description to be formatted. - parameters (list[ParameterInfo] | None, optional): A list of parameter information. - request_body (RequestBodyInfo | None, optional): Information about the request body. - - Returns: - str: The formatted description string with minimal details. - """ - desc_parts = [base_description] - - # Only add critical parameter information if they have descriptions - if parameters: - path_params = [p for p in parameters if p.location == "path" and p.description] - if path_params: - desc_parts.append("\n\n**Path Parameters:**") - for param in path_params: - desc_parts.append(f"\n- **{param.name}**: {param.description}") - - # Skip query parameters, request body details, and all response information - # These are already captured in the inputSchema - - return "\n".join(desc_parts) - - def format_description_with_responses( base_description: str, responses: dict[ @@ -384,6 +351,5 @@ __all__ = [ "format_deep_object_parameter", "format_description_with_responses", "format_json_for_description", - "format_simple_description", "generate_example_from_schema", ] diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py index 4f9833bdf..4cf938822 100644 --- a/tests/server/test_tool_transformation.py +++ b/tests/server/test_tool_transformation.py @@ -1,6 +1,12 @@ +import httpx + from fastmcp import FastMCP +from fastmcp.client import Client from fastmcp.server.transforms import ToolTransform -from fastmcp.tools.tool_transform import ToolTransformConfig +from fastmcp.tools.tool_transform import ( + ArgTransformConfig, + ToolTransformConfig, +) async def test_tool_transformation_via_layer(): @@ -207,3 +213,73 @@ async def test_tool_transform_config_enabled_true_overrides_earlier_disable(): # Tool should now be visible assert "my_tool" in tool_names + + +async def test_openapi_path_params_not_duplicated_in_description(): + """Path parameter details should live in inputSchema, not the description. + + Regression test for https://github.com/jlowin/fastmcp/issues/3130 — hiding + a path param via ToolTransform left stale references in the description + because the description was generated before transforms ran. The fix is to + keep parameter docs in inputSchema only, where transforms can control them. + """ + spec = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "0.1.0"}, + "paths": { + "/api/{version}/users/{user_id}": { + "get": { + "operationId": "my_endpoint", + "summary": "My endpoint", + "parameters": [ + { + "name": "version", + "in": "path", + "required": True, + "description": "API version", + "schema": {"type": "string"}, + }, + { + "name": "user_id", + "in": "path", + "required": True, + "description": "The user ID", + "schema": {"type": "string"}, + }, + ], + "responses": {"200": {"description": "OK"}}, + }, + }, + }, + } + + async with httpx.AsyncClient(base_url="http://localhost") as http_client: + mcp = FastMCP.from_openapi(openapi_spec=spec, client=http_client) + + # Hide one of the two path params + mcp.add_transform( + ToolTransform( + { + "my_endpoint": ToolTransformConfig( + arguments={ + "version": ArgTransformConfig(hide=True, default="v1"), + } + ) + } + ) + ) + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = tools[0] + + # Description should be the summary only — no parameter details + assert tool.description == "My endpoint" + + # Hidden param gone from schema, visible param still present + assert "version" not in tool.inputSchema.get("properties", {}) + assert "user_id" in tool.inputSchema["properties"] + assert ( + tool.inputSchema["properties"]["user_id"]["description"] + == "The user ID" + ) From 25e2f4da32bd2ceb4c1d866d3dedc4a0906df4be Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:39:34 -0500 Subject: [PATCH 61/63] Remove deprecated FastMCP() constructor kwargs (#3148) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/development/v3-notes/v3-features.mdx | 56 +-- docs/python-sdk/fastmcp-mcp_config.mdx | 32 +- .../fastmcp-server-mixins-transport.mdx | 2 +- docs/python-sdk/fastmcp-server-server.mdx | 108 +++--- docs/python-sdk/fastmcp-settings.mdx | 12 +- src/fastmcp/mcp_config.py | 7 +- src/fastmcp/server/mixins/transport.py | 25 +- src/fastmcp/server/server.py | 200 ++-------- src/fastmcp/settings.py | 27 -- .../server/test_include_exclude_tags.py | 69 +--- .../test_add_tool_transformation.py | 35 +- tests/deprecated/test_deprecated.py | 49 +-- tests/deprecated/test_openapi_deprecations.py | 25 -- tests/deprecated/test_settings.py | 361 +++--------------- tests/deprecated/test_tool_serializer.py | 9 +- tests/server/mount/test_filtering.py | 12 +- .../local_provider_tools/test_tags.py | 7 +- .../providers/test_local_provider_prompts.py | 7 +- .../test_local_provider_resources.py | 14 +- tests/server/test_server.py | 4 +- tests/utilities/test_inspect.py | 12 +- 21 files changed, 279 insertions(+), 794 deletions(-) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 690390e39..93420c6b8 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -66,6 +66,32 @@ async def get_emails( Background tasks now use a distributed Redis notification queue for reliable delivery ([#2906](https://github.com/jlowin/fastmcp/pull/2906)). Elicitation switches from polling to BLPOP (single blocking call instead of ~7,200 round-trips/hour), and notification delivery retries up to 3x with TTL-based expiration. +### Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed + +Sixteen deprecated keyword arguments have been removed from `FastMCP.__init__`. Passing any of them now raises `TypeError` with a migration hint. Environment variables (e.g., `FASTMCP_HOST`) continue to work — only the constructor kwargs moved. + +**Transport/server settings** (`host`, `port`, `log_level`, `debug`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, `stateless_http`): Pass to `run()`, `run_http_async()`, or `http_app()` as appropriate, or set via environment variables. + +```python +# Before +mcp = FastMCP("server", host="0.0.0.0", port=8080) +mcp.run() + +# After +mcp = FastMCP("server") +mcp.run(transport="http", host="0.0.0.0", port=8080) +``` + +**Duplicate handling** (`on_duplicate_tools`, `on_duplicate_resources`, `on_duplicate_prompts`): Use the unified `on_duplicate=` parameter. + +**Tag filtering** (`include_tags`, `exclude_tags`): Use `server.enable(tags=..., only=True)` and `server.disable(tags=...)` after construction. + +**Tool serializer** (`tool_serializer`): Return `ToolResult` from tools instead. + +**Tool transformations** (`tool_transformations`): Use `server.add_transform(ToolTransform(...))` after construction. + +The `_deprecated_settings` attribute and `.settings` property are also removed. `ExperimentalSettings` has been deleted (dead code). + ### Breaking: `ui=` Renamed to `app=` The MCP Apps decorator parameter has been renamed from `ui=ToolUI(...)` / `ui=ResourceUI(...)` to `app=AppConfig(...)` ([#3117](https://github.com/jlowin/fastmcp/pull/3117)). `ToolUI` and `ResourceUI` are consolidated into a single `AppConfig` class. Wire format is unchanged. See the MCP Apps section under beta2 for full details. @@ -1266,35 +1292,9 @@ main.mount(subserver, prefix="api") main.mount(subserver, namespace="api") ``` -#### Tag Filtering Init Parameters +#### Tag Filtering, Tool Serializer, Tool Transformations Init Parameters -`FastMCP(include_tags=..., exclude_tags=...)` deprecated. Use `enable()`/`disable()` methods: - -```python -# Deprecated -mcp = FastMCP("server", exclude_tags={"internal"}) - -# New -mcp = FastMCP("server") -mcp.disable(tags={"internal"}) -``` - -#### Tool Serializer Parameter - -The `tool_serializer` parameter on `FastMCP` is deprecated. Return `ToolResult` for explicit serialization control. - -#### Tool Transformation Methods - -`add_tool_transformation()`, `remove_tool_transformation()`, and `tool_transformations` constructor parameter are deprecated. Use `add_transform(ToolTransform({...}))` instead: - -```python -# Deprecated -mcp.add_tool_transformation("name", config) - -# New -from fastmcp.server.transforms import ToolTransform -mcp.add_transform(ToolTransform({"name": config})) -``` +These constructor parameters have been **removed** (not just deprecated) as of rc1. See "Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed" in the rc1 section above. The `add_tool_transformation()` and `remove_tool_transformation()` methods remain as deprecated shims. --- diff --git a/docs/python-sdk/fastmcp-mcp_config.mdx b/docs/python-sdk/fastmcp-mcp_config.mdx index 87ed568a0..71046dd4a 100644 --- a/docs/python-sdk/fastmcp-mcp_config.mdx +++ b/docs/python-sdk/fastmcp-mcp_config.mdx @@ -42,7 +42,7 @@ infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] Infer the appropriate transport type from the given URL. -### `update_config_file` +### `update_config_file` ```python update_config_file(file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes) -> None @@ -57,7 +57,7 @@ worry about transforming server objects here. ## Classes -### `StdioMCPServer` +### `StdioMCPServer` MCP server configuration for stdio transport. @@ -67,19 +67,19 @@ This is the canonical configuration format for MCP servers using stdio transport **Methods:** -#### `to_transport` +#### `to_transport` ```python to_transport(self) -> StdioTransport ``` -### `TransformingStdioMCPServer` +### `TransformingStdioMCPServer` A Stdio server with tool transforms. -### `RemoteMCPServer` +### `RemoteMCPServer` MCP server configuration for HTTP/SSE transport. @@ -89,19 +89,19 @@ This is the canonical configuration format for MCP servers using remote transpor **Methods:** -#### `to_transport` +#### `to_transport` ```python to_transport(self) -> StreamableHttpTransport | SSETransport ``` -### `TransformingRemoteMCPServer` +### `TransformingRemoteMCPServer` A Remote server with tool transforms. -### `MCPConfig` +### `MCPConfig` A configuration object for MCP Servers that conforms to the canonical MCP configuration format @@ -113,7 +113,7 @@ For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class. **Methods:** -#### `wrap_servers_at_root` +#### `wrap_servers_at_root` ```python wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] @@ -122,7 +122,7 @@ wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] If there's no mcpServers key but there are server configs at root, wrap them. -#### `add_server` +#### `add_server` ```python add_server(self, name: str, server: MCPServerTypes) -> None @@ -131,7 +131,7 @@ add_server(self, name: str, server: MCPServerTypes) -> None Add or update a server in the configuration. -#### `from_dict` +#### `from_dict` ```python from_dict(cls, config: dict[str, Any]) -> Self @@ -140,7 +140,7 @@ from_dict(cls, config: dict[str, Any]) -> Self Parse MCP configuration from dictionary format. -#### `to_dict` +#### `to_dict` ```python to_dict(self) -> dict[str, Any] @@ -149,7 +149,7 @@ to_dict(self) -> dict[str, Any] Convert MCPConfig to dictionary format, preserving all fields. -#### `write_to_file` +#### `write_to_file` ```python write_to_file(self, file_path: Path) -> None @@ -158,7 +158,7 @@ write_to_file(self, file_path: Path) -> None Write configuration to JSON file. -#### `from_file` +#### `from_file` ```python from_file(cls, file_path: Path) -> Self @@ -167,7 +167,7 @@ from_file(cls, file_path: Path) -> Self Load configuration from JSON file. -### `CanonicalMCPConfig` +### `CanonicalMCPConfig` Canonical MCP configuration format. @@ -178,7 +178,7 @@ The format is designed to be client-agnostic and extensible for future use cases **Methods:** -#### `add_server` +#### `add_server` ```python add_server(self, name: str, server: CanonicalMCPServerTypes) -> None diff --git a/docs/python-sdk/fastmcp-server-mixins-transport.mdx b/docs/python-sdk/fastmcp-server-mixins-transport.mdx index ed61e5a5d..0ce07f2fb 100644 --- a/docs/python-sdk/fastmcp-server-mixins-transport.mdx +++ b/docs/python-sdk/fastmcp-server-mixins-transport.mdx @@ -104,7 +104,7 @@ Run the server using HTTP transport. - `stateless`: Alias for stateless_http for CLI consistency -#### `http_app` +#### `http_app` ```python http_app(self: FastMCP, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http', event_store: EventStore | None = None, retry_interval: int | None = None) -> StarletteWithLifespan diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 80b45257b..efaa20d12 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -54,65 +54,59 @@ use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.pr ## Classes -### `StateValue` +### `StateValue` Wrapper for stored context state values. -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` - -```python -settings(self) -> Settings -``` - -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -132,7 +126,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -144,7 +138,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -159,7 +153,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -171,7 +165,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -183,7 +177,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -196,7 +190,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -216,7 +210,7 @@ session transforms can override provider-level disables. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -229,7 +223,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -248,7 +242,7 @@ transforms (including session-level) have been applied. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -261,7 +255,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -280,7 +274,7 @@ all transforms (including session-level) have been applied. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -293,7 +287,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -312,19 +306,19 @@ transforms (including session-level) have been applied. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -354,19 +348,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -395,19 +389,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -437,7 +431,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -455,7 +449,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -471,19 +465,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -539,7 +533,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -554,7 +548,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -569,7 +563,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction] @@ -628,7 +622,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -643,19 +637,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -732,7 +726,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -779,7 +773,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -820,7 +814,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self @@ -849,7 +843,7 @@ response structure while still returning structured JSON. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -873,7 +867,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -891,7 +885,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index 8c6470a39..6b630615a 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -7,15 +7,13 @@ sidebarTitle: settings ## Classes -### `DocketSettings` +### `DocketSettings` Docket worker configuration. -### `ExperimentalSettings` - -### `Settings` +### `Settings` FastMCP settings. @@ -23,7 +21,7 @@ FastMCP settings. **Methods:** -#### `get_setting` +#### `get_setting` ```python get_setting(self, attr: str) -> Any @@ -33,7 +31,7 @@ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `set_setting` +#### `set_setting` ```python set_setting(self, attr: str, value: Any) -> None @@ -43,7 +41,7 @@ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `normalize_log_level` +#### `normalize_log_level` ```python normalize_log_level(cls, v) diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py index f65132edb..c7dac539f 100644 --- a/src/fastmcp/mcp_config.py +++ b/src/fastmcp/mcp_config.py @@ -109,10 +109,13 @@ class _TransformingMCPServerMixin(FastMCPBaseModel): wrapped_mcp_server = create_proxy( client, name=server_name, - include_tags=self.include_tags, - exclude_tags=self.exclude_tags, ) + if self.include_tags is not None: + wrapped_mcp_server.enable(tags=self.include_tags, only=True) + if self.exclude_tags is not None: + wrapped_mcp_server.disable(tags=self.exclude_tags) + # Apply tool transforms if configured if self.tools: from fastmcp.server.transforms import ToolTransform diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py index 9e797dc1f..1f069a02d 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -231,17 +231,15 @@ class TransportMixin: # Resolve from settings/env var if not explicitly set if stateless_http is None: - stateless_http = self._deprecated_settings.stateless_http + stateless_http = fastmcp.settings.stateless_http # SSE doesn't support stateless mode if stateless_http and transport == "sse": raise ValueError("SSE transport does not support stateless mode") - host = host or self._deprecated_settings.host - port = port or self._deprecated_settings.port - default_log_level_to_use = ( - log_level or self._deprecated_settings.log_level - ).lower() + host = host or fastmcp.settings.host + port = port or fastmcp.settings.port + default_log_level_to_use = (log_level or fastmcp.settings.log_level).lower() app = self.http_app( path=path, @@ -311,31 +309,30 @@ class TransportMixin: if transport in ("streamable-http", "http"): return create_streamable_http_app( server=self, - streamable_http_path=path - or self._deprecated_settings.streamable_http_path, + streamable_http_path=path or fastmcp.settings.streamable_http_path, event_store=event_store, retry_interval=retry_interval, auth=self.auth, json_response=( json_response if json_response is not None - else self._deprecated_settings.json_response + else fastmcp.settings.json_response ), stateless_http=( stateless_http if stateless_http is not None - else self._deprecated_settings.stateless_http + else fastmcp.settings.stateless_http ), - debug=self._deprecated_settings.debug, + debug=fastmcp.settings.debug, middleware=middleware, ) elif transport == "sse": return create_sse_app( server=self, - message_path=self._deprecated_settings.message_path, - sse_path=path or self._deprecated_settings.sse_path, + message_path=fastmcp.settings.message_path, + sse_path=path or fastmcp.settings.sse_path, auth=self.auth, - debug=self._deprecated_settings.debug, + debug=fastmcp.settings.debug, middleware=middleware, ) else: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 35d717e8c..66c60d978 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -10,8 +10,6 @@ from collections.abc import ( AsyncIterator, Awaitable, Callable, - Collection, - Mapping, Sequence, ) from contextlib import ( @@ -79,7 +77,6 @@ from fastmcp.server.transforms import ( ) from fastmcp.server.transforms.visibility import apply_session_transforms, is_enabled from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting -from fastmcp.settings import Settings from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool import AuthCheckCallable, Tool, ToolResult from fastmcp.tools.tool_transform import ToolTransformConfig @@ -99,7 +96,6 @@ if TYPE_CHECKING: from fastmcp.server.providers.openapi import RouteMap from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn from fastmcp.server.providers.proxy import FastMCPProxy - from fastmcp.tools.tool import ToolResultSerializerType logger = get_logger(__name__) @@ -107,39 +103,37 @@ logger = get_logger(__name__) DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] -def _resolve_on_duplicate( - on_duplicate: DuplicateBehavior | None, - on_duplicate_tools: DuplicateBehavior | None, - on_duplicate_resources: DuplicateBehavior | None, - on_duplicate_prompts: DuplicateBehavior | None, -) -> DuplicateBehavior: - """Resolve on_duplicate from deprecated per-type params. +_REMOVED_KWARGS: dict[str, str] = { + "host": "Pass `host` to `run_http_async()`, or set FASTMCP_HOST.", + "port": "Pass `port` to `run_http_async()`, or set FASTMCP_PORT.", + "sse_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_SSE_PATH.", + "message_path": "Set FASTMCP_MESSAGE_PATH.", + "streamable_http_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_STREAMABLE_HTTP_PATH.", + "json_response": "Pass `json_response` to `run_http_async()` or `http_app()`, or set FASTMCP_JSON_RESPONSE.", + "stateless_http": "Pass `stateless_http` to `run_http_async()` or `http_app()`, or set FASTMCP_STATELESS_HTTP.", + "debug": "Set FASTMCP_DEBUG.", + "log_level": "Pass `log_level` to `run_http_async()`, or set FASTMCP_LOG_LEVEL.", + "on_duplicate_tools": "Use `on_duplicate=` instead.", + "on_duplicate_resources": "Use `on_duplicate=` instead.", + "on_duplicate_prompts": "Use `on_duplicate=` instead.", + "tool_serializer": "Return ToolResult from your tools instead. See https://gofastmcp.com/servers/tools#custom-serialization", + "include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.", + "exclude_tags": "Use `server.disable(tags=...)` after creating the server.", + "tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.", +} - Takes the most strict value if multiple are provided. - Delete this function when removing deprecated params. - """ - strictness_order: list[DuplicateBehavior] = ["error", "warn", "replace", "ignore"] - deprecated_values: list[DuplicateBehavior] = [] - deprecated_params: list[tuple[str, DuplicateBehavior | None]] = [ - ("on_duplicate_tools", on_duplicate_tools), - ("on_duplicate_resources", on_duplicate_resources), - ("on_duplicate_prompts", on_duplicate_prompts), - ] - for name, value in deprecated_params: - if value is not None: - if fastmcp.settings.deprecation_warnings: - warnings.warn( - f"{name} is deprecated, use on_duplicate instead", - DeprecationWarning, - stacklevel=4, - ) - deprecated_values.append(value) - - if on_duplicate is None and deprecated_values: - return min(deprecated_values, key=lambda x: strictness_order.index(x)) - - return on_duplicate or "warn" +def _check_removed_kwargs(kwargs: dict[str, Any]) -> None: + """Raise helpful TypeErrors for kwargs removed in v3.""" + for key in kwargs: + if key in _REMOVED_KWARGS: + raise TypeError( + f"FastMCP() no longer accepts `{key}`. {_REMOVED_KWARGS[key]}" + ) + if kwargs: + raise TypeError( + f"FastMCP() got unexpected keyword argument(s): {', '.join(repr(k) for k in kwargs)}" + ) Transport = Literal["stdio", "http", "sse", "streamable-http"] @@ -232,45 +226,23 @@ class FastMCP( middleware: Sequence[Middleware] | None = None, providers: Sequence[Provider] | None = None, lifespan: LifespanCallable | Lifespan | None = None, - mask_error_details: bool | None = None, tools: Sequence[Tool | Callable[..., Any]] | None = None, - tool_serializer: ToolResultSerializerType | None = None, - include_tags: Collection[str] | None = None, - exclude_tags: Collection[str] | None = None, on_duplicate: DuplicateBehavior | None = None, + mask_error_details: bool | None = None, strict_input_validation: bool | None = None, list_page_size: int | None = None, tasks: bool | None = None, session_state_store: AsyncKeyValue | None = None, - # --- - # --- DEPRECATED parameters --- - # --- - on_duplicate_tools: DuplicateBehavior | None = None, - on_duplicate_resources: DuplicateBehavior | None = None, - on_duplicate_prompts: DuplicateBehavior | None = None, - log_level: str | None = None, - debug: bool | None = None, - host: str | None = None, - port: int | None = None, - sse_path: str | None = None, - message_path: str | None = None, - streamable_http_path: str | None = None, - json_response: bool | None = None, - stateless_http: bool | None = None, sampling_handler: SamplingHandler | None = None, sampling_handler_behavior: Literal["always", "fallback"] | None = None, - tool_transformations: Mapping[str, ToolTransformConfig] | None = None, + **kwargs: Any, ): + _check_removed_kwargs(kwargs) + # Initialize Provider (sets up _transforms) super().__init__() - # Resolve on_duplicate from deprecated params (delete when removing deprecation) - self._on_duplicate: DuplicateBehaviorSetting = _resolve_on_duplicate( - on_duplicate, - on_duplicate_tools, - on_duplicate_resources, - on_duplicate_prompts, - ) + self._on_duplicate: DuplicateBehaviorSetting = on_duplicate or "warn" # Resolve server default for background task support self._support_tasks_by_default: bool = tasks if tasks is not None else False @@ -312,16 +284,6 @@ class FastMCP( raise ValueError("list_page_size must be a positive integer") self._list_page_size: int | None = list_page_size - if tool_serializer is not None and fastmcp.settings.deprecation_warnings: - warnings.warn( - "The `tool_serializer` parameter is deprecated. " - "Return ToolResult from your tools for full control over serialization. " - "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - DeprecationWarning, - stacklevel=2, - ) - self._tool_serializer: Callable[[Any], str] | None = tool_serializer - # Handle Lifespan instances (they're callable) or regular lifespan functions if lifespan is not None: self._lifespan: LifespanCallable[LifespanResultT] = lifespan @@ -349,38 +311,9 @@ class FastMCP( if tools: for tool in tools: if not isinstance(tool, Tool): - tool = Tool.from_function(tool, serializer=self._tool_serializer) + tool = Tool.from_function(tool) self.add_tool(tool) - # Handle deprecated include_tags and exclude_tags parameters - if include_tags is not None: - warnings.warn( - "include_tags is deprecated. Use server.enable(tags=..., only=True) instead.", - DeprecationWarning, - stacklevel=2, - ) - # For backwards compatibility, initialize allowlist from include_tags - self.enable(tags=set(include_tags), only=True) - if exclude_tags is not None: - warnings.warn( - "exclude_tags is deprecated. Use server.disable(tags=...) instead.", - DeprecationWarning, - stacklevel=2, - ) - # For backwards compatibility, initialize blocklist from exclude_tags - self.disable(tags=set(exclude_tags)) - - # Handle deprecated tool_transformations parameter - if tool_transformations: - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "The tool_transformations parameter is deprecated. Use " - "server.add_transform(ToolTransform({...})) instead.", - DeprecationWarning, - stacklevel=2, - ) - self._transforms.append(ToolTransform(dict(tool_transformations))) - self.strict_input_validation: bool = ( strict_input_validation if strict_input_validation is not None @@ -397,71 +330,9 @@ class FastMCP( sampling_handler_behavior or "fallback" ) - self._handle_deprecated_settings( - log_level=log_level, - debug=debug, - host=host, - port=port, - sse_path=sse_path, - message_path=message_path, - streamable_http_path=streamable_http_path, - json_response=json_response, - stateless_http=stateless_http, - ) - def __repr__(self) -> str: return f"{type(self).__name__}({self.name!r})" - def _handle_deprecated_settings( - self, - log_level: str | None, - debug: bool | None, - host: str | None, - port: int | None, - sse_path: str | None, - message_path: str | None, - streamable_http_path: str | None, - json_response: bool | None, - stateless_http: bool | None, - ) -> None: - """Handle deprecated settings. Deprecated in 2.8.0.""" - deprecated_settings: dict[str, Any] = {} - - for name, arg in [ - ("log_level", log_level), - ("debug", debug), - ("host", host), - ("port", port), - ("sse_path", sse_path), - ("message_path", message_path), - ("streamable_http_path", streamable_http_path), - ("json_response", json_response), - ("stateless_http", stateless_http), - ]: - if arg is not None: - # Deprecated in 2.8.0 - if fastmcp.settings.deprecation_warnings: - warnings.warn( - f"Providing `{name}` when creating a server is deprecated. Provide it when calling `run` or as a global setting instead.", - DeprecationWarning, - stacklevel=2, - ) - deprecated_settings[name] = arg - - combined_settings = fastmcp.settings.model_dump() | deprecated_settings - self._deprecated_settings = Settings(**combined_settings) - - @property - def settings(self) -> Settings: - # Deprecated in 2.8.0 - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "Accessing `.settings` on a FastMCP instance is deprecated. Use the global `fastmcp.settings` instead.", - DeprecationWarning, - stacklevel=2, - ) - return self._deprecated_settings - @property def name(self) -> str: return self._mcp_server.name @@ -1530,7 +1401,6 @@ class FastMCP( meta=meta, task=task if task is not None else self._support_tasks_by_default, timeout=timeout, - serializer=self._tool_serializer, auth=auth, ) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 0257e8d3b..561a80437 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -2,7 +2,6 @@ from __future__ import annotations as _annotations import inspect import os -import warnings from datetime import timedelta from pathlib import Path from typing import Annotated, Any, Literal @@ -115,30 +114,6 @@ class DocketSettings(BaseSettings): ] = timedelta(seconds=5) -class ExperimentalSettings(BaseSettings): - model_config = SettingsConfigDict( - env_prefix="FASTMCP_EXPERIMENTAL_", - extra="ignore", - validate_assignment=True, - ) - - # Deprecated in 2.14 - the new OpenAPI parser is now the default and only parser - enable_new_openapi_parser: bool = False - - @field_validator("enable_new_openapi_parser", mode="after") - @classmethod - def _warn_openapi_parser_deprecated(cls, v: bool) -> bool: - if v: - warnings.warn( - "enable_new_openapi_parser is deprecated. " - "The new OpenAPI parser is now the default (and only) parser. " - "You can remove this setting.", - DeprecationWarning, - stacklevel=2, - ) - return v - - class Settings(BaseSettings): """FastMCP settings.""" @@ -191,8 +166,6 @@ class Settings(BaseSettings): return v.upper() return v - experimental: ExperimentalSettings = ExperimentalSettings() - docket: DocketSettings = DocketSettings() enable_rich_logging: Annotated[ diff --git a/tests/deprecated/server/test_include_exclude_tags.py b/tests/deprecated/server/test_include_exclude_tags.py index 1bdf68432..70312b412 100644 --- a/tests/deprecated/server/test_include_exclude_tags.py +++ b/tests/deprecated/server/test_include_exclude_tags.py @@ -1,68 +1,25 @@ -"""Tests for deprecated include_tags/exclude_tags parameters.""" +"""Tests for removed include_tags/exclude_tags parameters.""" import pytest from fastmcp import FastMCP -from fastmcp.server.transforms.visibility import Visibility -class TestIncludeExcludeTagsDeprecation: - """Test that include_tags/exclude_tags emit deprecation warnings but still work.""" +class TestIncludeExcludeTagsRemoved: + """Test that include_tags/exclude_tags raise TypeError with migration hints.""" - def test_exclude_tags_emits_warning(self): - """exclude_tags parameter emits deprecation warning.""" - with pytest.warns(DeprecationWarning, match="exclude_tags.*deprecated"): + def test_exclude_tags_raises_type_error(self): + with pytest.raises(TypeError, match="no longer accepts `exclude_tags`"): FastMCP(exclude_tags={"internal"}) - def test_include_tags_emits_warning(self): - """include_tags parameter emits deprecation warning.""" - with pytest.warns(DeprecationWarning, match="include_tags.*deprecated"): + def test_include_tags_raises_type_error(self): + with pytest.raises(TypeError, match="no longer accepts `include_tags`"): FastMCP(include_tags={"public"}) - def test_exclude_tags_still_works(self): - """exclude_tags adds a Visibility transform that disables matching tags.""" - with pytest.warns(DeprecationWarning): - mcp = FastMCP(exclude_tags={"internal"}) + def test_exclude_tags_error_mentions_disable(self): + with pytest.raises(TypeError, match="server.disable"): + FastMCP(exclude_tags={"internal"}) - # Should have added a Visibility transform that disables the tag - enabled_transforms = [t for t in mcp._transforms if isinstance(t, Visibility)] - assert len(enabled_transforms) == 1 - e = enabled_transforms[0] - assert e._enabled is False - assert e.tags == {"internal"} - - def test_include_tags_still_works(self): - """include_tags adds Visibility transforms for allowlist mode.""" - with pytest.warns(DeprecationWarning): - mcp = FastMCP(include_tags={"public"}) - - # Should have added Visibility transforms for allowlist mode - # (one to disable all, one to enable matching) - enabled_transforms = [t for t in mcp._transforms if isinstance(t, Visibility)] - assert len(enabled_transforms) == 2 - - # First should disable all (Visibility.all(False)) - disable_all_transform = enabled_transforms[0] - assert disable_all_transform._enabled is False - assert disable_all_transform.match_all is True - - # Second should enable matching tags - enable_transform = enabled_transforms[1] - assert enable_transform._enabled is True - assert enable_transform.tags == {"public"} - - def test_exclude_and_include_both_create_transforms(self): - """exclude_tags and include_tags both create transforms.""" - with pytest.warns(DeprecationWarning): - mcp = FastMCP(include_tags={"public"}, exclude_tags={"deprecated"}) - - # Should have added transforms for both - # include_tags creates 2 (disable all + enable matching) - # exclude_tags creates 1 (disable matching) - enabled_transforms = [t for t in mcp._transforms if isinstance(t, Visibility)] - assert len(enabled_transforms) == 3 - - # Check we have both tag rules - tags_in_transforms = [t.tags for t in enabled_transforms if t.tags] - assert {"public"} in tags_in_transforms - assert {"deprecated"} in tags_in_transforms + def test_include_tags_error_mentions_enable(self): + with pytest.raises(TypeError, match="server.enable"): + FastMCP(include_tags={"public"}) diff --git a/tests/deprecated/test_add_tool_transformation.py b/tests/deprecated/test_add_tool_transformation.py index 348247b9f..0228b8b60 100644 --- a/tests/deprecated/test_add_tool_transformation.py +++ b/tests/deprecated/test_add_tool_transformation.py @@ -68,37 +68,12 @@ class TestAddToolTransformationDeprecated: assert "remove_tool_transformation is deprecated" in str(w[0].message) assert "no effect" in str(w[0].message) - async def test_tool_transformations_constructor_emits_warning(self): - """tool_transformations constructor param should emit deprecation warning.""" - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") + async def test_tool_transformations_constructor_raises_type_error(self): + """tool_transformations constructor param should raise TypeError.""" + import pytest + + with pytest.raises(TypeError, match="no longer accepts `tool_transformations`"): FastMCP( "test", tool_transformations={"my_tool": ToolTransformConfig(name="renamed")}, ) - - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "tool_transformations parameter is deprecated" in str(w[0].message) - - async def test_tool_transformations_constructor_still_works(self): - """tool_transformations constructor param should still apply transforms.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - mcp = FastMCP( - "test", - tool_transformations={ - "my_tool": ToolTransformConfig(name="renamed_tool") - }, - ) - - @mcp.tool - def my_tool() -> str: - return "result" - - async with Client(mcp) as client: - tools = await client.list_tools() - tool_names = [t.name for t in tools] - - assert "my_tool" not in tool_names - assert "renamed_tool" in tool_names diff --git a/tests/deprecated/test_deprecated.py b/tests/deprecated/test_deprecated.py index 25a32aff6..37f33cf27 100644 --- a/tests/deprecated/test_deprecated.py +++ b/tests/deprecated/test_deprecated.py @@ -1,48 +1,23 @@ -import warnings - import pytest from starlette.applications import Starlette from fastmcp import FastMCP -from fastmcp.utilities.tests import temporary_settings - -# reset deprecation warnings for this module -pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning") -class TestDeprecationWarningsSetting: - def test_deprecation_warnings_setting_true(self): - with temporary_settings(deprecation_warnings=True): - with pytest.warns(DeprecationWarning) as recorded_warnings: - # will warn once for providing deprecated arg - mcp = FastMCP(host="1.2.3.4") - # will warn once for accessing deprecated property - mcp.settings +class TestRemovedKwargs: + def test_host_kwarg_raises_type_error(self): + with pytest.raises(TypeError, match="no longer accepts `host`"): + FastMCP(host="1.2.3.4") - assert len(recorded_warnings) == 2 - - def test_deprecation_warnings_setting_false(self): - with temporary_settings(deprecation_warnings=False): - # will error if a warning is raised - with warnings.catch_warnings(): - warnings.simplefilter("error") - # will warn once for providing deprecated arg - mcp = FastMCP(host="1.2.3.4") - # will warn once for accessing deprecated property - mcp.settings + def test_settings_property_removed(self): + mcp = FastMCP() + assert not hasattr(mcp, "_deprecated_settings") + with pytest.raises(AttributeError): + mcp.settings # noqa: B018 # ty: ignore[unresolved-attribute] def test_http_app_with_sse_transport(): - """Test that http_app with SSE transport works (no warning).""" + """Test that http_app with SSE transport works.""" server = FastMCP("TestServer") - - # This should not raise a warning since we're using the new API - with warnings.catch_warnings(record=True) as recorded_warnings: - app = server.http_app(transport="sse") - assert isinstance(app, Starlette) - - # Verify no deprecation warnings were raised for using transport parameter - deprecation_warnings = [ - w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 0 + app = server.http_app(transport="sse") + assert isinstance(app, Starlette) diff --git a/tests/deprecated/test_openapi_deprecations.py b/tests/deprecated/test_openapi_deprecations.py index d55f84736..b57611c6f 100644 --- a/tests/deprecated/test_openapi_deprecations.py +++ b/tests/deprecated/test_openapi_deprecations.py @@ -5,34 +5,9 @@ import warnings import pytest -import fastmcp - pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning") -class TestEnableNewOpenAPIParserDeprecation: - """Test enable_new_openapi_parser setting deprecation.""" - - def test_setting_true_emits_warning(self): - """Setting enable_new_openapi_parser=True should emit deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"enable_new_openapi_parser is deprecated.*now the default", - ): - fastmcp.settings.experimental.enable_new_openapi_parser = True - - def test_setting_false_no_warning(self): - """Setting enable_new_openapi_parser=False should not emit warning.""" - with warnings.catch_warnings(record=True) as recorded: - warnings.simplefilter("always") - fastmcp.settings.experimental.enable_new_openapi_parser = False - - deprecation_warnings = [ - w for w in recorded if issubclass(w.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 0 - - class TestExperimentalOpenAPIImportDeprecation: """Test experimental OpenAPI import path deprecations.""" diff --git a/tests/deprecated/test_settings.py b/tests/deprecated/test_settings.py index 301abf410..47ea6613c 100644 --- a/tests/deprecated/test_settings.py +++ b/tests/deprecated/test_settings.py @@ -1,319 +1,64 @@ -import warnings -from unittest.mock import patch - import pytest from fastmcp import FastMCP -# reset deprecation warnings for this module -pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning") +class TestRemovedServerInitKwargs: + """Test that removed server initialization keyword arguments raise TypeError.""" -class TestDeprecatedServerInitKwargs: - """Test deprecated server initialization keyword arguments.""" + @pytest.mark.parametrize( + "kwarg, value, expected_message", + [ + ("host", "0.0.0.0", "run_http_async"), + ("port", 8080, "run_http_async"), + ("sse_path", "/custom-sse", "FASTMCP_SSE_PATH"), + ("message_path", "/custom-message", "FASTMCP_MESSAGE_PATH"), + ("streamable_http_path", "/custom-http", "run_http_async"), + ("json_response", True, "run_http_async"), + ("stateless_http", True, "run_http_async"), + ("debug", True, "FASTMCP_DEBUG"), + ("log_level", "DEBUG", "run_http_async"), + ("on_duplicate_tools", "warn", "on_duplicate="), + ("on_duplicate_resources", "error", "on_duplicate="), + ("on_duplicate_prompts", "replace", "on_duplicate="), + ("tool_serializer", lambda x: str(x), "ToolResult"), + ("include_tags", {"public"}, "server.enable"), + ("exclude_tags", {"internal"}, "server.disable"), + ( + "tool_transformations", + {"my_tool": {"name": "renamed"}}, + "server.add_transform", + ), + ], + ) + def test_removed_kwarg_raises_type_error(self, kwarg, value, expected_message): + with pytest.raises(TypeError, match=f"no longer accepts `{kwarg}`"): + FastMCP("TestServer", **{kwarg: value}) - def test_log_level_deprecation_warning(self): - """Test that log_level raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `log_level` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", log_level="DEBUG") + @pytest.mark.parametrize( + "kwarg, value, expected_message", + [ + ("host", "0.0.0.0", "run_http_async"), + ("on_duplicate_tools", "warn", "on_duplicate="), + ("include_tags", {"public"}, "server.enable"), + ], + ) + def test_removed_kwarg_error_includes_migration_hint( + self, kwarg, value, expected_message + ): + with pytest.raises(TypeError, match=expected_message): + FastMCP("TestServer", **{kwarg: value}) - # Verify the setting is still applied - assert server._deprecated_settings.log_level == "DEBUG" + def test_unknown_kwarg_raises_standard_type_error(self): + with pytest.raises(TypeError, match="unexpected keyword argument"): + FastMCP("TestServer", **{"totally_fake_param": True}) # ty: ignore[invalid-argument-type] - def test_debug_deprecation_warning(self): - """Test that debug raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `debug` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", debug=True) - - # Verify the setting is still applied - assert server._deprecated_settings.debug is True - - def test_host_deprecation_warning(self): - """Test that host raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `host` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", host="0.0.0.0") - - # Verify the setting is still applied - assert server._deprecated_settings.host == "0.0.0.0" - - def test_port_deprecation_warning(self): - """Test that port raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `port` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", port=8080) - - # Verify the setting is still applied - assert server._deprecated_settings.port == 8080 - - def test_sse_path_deprecation_warning(self): - """Test that sse_path raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `sse_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", sse_path="/custom-sse") - - # Verify the setting is still applied - assert server._deprecated_settings.sse_path == "/custom-sse" - - def test_message_path_deprecation_warning(self): - """Test that message_path raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `message_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", message_path="/custom-message") - - # Verify the setting is still applied - assert server._deprecated_settings.message_path == "/custom-message" - - def test_streamable_http_path_deprecation_warning(self): - """Test that streamable_http_path raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `streamable_http_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", streamable_http_path="/custom-http") - - # Verify the setting is still applied - assert server._deprecated_settings.streamable_http_path == "/custom-http" - - def test_json_response_deprecation_warning(self): - """Test that json_response raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `json_response` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", json_response=True) - - # Verify the setting is still applied - assert server._deprecated_settings.json_response is True - - def test_stateless_http_deprecation_warning(self): - """Test that stateless_http raises a deprecation warning.""" - with pytest.warns( - DeprecationWarning, - match=r"Providing `stateless_http` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", - ): - server = FastMCP("TestServer", stateless_http=True) - - # Verify the setting is still applied - assert server._deprecated_settings.stateless_http is True - - def test_multiple_deprecated_kwargs_warnings(self): - """Test that multiple deprecated kwargs each raise their own warning.""" - with warnings.catch_warnings(record=True) as recorded_warnings: - warnings.simplefilter("always") - server = FastMCP( - "TestServer", - log_level="INFO", - debug=False, - host="127.0.0.1", - port=9999, - sse_path="/sse/", - message_path="/msg", - streamable_http_path="/http", - json_response=False, - stateless_http=False, - ) - - # Should have 9 deprecation warnings (one for each deprecated parameter) - deprecation_warnings = [ - w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 9 - - # Verify all expected parameters are mentioned in warnings - expected_params = { - "log_level", - "debug", - "host", - "port", - "sse_path", - "message_path", - "streamable_http_path", - "json_response", - "stateless_http", - } - mentioned_params = set() - for warning in deprecation_warnings: - message = str(warning.message) - for param in expected_params: - if f"Providing `{param}`" in message: - mentioned_params.add(param) - - assert mentioned_params == expected_params - - # Verify all settings are still applied - assert server._deprecated_settings.log_level == "INFO" - assert server._deprecated_settings.debug is False - assert server._deprecated_settings.host == "127.0.0.1" - assert server._deprecated_settings.port == 9999 - assert server._deprecated_settings.sse_path == "/sse/" - assert server._deprecated_settings.message_path == "/msg" - assert server._deprecated_settings.streamable_http_path == "/http" - assert server._deprecated_settings.json_response is False - assert server._deprecated_settings.stateless_http is False - - def test_non_deprecated_kwargs_no_warnings(self): - """Test that non-deprecated kwargs don't raise warnings.""" - with warnings.catch_warnings(record=True) as recorded_warnings: - warnings.simplefilter("always") - server = FastMCP( - name="TestServer", - instructions="Test instructions", - on_duplicate="warn", # New unified parameter - mask_error_details=True, - ) - - # Should have no deprecation warnings - deprecation_warnings = [ - w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 0 - - # Verify server was created successfully + def test_valid_kwargs_still_work(self): + server = FastMCP( + name="TestServer", + instructions="Test instructions", + on_duplicate="warn", + mask_error_details=True, + ) assert server.name == "TestServer" assert server.instructions == "Test instructions" - - def test_deprecated_duplicate_kwargs_raise_warnings(self): - """Test that deprecated on_duplicate_* kwargs raise warnings.""" - with warnings.catch_warnings(record=True) as recorded_warnings: - warnings.simplefilter("always") - FastMCP( - name="TestServer", - on_duplicate_tools="warn", - on_duplicate_resources="error", - on_duplicate_prompts="replace", - ) - - # Should have 3 deprecation warnings (one for each deprecated param) - deprecation_warnings = [ - w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 3 - - # Check warning messages - warning_messages = [str(w.message) for w in deprecation_warnings] - assert any("on_duplicate_tools" in msg for msg in warning_messages) - assert any("on_duplicate_resources" in msg for msg in warning_messages) - assert any("on_duplicate_prompts" in msg for msg in warning_messages) - - def test_none_values_no_warnings(self): - """Test that None values for deprecated kwargs don't raise warnings.""" - with warnings.catch_warnings(record=True) as recorded_warnings: - warnings.simplefilter("always") - FastMCP( - "TestServer", - log_level=None, - debug=None, - host=None, - port=None, - sse_path=None, - message_path=None, - streamable_http_path=None, - json_response=None, - stateless_http=None, - ) - - # Should have no deprecation warnings for None values - deprecation_warnings = [ - w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 0 - - def test_deprecated_settings_inheritance_from_global(self): - """Test that deprecated settings inherit from global settings when not provided.""" - # Mock fastmcp.settings to test inheritance - with patch("fastmcp.settings") as mock_settings: - mock_settings.model_dump.return_value = { - "log_level": "WARNING", - "debug": True, - "host": "0.0.0.0", - "port": 3000, - "sse_path": "/events", - "message_path": "/messages", - "streamable_http_path": "/stream", - "json_response": True, - "stateless_http": True, - } - - server = FastMCP("TestServer") - - # Verify settings are inherited from global settings - assert server._deprecated_settings.log_level == "WARNING" - assert server._deprecated_settings.debug is True - assert server._deprecated_settings.host == "0.0.0.0" - assert server._deprecated_settings.port == 3000 - assert server._deprecated_settings.sse_path == "/events" - assert server._deprecated_settings.message_path == "/messages" - assert server._deprecated_settings.streamable_http_path == "/stream" - assert server._deprecated_settings.json_response is True - assert server._deprecated_settings.stateless_http is True - - def test_deprecated_settings_override_global(self): - """Test that deprecated settings override global settings when provided.""" - # Mock fastmcp.settings to test override behavior - with patch("fastmcp.settings") as mock_settings: - mock_settings.model_dump.return_value = { - "log_level": "WARNING", - "debug": True, - "host": "0.0.0.0", - "port": 3000, - "sse_path": "/events", - "message_path": "/messages", - "streamable_http_path": "/stream", - "json_response": True, - "stateless_http": True, - } - - with warnings.catch_warnings(): - warnings.simplefilter("ignore") # Ignore warnings for this test - server = FastMCP( - "TestServer", - log_level="ERROR", - debug=False, - host="127.0.0.1", - port=8080, - ) - - # Verify provided settings override global settings - assert server._deprecated_settings.log_level == "ERROR" - assert server._deprecated_settings.debug is False - assert server._deprecated_settings.host == "127.0.0.1" - assert server._deprecated_settings.port == 8080 - # Non-overridden settings should still come from global - assert server._deprecated_settings.sse_path == "/events" - assert server._deprecated_settings.message_path == "/messages" - assert server._deprecated_settings.streamable_http_path == "/stream" - assert server._deprecated_settings.json_response is True - assert server._deprecated_settings.stateless_http is True - - def test_stacklevel_points_to_constructor_call(self): - """Test that deprecation warnings point to the FastMCP constructor call.""" - with warnings.catch_warnings(record=True) as recorded_warnings: - warnings.simplefilter("always") - - FastMCP("TestServer", log_level="DEBUG") - - # Should have exactly one deprecation warning - deprecation_warnings = [ - w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 1 - - # The warning should point to the server.py file where FastMCP.__init__ is called - # This verifies the stacklevel is working as intended (pointing to constructor) - warning = deprecation_warnings[0] - assert "server.py" in warning.filename diff --git a/tests/deprecated/test_tool_serializer.py b/tests/deprecated/test_tool_serializer.py index f90bf06cc..2b706ae74 100644 --- a/tests/deprecated/test_tool_serializer.py +++ b/tests/deprecated/test_tool_serializer.py @@ -143,15 +143,14 @@ class TestSerializerDeprecationWarnings: with pytest.warns(DeprecationWarning, match="serializer.*deprecated"): provider.tool(my_tool, serializer=custom_serializer) - def test_fastmcp_tool_serializer_parameter_warning(self): - """Test that FastMCP tool_serializer parameter warns.""" + def test_fastmcp_tool_serializer_parameter_raises_type_error(self): + """Test that FastMCP tool_serializer parameter raises TypeError.""" def custom_serializer(data) -> str: return f"Custom: {data}" - with temporary_settings(deprecation_warnings=True): - with pytest.warns(DeprecationWarning, match="tool_serializer.*deprecated"): - FastMCP("TestServer", tool_serializer=custom_serializer) + with pytest.raises(TypeError, match="no longer accepts `tool_serializer`"): + FastMCP("TestServer", tool_serializer=custom_serializer) def test_transformed_tool_from_tool_serializer_warning(self): """Test that TransformedTool.from_tool warns when serializer is provided.""" diff --git a/tests/server/mount/test_filtering.py b/tests/server/mount/test_filtering.py index 413cc0e60..376db4c75 100644 --- a/tests/server/mount/test_filtering.py +++ b/tests/server/mount/test_filtering.py @@ -11,7 +11,8 @@ class TestParentTagFiltering: async def test_parent_include_tags_filters_mounted_tools(self): """Test that parent include_tags filters out non-matching mounted tools.""" - parent = FastMCP("Parent", include_tags={"allowed"}) + parent = FastMCP("Parent") + parent.enable(tags={"allowed"}, only=True) mounted = FastMCP("Mounted") @mounted.tool(tags={"allowed"}) @@ -38,7 +39,8 @@ class TestParentTagFiltering: async def test_parent_exclude_tags_filters_mounted_tools(self): """Test that parent exclude_tags filters out matching mounted tools.""" - parent = FastMCP("Parent", exclude_tags={"blocked"}) + parent = FastMCP("Parent") + parent.disable(tags={"blocked"}) mounted = FastMCP("Mounted") @mounted.tool(tags={"production"}) @@ -58,7 +60,8 @@ class TestParentTagFiltering: async def test_parent_filters_apply_to_mounted_resources(self): """Test that parent tag filters apply to mounted resources.""" - parent = FastMCP("Parent", include_tags={"allowed"}) + parent = FastMCP("Parent") + parent.enable(tags={"allowed"}, only=True) mounted = FastMCP("Mounted") @mounted.resource("resource://allowed", tags={"allowed"}) @@ -78,7 +81,8 @@ class TestParentTagFiltering: async def test_parent_filters_apply_to_mounted_prompts(self): """Test that parent tag filters apply to mounted prompts.""" - parent = FastMCP("Parent", exclude_tags={"blocked"}) + parent = FastMCP("Parent") + parent.disable(tags={"blocked"}) mounted = FastMCP("Mounted") @mounted.prompt(tags={"allowed"}) diff --git a/tests/server/providers/local_provider_tools/test_tags.py b/tests/server/providers/local_provider_tools/test_tags.py index 019ed4c07..fb32e3515 100644 --- a/tests/server/providers/local_provider_tools/test_tags.py +++ b/tests/server/providers/local_provider_tools/test_tags.py @@ -40,7 +40,7 @@ class PersonDataclass: class TestToolTags: def create_server(self, include_tags=None, exclude_tags=None): - mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + mcp = FastMCP() @mcp.tool(tags={"a", "b"}) def tool_1() -> int: @@ -50,6 +50,11 @@ class TestToolTags: def tool_2() -> int: return 2 + if include_tags: + mcp.enable(tags=include_tags, only=True) + if exclude_tags: + mcp.disable(tags=exclude_tags) + return mcp async def test_include_tags_all_tools(self): diff --git a/tests/server/providers/test_local_provider_prompts.py b/tests/server/providers/test_local_provider_prompts.py index 6b0400e64..ac0df86a3 100644 --- a/tests/server/providers/test_local_provider_prompts.py +++ b/tests/server/providers/test_local_provider_prompts.py @@ -415,7 +415,7 @@ class TestPromptEnabled: class TestPromptTags: def create_server(self, include_tags=None, exclude_tags=None): - mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + mcp = FastMCP() @mcp.prompt(tags={"a", "b"}) def prompt_1() -> str: @@ -425,6 +425,11 @@ class TestPromptTags: def prompt_2() -> str: return "2" + if include_tags: + mcp.enable(tags=include_tags, only=True) + if exclude_tags: + mcp.disable(tags=exclude_tags) + return mcp async def test_include_tags_all_prompts(self): diff --git a/tests/server/providers/test_local_provider_resources.py b/tests/server/providers/test_local_provider_resources.py index 972b9358b..4c8da4559 100644 --- a/tests/server/providers/test_local_provider_resources.py +++ b/tests/server/providers/test_local_provider_resources.py @@ -676,7 +676,7 @@ class TestTemplateDecorator: class TestResourceTags: def create_server(self, include_tags=None, exclude_tags=None): - mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + mcp = FastMCP() @mcp.resource("resource://1", tags={"a", "b"}) def resource_1() -> str: @@ -686,6 +686,11 @@ class TestResourceTags: def resource_2() -> str: return "2" + if include_tags: + mcp.enable(tags=include_tags, only=True) + if exclude_tags: + mcp.disable(tags=exclude_tags) + return mcp async def test_include_tags_all_resources(self): @@ -823,7 +828,7 @@ class TestResourceEnabled: class TestResourceTemplatesTags: def create_server(self, include_tags=None, exclude_tags=None): - mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags) + mcp = FastMCP() @mcp.resource("resource://1/{param}", tags={"a", "b"}) def template_resource_1(param: str) -> str: @@ -833,6 +838,11 @@ class TestResourceTemplatesTags: def template_resource_2(param: str) -> str: return f"Template resource 2: {param}" + if include_tags: + mcp.enable(tags=include_tags, only=True) + if exclude_tags: + mcp.disable(tags=exclude_tags) + return mcp async def test_include_tags_all_resources(self): diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 12166715c..ba7af98d5 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -211,9 +211,9 @@ class TestAbstractCollectionTypes: "test", middleware=(), # Empty tuple tools=(Tool.from_function(dummy_tool),), # Tuple of tools - include_tags={"tag1", "tag2"}, # Set - exclude_tags={"tag3"}, # Set ) + mcp.enable(tags={"tag1", "tag2"}, only=True) + mcp.disable(tags={"tag3"}) assert mcp is not None assert mcp.name == "test" assert isinstance(mcp.middleware, list) # Should be converted to list diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index 8df506466..448e1eb03 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -281,10 +281,8 @@ class TestGetFastMCPInfo: components weren't actually available to clients. """ # Create server with include_tags that will filter out untagged components - mcp = FastMCP( - "FilteredServer", - include_tags={"fetch", "analyze", "create"}, - ) + mcp = FastMCP("FilteredServer") + mcp.enable(tags={"fetch", "analyze", "create"}, only=True) # Add tools with and without matching tags @mcp.tool(tags={"fetch"}) @@ -396,7 +394,8 @@ class TestGetFastMCPInfo: return [{"role": "user", "content": "blocked"}] # Create parent server with tag filtering - parent = FastMCP("ParentServer", include_tags={"allowed"}) + parent = FastMCP("ParentServer") + parent.enable(tags={"allowed"}, only=True) parent.mount(mounted) # Get inspect info @@ -448,7 +447,8 @@ class TestGetFastMCPInfo: return "untagged" # Create parent with exclude_tags - should filter mounted components - parent = FastMCP("ParentServer", exclude_tags={"development"}) + parent = FastMCP("ParentServer") + parent.disable(tags={"development"}) parent.mount(mounted) # Get inspect info From 50b23299f8f79dcb456696288829cd6e8b0c05ce Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:48:51 -0500 Subject: [PATCH 62/63] Support async auth checks (#3152) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- AGENTS.md | 2 +- docs/development/v3-notes/v3-features.mdx | 4 + .../python-sdk/fastmcp-resources-template.mdx | 4 +- .../fastmcp-server-auth-authorization.mdx | 14 +- ...viders-local_provider-decorators-tools.mdx | 10 +- docs/python-sdk/fastmcp-tools-tool.mdx | 24 +-- docs/servers/authorization.mdx | 32 +++- src/fastmcp/prompts/function_prompt.py | 12 +- src/fastmcp/prompts/prompt.py | 6 +- src/fastmcp/resources/function_resource.py | 8 +- src/fastmcp/resources/resource.py | 6 +- src/fastmcp/resources/template.py | 8 +- src/fastmcp/server/auth/authorization.py | 18 +- .../server/middleware/authorization.py | 14 +- .../local_provider/decorators/prompts.py | 8 +- .../local_provider/decorators/resources.py | 4 +- .../local_provider/decorators/tools.py | 9 +- src/fastmcp/server/server.py | 34 ++-- src/fastmcp/tools/function_tool.py | 12 +- src/fastmcp/tools/tool.py | 9 +- tests/server/auth/test_authorization.py | 172 ++++++++++++++++-- 21 files changed, 292 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d6ff4e9b..c726c0504 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ When modifying MCP functionality, changes typically need to be applied across al - Uses Mintlify framework - Files must be in docs.json to be included -- Never modify `docs/python-sdk/**` (auto-generated) +- Do not manually modify `docs/python-sdk/**` — a bot automatically updates these files via commits added to PRs - **Core Principle:** A feature doesn't exist unless it is documented! ### Documentation Guidelines diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 93420c6b8..f91734ba6 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -66,6 +66,10 @@ async def get_emails( Background tasks now use a distributed Redis notification queue for reliable delivery ([#2906](https://github.com/jlowin/fastmcp/pull/2906)). Elicitation switches from polling to BLPOP (single blocking call instead of ~7,200 round-trips/hour), and notification delivery retries up to 3x with TTL-based expiration. +### Async Auth Checks + +Auth check functions can now be `async`, enabling authorization decisions that depend on asynchronous operations like reading server state via `Context.get_state` or calling external services ([#3150](https://github.com/jlowin/fastmcp/issues/3150)). Sync and async checks can be freely mixed. Previously, passing an async function as an auth check would silently pass (coroutine objects are truthy). + ### Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed Sixteen deprecated keyword arguments have been removed from `FastMCP.__init__`. Passing any of them now raises `TypeError` with a migration hint. Environment variables (e.g., `FASTMCP_HOST`) continue to work — only the constructor kwargs moved. diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx index e55bae102..0baaedbd6 100644 --- a/docs/python-sdk/fastmcp-resources-template.mdx +++ b/docs/python-sdk/fastmcp-resources-template.mdx @@ -62,7 +62,7 @@ A template for dynamically creating resources. #### `from_function` ```python -from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None) -> FunctionResourceTemplate +from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate ``` #### `set_default_mime_type` @@ -237,7 +237,7 @@ FunctionResourceTemplate splats the params dict since .fn expects **kwargs. #### `from_function` ```python -from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None) -> FunctionResourceTemplate +from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate ``` Create a template from a function. diff --git a/docs/python-sdk/fastmcp-server-auth-authorization.mdx b/docs/python-sdk/fastmcp-server-auth-authorization.mdx index 6268118d8..28663f6f8 100644 --- a/docs/python-sdk/fastmcp-server-auth-authorization.mdx +++ b/docs/python-sdk/fastmcp-server-auth-authorization.mdx @@ -36,7 +36,7 @@ Example: ## Functions -### `require_scopes` +### `require_scopes` ```python require_scopes(*scopes: str) -> AuthCheck @@ -52,7 +52,7 @@ in the token (AND logic). - `*scopes`: One or more scope strings that must all be present. -### `restrict_tag` +### `restrict_tag` ```python restrict_tag(tag: str) -> AuthCheck @@ -69,7 +69,7 @@ required scopes. If the component doesn't have the tag, access is allowed. - `scopes`: List of scopes required when the tag is present. -### `run_auth_checks` +### `run_auth_checks` ```python run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool @@ -78,7 +78,8 @@ run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool Run auth checks with AND logic. -All checks must pass for authorization to succeed. +All checks must pass for authorization to succeed. Checks can be +synchronous or asynchronous functions. Auth checks can: - Return True to allow access @@ -88,6 +89,7 @@ Auth checks can: **Args:** - `checks`: A single check function or list of check functions. +Each check can be sync (returns bool) or async (returns Awaitable[bool]). - `ctx`: The auth context to pass to each check. **Returns:** @@ -99,7 +101,7 @@ Auth checks can: ## Classes -### `AuthContext` +### `AuthContext` Context passed to auth check callables. @@ -115,7 +117,7 @@ access to the current authentication token and the component being accessed. **Methods:** -#### `tool` +#### `tool` ```python tool(self) -> Tool | None diff --git a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx index bfb3ccea1..44558f560 100644 --- a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx +++ b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx @@ -14,7 +14,7 @@ registration functionality to LocalProvider. ## Classes -### `ToolDecoratorMixin` +### `ToolDecoratorMixin` Mixin class providing tool decorator functionality for LocalProvider. @@ -26,7 +26,7 @@ This mixin contains all methods related to: **Methods:** -#### `add_tool` +#### `add_tool` ```python add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool @@ -37,19 +37,19 @@ Add a tool to this provider's storage. Accepts either a Tool object or a decorated function with __fastmcp__ metadata. -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx index 1068a7716..f13d63f1c 100644 --- a/docs/python-sdk/fastmcp-tools-tool.mdx +++ b/docs/python-sdk/fastmcp-tools-tool.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool ## Functions -### `default_serializer` +### `default_serializer` ```python default_serializer(data: Any) -> str @@ -15,17 +15,17 @@ default_serializer(data: Any) -> str ## Classes -### `ToolResult` +### `ToolResult` **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ``` -### `Tool` +### `Tool` Internal tool registration info. @@ -33,7 +33,7 @@ Internal tool registration info. **Methods:** -#### `to_mcp_tool` +#### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> MCPTool @@ -42,7 +42,7 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool Convert the FastMCP tool to an MCP tool. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool @@ -51,7 +51,7 @@ from_function(cls, fn: Callable[..., Any]) -> FunctionTool Create a Tool from a function. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -66,7 +66,7 @@ implemented by subclasses. (list of ContentBlocks, dict of structured output). -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ToolResult @@ -78,7 +78,7 @@ Handles ToolResult passthrough and converts raw values using the tool's attributes (serializer, output_schema) for proper conversion. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -87,7 +87,7 @@ register_with_docket(self, docket: Docket) -> None Register this tool with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution @@ -103,13 +103,13 @@ Schedule this tool for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool) -> TransformedTool ``` -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/servers/authorization.mdx b/docs/servers/authorization.mdx index 0ad0d569e..a48d2a9e8 100644 --- a/docs/servers/authorization.mdx +++ b/docs/servers/authorization.mdx @@ -24,7 +24,7 @@ When an `AuthProvider` is configured, all requests to the MCP endpoint must carr ## 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. +An auth check is any callable that accepts an `AuthContext` and returns a boolean. Auth checks can be synchronous or asynchronous, so checks that need to perform async operations (like reading server state or calling external services) work naturally. ```python from fastmcp.server.auth import AuthContext @@ -137,6 +137,34 @@ def advanced_feature() -> str: return "Advanced feature" ``` +### Async Auth Checks + +Auth checks can be `async` functions, which is useful when the authorization decision depends on asynchronous operations like reading server state or querying external services. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth import AuthContext + +mcp = FastMCP("Async Auth Server") + +async def check_user_permissions(ctx: AuthContext) -> bool: + """Async auth check that reads server state.""" + if ctx.token is None: + return False + user_id = ctx.token.claims.get("sub") + # Async operations work naturally in auth checks + permissions = await fetch_user_permissions(user_id) + return "admin" in permissions + +@mcp.tool(auth=check_user_permissions) +def admin_tool() -> str: + return "Admin action completed" +``` + +Sync and async checks can be freely combined in a list — each check is handled according to its type. + +### Error Handling + Auth checks can raise exceptions for explicit denial with custom messages: - **`AuthorizationError`**: Propagates with its custom message, useful for explaining why access was denied @@ -346,7 +374,7 @@ def require_matching_tag(ctx: AuthContext) -> bool: 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] + AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool] require_scopes, # Built-in: requires specific scopes restrict_tag, # Built-in: tag-based scope requirements run_auth_checks, # Utility: run checks with AND logic diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index 916838ede..a58700a01 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -25,12 +25,12 @@ import fastmcp from fastmcp.decorators import resolve_task_config from fastmcp.exceptions import PromptError from fastmcp.prompts.prompt import Prompt, PromptArgument, PromptResult +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.tool import AuthCheckCallable from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -67,7 +67,7 @@ class PromptMeta: tags: set[str] | None = None meta: dict[str, Any] | None = None task: bool | TaskConfig | None = None - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None + auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True @@ -91,7 +91,7 @@ class FunctionPrompt(Prompt): tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. @@ -377,7 +377,7 @@ def prompt( tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @overload def prompt( @@ -391,7 +391,7 @@ def prompt( tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @@ -406,7 +406,7 @@ def prompt( tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Any: """Standalone decorator to mark a function as an MCP prompt. diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 8cf2d3265..07540629e 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -27,8 +27,8 @@ from mcp.types import PromptArgument as SDKPromptArgument from pydantic import Field from pydantic.json_schema import SkipJsonSchema +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.tools.tool import AuthCheckCallable from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( @@ -195,7 +195,7 @@ class Prompt(FastMCPComponent): arguments: list[PromptArgument] | None = Field( default=None, description="Arguments that can be passed to the prompt" ) - auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field( + auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field( default=None, description="Authorization checks for this prompt", exclude=True ) @@ -237,7 +237,7 @@ class Prompt(FastMCPComponent): tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index 76c6f974c..bf6673552 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -16,12 +16,12 @@ import fastmcp from fastmcp.decorators import resolve_task_config from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.server.apps import resolve_ui_mime_type +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.tool import AuthCheckCallable from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool if TYPE_CHECKING: @@ -57,7 +57,7 @@ class ResourceMeta: annotations: Annotations | None = None meta: dict[str, Any] | None = None task: bool | TaskConfig | None = None - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None + auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True @@ -94,7 +94,7 @@ class FunctionResource(Resource): annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResource: """Create a FunctionResource from a function. @@ -246,7 +246,7 @@ def resource( annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: """Standalone decorator to mark a function as an MCP resource. diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 1f36fd5e2..26ed535de 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -29,8 +29,8 @@ from pydantic import ( from pydantic.json_schema import SkipJsonSchema from typing_extensions import Self +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.tools.tool import AuthCheckCallable from fastmcp.utilities.components import FastMCPComponent @@ -227,7 +227,7 @@ class Resource(FastMCPComponent): Field(description="Optional annotations about the resource's behavior"), ] = None auth: Annotated[ - SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None], + SkipJsonSchema[AuthCheck | list[AuthCheck] | None], Field(description="Authorization checks for this resource", exclude=True), ] = None @@ -247,7 +247,7 @@ class Resource(FastMCPComponent): annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResource: from fastmcp.resources.function_resource import ( FunctionResource, diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 8ce650073..c2fb1b622 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -24,12 +24,12 @@ from pydantic import ( from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.server.apps import resolve_ui_mime_type +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.tools.tool import AuthCheckCallable from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.types import get_cached_typeadapter @@ -117,7 +117,7 @@ class ResourceTemplate(FastMCPComponent): annotations: Annotations | None = Field( default=None, description="Optional annotations about the resource's behavior" ) - auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field( + auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field( default=None, description="Authorization checks for this resource template", exclude=True, @@ -140,7 +140,7 @@ class ResourceTemplate(FastMCPComponent): annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResourceTemplate: return FunctionResourceTemplate.from_function( fn=fn, @@ -471,7 +471,7 @@ class FunctionResourceTemplate(ResourceTemplate): annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResourceTemplate: """Create a template from a function.""" diff --git a/src/fastmcp/server/auth/authorization.py b/src/fastmcp/server/auth/authorization.py index dd0e16cc1..8455b81f5 100644 --- a/src/fastmcp/server/auth/authorization.py +++ b/src/fastmcp/server/auth/authorization.py @@ -28,8 +28,9 @@ Example: from __future__ import annotations +import inspect import logging -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -70,8 +71,8 @@ class AuthContext: return self.component if isinstance(self.component, Tool) else None -# Type alias for auth check functions -AuthCheck = Callable[[AuthContext], bool] +# Type alias for auth check functions (sync or async) +AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]] def require_scopes(*scopes: str) -> AuthCheck: @@ -130,13 +131,14 @@ def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck: return check -def run_auth_checks( +async def run_auth_checks( checks: AuthCheck | list[AuthCheck], ctx: AuthContext, ) -> bool: """Run auth checks with AND logic. - All checks must pass for authorization to succeed. + All checks must pass for authorization to succeed. Checks can be + synchronous or asynchronous functions. Auth checks can: - Return True to allow access @@ -146,6 +148,7 @@ def run_auth_checks( Args: checks: A single check function or list of check functions. + Each check can be sync (returns bool) or async (returns Awaitable[bool]). ctx: The auth context to pass to each check. Returns: @@ -159,7 +162,10 @@ def run_auth_checks( for check in check_list: try: - if not check(ctx): + result = check(ctx) + if inspect.isawaitable(result): + result = await result + if not result: return False except AuthorizationError: # Let AuthorizationError propagate with its custom message diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py index 6a50ed656..abe33447b 100644 --- a/src/fastmcp/server/middleware/authorization.py +++ b/src/fastmcp/server/middleware/authorization.py @@ -102,7 +102,7 @@ class AuthMiddleware(Middleware): authorized_tools: list[Tool] = [] for tool in tools: ctx = AuthContext(token=token, component=tool) - if run_auth_checks(self.auth, ctx): + if await run_auth_checks(self.auth, ctx): authorized_tools.append(tool) return authorized_tools @@ -143,7 +143,7 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=tool) - if not run_auth_checks(self.auth, ctx): + if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for tool '{tool_name}': insufficient permissions" ) @@ -169,7 +169,7 @@ class AuthMiddleware(Middleware): authorized_resources: list[Resource] = [] for resource in resources: ctx = AuthContext(token=token, component=resource) - if run_auth_checks(self.auth, ctx): + if await run_auth_checks(self.auth, ctx): authorized_resources.append(resource) return authorized_resources @@ -210,7 +210,7 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=component) - if not run_auth_checks(self.auth, ctx): + if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for resource '{uri}': insufficient permissions" ) @@ -238,7 +238,7 @@ class AuthMiddleware(Middleware): authorized_templates: list[ResourceTemplate] = [] for template in templates: ctx = AuthContext(token=token, component=template) - if run_auth_checks(self.auth, ctx): + if await run_auth_checks(self.auth, ctx): authorized_templates.append(template) return authorized_templates @@ -262,7 +262,7 @@ class AuthMiddleware(Middleware): authorized_prompts: list[Prompt] = [] for prompt in prompts: ctx = AuthContext(token=token, component=prompt) - if run_auth_checks(self.auth, ctx): + if await run_auth_checks(self.auth, ctx): authorized_prompts.append(prompt) return authorized_prompts @@ -301,7 +301,7 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=prompt) - if not run_auth_checks(self.auth, ctx): + if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for prompt '{prompt_name}': insufficient permissions" ) diff --git a/src/fastmcp/server/providers/local_provider/decorators/prompts.py b/src/fastmcp/server/providers/local_provider/decorators/prompts.py index a25d8fa52..5e01b7a04 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/prompts.py +++ b/src/fastmcp/server/providers/local_provider/decorators/prompts.py @@ -17,8 +17,8 @@ from mcp.types import AnyFunction import fastmcp from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.prompts.prompt import Prompt +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.tool import AuthCheckCallable if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider @@ -82,7 +82,7 @@ class PromptDecoratorMixin: enabled: bool = True, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: ... @overload @@ -99,7 +99,7 @@ class PromptDecoratorMixin: enabled: bool = True, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[AnyFunction], FunctionPrompt]: ... def prompt( @@ -115,7 +115,7 @@ class PromptDecoratorMixin: enabled: bool = True, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt diff --git a/src/fastmcp/server/providers/local_provider/decorators/resources.py b/src/fastmcp/server/providers/local_provider/decorators/resources.py index f6985b164..52314378e 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/resources.py +++ b/src/fastmcp/server/providers/local_provider/decorators/resources.py @@ -17,8 +17,8 @@ import fastmcp from fastmcp.resources.function_resource import resource as standalone_resource from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.tool import AuthCheckCallable if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider @@ -117,7 +117,7 @@ class ResourceDecoratorMixin: annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]: """Decorator to register a function as a resource. diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index 7527f1b76..c59fa8a3f 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -16,9 +16,10 @@ import mcp.types from mcp.types import AnyFunction, ToolAnnotations import fastmcp +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import AuthCheckCallable, Tool +from fastmcp.tools.tool import Tool from fastmcp.utilities.types import NotSet, NotSetT if TYPE_CHECKING: @@ -105,7 +106,7 @@ class ToolDecoratorMixin: task: bool | TaskConfig | None = None, serializer: ToolResultSerializerType | None = None, # Deprecated timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionTool: ... @overload @@ -127,7 +128,7 @@ class ToolDecoratorMixin: task: bool | TaskConfig | None = None, serializer: ToolResultSerializerType | None = None, # Deprecated timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[AnyFunction], FunctionTool]: ... # NOTE: This method mirrors fastmcp.tools.tool() but adds registration, @@ -152,7 +153,7 @@ class ToolDecoratorMixin: task: bool | TaskConfig | None = None, serializer: ToolResultSerializerType | None = None, # Deprecated timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionTool] | FunctionTool diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 66c60d978..bd0e5e264 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -61,7 +61,7 @@ from fastmcp.server.apps import ( app_config_to_meta_dict, resolve_ui_mime_type, ) -from fastmcp.server.auth import AuthContext, AuthProvider, run_auth_checks +from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks from fastmcp.server.dependencies import get_access_token from fastmcp.server.lifespan import Lifespan from fastmcp.server.low_level import LowLevelServer @@ -78,7 +78,7 @@ from fastmcp.server.transforms import ( from fastmcp.server.transforms.visibility import apply_session_transforms, is_enabled from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import AuthCheckCallable, Tool, ToolResult +from fastmcp.tools.tool import Tool, ToolResult from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger @@ -509,7 +509,7 @@ class FastMCP( if not skip_auth and tool.auth is not None: ctx = AuthContext(token=token, component=tool) try: - if not run_auth_checks(tool.auth, ctx): + if not await run_auth_checks(tool.auth, ctx): continue except AuthorizationError: continue @@ -540,7 +540,7 @@ class FastMCP( if not skip_auth and tool.auth is not None: ctx = AuthContext(token=token, component=tool) try: - if not run_auth_checks(tool.auth, ctx): + if not await run_auth_checks(tool.auth, ctx): return None except AuthorizationError: return None @@ -607,7 +607,7 @@ class FastMCP( if not skip_auth and resource.auth is not None: ctx = AuthContext(token=token, component=resource) try: - if not run_auth_checks(resource.auth, ctx): + if not await run_auth_checks(resource.auth, ctx): continue except AuthorizationError: continue @@ -638,7 +638,7 @@ class FastMCP( if not skip_auth and resource.auth is not None: ctx = AuthContext(token=token, component=resource) try: - if not run_auth_checks(resource.auth, ctx): + if not await run_auth_checks(resource.auth, ctx): return None except AuthorizationError: return None @@ -706,7 +706,7 @@ class FastMCP( if not skip_auth and template.auth is not None: ctx = AuthContext(token=token, component=template) try: - if not run_auth_checks(template.auth, ctx): + if not await run_auth_checks(template.auth, ctx): continue except AuthorizationError: continue @@ -737,7 +737,7 @@ class FastMCP( if not skip_auth and template.auth is not None: ctx = AuthContext(token=token, component=template) try: - if not run_auth_checks(template.auth, ctx): + if not await run_auth_checks(template.auth, ctx): return None except AuthorizationError: return None @@ -801,7 +801,7 @@ class FastMCP( if not skip_auth and prompt.auth is not None: ctx = AuthContext(token=token, component=prompt) try: - if not run_auth_checks(prompt.auth, ctx): + if not await run_auth_checks(prompt.auth, ctx): continue except AuthorizationError: continue @@ -832,7 +832,7 @@ class FastMCP( if not skip_auth and prompt.auth is not None: ctx = AuthContext(token=token, component=prompt) try: - if not run_auth_checks(prompt.auth, ctx): + if not await run_auth_checks(prompt.auth, ctx): return None except AuthorizationError: return None @@ -1283,7 +1283,7 @@ class FastMCP( app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionTool: ... @overload @@ -1304,7 +1304,7 @@ class FastMCP( app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[AnyFunction], FunctionTool]: ... def tool( @@ -1324,7 +1324,7 @@ class FastMCP( app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionTool] | FunctionTool @@ -1445,7 +1445,7 @@ class FastMCP( meta: dict[str, Any] | None = None, app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]: """Decorator to register a function as a resource. @@ -1576,7 +1576,7 @@ class FastMCP( tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: ... @overload @@ -1592,7 +1592,7 @@ class FastMCP( tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[AnyFunction], FunctionPrompt]: ... def prompt( @@ -1607,7 +1607,7 @@ class FastMCP( tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 812f5108c..6c1a361f6 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -24,11 +24,11 @@ from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import without_injected_parameters from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema from fastmcp.tools.tool import ( - AuthCheckCallable, Tool, ToolResult, ToolResultSerializerType, @@ -78,7 +78,7 @@ class ToolMeta: exclude_args: list[str] | None = None serializer: Any | None = None timeout: float | None = None - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None + auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True @@ -123,7 +123,7 @@ class FunctionTool(Tool): meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionTool: """Create a FunctionTool from a function. @@ -345,7 +345,7 @@ def tool( exclude_args: list[str] | None = None, serializer: Any | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @overload def tool( @@ -364,7 +364,7 @@ def tool( exclude_args: list[str] | None = None, serializer: Any | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @@ -384,7 +384,7 @@ def tool( exclude_args: list[str] | None = None, serializer: Any | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> Any: """Standalone decorator to mark a function as an MCP tool. diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index e13cda280..bdacdac58 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -26,6 +26,7 @@ from mcp.types import Tool as MCPTool from pydantic import BaseModel, Field, model_validator from pydantic.json_schema import SkipJsonSchema +from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger @@ -37,10 +38,6 @@ from fastmcp.utilities.types import ( NotSetT, ) -# Runtime type alias for auth checks to avoid circular imports with authorization.py -# AuthCheck is Callable[[AuthContext], bool] but we use Any to avoid the import -AuthCheckCallable: TypeAlias = Callable[[Any], bool] - if TYPE_CHECKING: from docket import Docket from docket.execution import Execution @@ -147,7 +144,7 @@ class Tool(FastMCPComponent): ), ] = None auth: Annotated[ - SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None], + SkipJsonSchema[AuthCheck | list[AuthCheck] | None], Field(description="Authorization checks for this tool", exclude=True), ] = None timeout: Annotated[ @@ -207,7 +204,7 @@ class Tool(FastMCPComponent): meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, - auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionTool: """Create a Tool from a function.""" from fastmcp.tools.function_tool import FunctionTool diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index 6bab4cecd..4bd0dff9a 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -120,31 +120,31 @@ class TestRestrictTag: class TestRunAuthChecks: - def test_single_check_passes(self): + async def test_single_check_passes(self): ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool()) - assert run_auth_checks(require_scopes("test"), ctx) is True + assert await run_auth_checks(require_scopes("test"), ctx) is True - def test_single_check_fails(self): + async def test_single_check_fails(self): ctx = AuthContext(token=None, component=make_tool()) - assert run_auth_checks(require_scopes("test"), ctx) is False + assert await run_auth_checks(require_scopes("test"), ctx) is False - def test_multiple_checks_all_pass(self): + async def test_multiple_checks_all_pass(self): token = make_token(scopes=["test", "admin"]) ctx = AuthContext(token=token, component=make_tool()) checks = [require_scopes("test"), require_scopes("admin")] - assert run_auth_checks(checks, ctx) is True + assert await run_auth_checks(checks, ctx) is True - def test_multiple_checks_one_fails(self): + async def test_multiple_checks_one_fails(self): token = make_token(scopes=["read"]) ctx = AuthContext(token=token, component=make_tool()) checks = [require_scopes("read"), require_scopes("admin")] - assert run_auth_checks(checks, ctx) is False + assert await run_auth_checks(checks, ctx) is False - def test_empty_list_passes(self): + async def test_empty_list_passes(self): ctx = AuthContext(token=None, component=make_tool()) - assert run_auth_checks([], ctx) is True + assert await run_auth_checks([], ctx) is True - def test_custom_lambda_check(self): + async def test_custom_lambda_check(self): token = make_token() token.claims = {"level": 5} ctx = AuthContext(token=token, component=make_tool()) @@ -152,9 +152,9 @@ class TestRunAuthChecks: def check(ctx: AuthContext) -> bool: return ctx.token is not None and ctx.token.claims.get("level", 0) >= 3 - assert run_auth_checks(check, ctx) is True + assert await run_auth_checks(check, ctx) is True - def test_authorization_error_propagates(self): + async def test_authorization_error_propagates(self): """AuthorizationError from auth check should propagate with custom message.""" from fastmcp.exceptions import AuthorizationError @@ -163,9 +163,9 @@ class TestRunAuthChecks: ctx = AuthContext(token=make_token(), component=make_tool()) with pytest.raises(AuthorizationError, match="Custom denial reason"): - run_auth_checks(custom_auth_check, ctx) + await run_auth_checks(custom_auth_check, ctx) - def test_generic_exception_is_masked(self): + async def test_generic_exception_is_masked(self): """Generic exceptions from auth checks should be masked (return False).""" def buggy_auth_check(ctx: AuthContext) -> bool: @@ -173,9 +173,9 @@ class TestRunAuthChecks: ctx = AuthContext(token=make_token(), component=make_tool()) # Should return False, not raise the ValueError - assert run_auth_checks(buggy_auth_check, ctx) is False + assert await run_auth_checks(buggy_auth_check, ctx) is False - def test_authorization_error_stops_chain(self): + async def test_authorization_error_stops_chain(self): """AuthorizationError should stop the check chain and propagate.""" from fastmcp.exceptions import AuthorizationError @@ -195,11 +195,62 @@ class TestRunAuthChecks: ctx = AuthContext(token=make_token(), component=make_tool()) with pytest.raises(AuthorizationError, match="Explicit denial"): - run_auth_checks([check_1, check_2, check_3], ctx) + await run_auth_checks([check_1, check_2, check_3], ctx) # Check 3 should not be called assert call_order == [1, 2] + async def test_async_check_passes(self): + """Async auth check functions should be awaited.""" + + async def async_check(ctx: AuthContext) -> bool: + return ctx.token is not None + + ctx = AuthContext(token=make_token(), component=make_tool()) + assert await run_auth_checks(async_check, ctx) is True + + async def test_async_check_fails(self): + """Async auth check that returns False should deny access.""" + + async def async_check(ctx: AuthContext) -> bool: + return False + + ctx = AuthContext(token=make_token(), component=make_tool()) + assert await run_auth_checks(async_check, ctx) is False + + async def test_mixed_sync_and_async_checks(self): + """A mix of sync and async checks should all be evaluated.""" + + def sync_check(ctx: AuthContext) -> bool: + return True + + async def async_check(ctx: AuthContext) -> bool: + return ctx.token is not None + + ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool()) + checks = [sync_check, async_check, require_scopes("test")] + assert await run_auth_checks(checks, ctx) is True + + async def test_async_check_exception_is_masked(self): + """Async checks that raise non-AuthorizationError should be masked.""" + + async def buggy_async_check(ctx: AuthContext) -> bool: + raise ValueError("async error") + + ctx = AuthContext(token=make_token(), component=make_tool()) + assert await run_auth_checks(buggy_async_check, ctx) is False + + async def test_async_check_authorization_error_propagates(self): + """Async checks that raise AuthorizationError should propagate.""" + from fastmcp.exceptions import AuthorizationError + + async def async_denial(ctx: AuthContext) -> bool: + raise AuthorizationError("Async denial") + + ctx = AuthContext(token=make_token(), component=make_tool()) + with pytest.raises(AuthorizationError, match="Async denial"): + await run_auth_checks(async_denial, ctx) + # ============================================================================= # Tests for tool-level auth with FastMCP @@ -454,6 +505,91 @@ class TestAuthIntegration: auth_context_var.reset(tok) +# ============================================================================= +# Integration tests with async auth checks +# ============================================================================= + + +class TestAsyncAuthIntegration: + async def test_async_auth_check_filters_tool_listing(self): + """Async auth checks should work for filtering tool lists.""" + mcp = FastMCP() + + async def check_claims(ctx: AuthContext) -> bool: + return ctx.token is not None and ctx.token.claims.get("role") == "admin" + + @mcp.tool(auth=check_claims) + def admin_tool() -> str: + return "admin" + + @mcp.tool + def public_tool() -> str: + return "public" + + # Without token, only public tool visible + tools = await mcp.list_tools() + assert len(tools) == 1 + assert tools[0].name == "public_tool" + + # With correct claims, both visible + token = make_token() + token.claims = {"role": "admin"} + tok = set_token(token) + try: + tools = await mcp.list_tools() + assert len(tools) == 2 + finally: + auth_context_var.reset(tok) + + async def test_async_auth_check_on_tool_call(self): + """Async auth checks should work for tool execution via client.""" + mcp = FastMCP() + + async def check_claims(ctx: AuthContext) -> bool: + return ctx.token is not None and ctx.token.claims.get("role") == "admin" + + @mcp.tool(auth=check_claims) + def admin_tool() -> str: + return "secret" + + token = make_token() + token.claims = {"role": "admin"} + tok = set_token(token) + try: + async with Client(mcp) as client: + result = await client.call_tool("admin_tool", {}) + assert result.content[0].text == "secret" + finally: + auth_context_var.reset(tok) + + async def test_async_auth_middleware(self): + """Async auth checks should work with AuthMiddleware.""" + + async def async_scope_check(ctx: AuthContext) -> bool: + return ctx.token is not None and "api" in ctx.token.scopes + + mcp = FastMCP(middleware=[AuthMiddleware(auth=async_scope_check)]) + + @mcp.tool + def api_tool() -> str: + return "api" + + # Without token, tool is hidden + result = await mcp._list_tools_mcp(__import__("mcp").types.ListToolsRequest()) + assert len(result.tools) == 0 + + # With token containing "api" scope, tool is visible + token = make_token(scopes=["api"]) + tok = set_token(token) + try: + result = await mcp._list_tools_mcp( + __import__("mcp").types.ListToolsRequest() + ) + assert len(result.tools) == 1 + finally: + auth_context_var.reset(tok) + + # ============================================================================= # Tests for transformed tools preserving auth # ============================================================================= From fe57c3d689c2d3f016959f3c6cf79ce53c8c2ad3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:45:37 -0500 Subject: [PATCH 63/63] Make $ref dereferencing optional via FastMCP(dereference_refs=...) (#3151) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/development/v3-notes/v3-features.mdx | 10 ++ .../fastmcp-server-middleware-dereference.mdx | 35 +++++ docs/python-sdk/fastmcp-server-server.mdx | 96 ++++++------- .../fastmcp-utilities-json_schema.mdx | 10 +- docs/servers/tools.mdx | 6 + src/fastmcp/server/middleware/dereference.py | 78 ++++++++++ src/fastmcp/server/server.py | 8 ++ src/fastmcp/utilities/json_schema.py | 33 ++--- tests/server/middleware/test_caching.py | 2 +- tests/server/middleware/test_dereference.py | 136 ++++++++++++++++++ tests/tools/tool/test_tool.py | 9 +- tests/tools/tool_transform/test_schemas.py | 66 +++++---- .../tool_transform/test_tool_transform.py | 13 +- tests/utilities/openapi/test_schemas.py | 33 +++-- tests/utilities/test_json_schema.py | 53 ++++++- 15 files changed, 460 insertions(+), 128 deletions(-) create mode 100644 docs/python-sdk/fastmcp-server-middleware-dereference.mdx create mode 100644 src/fastmcp/server/middleware/dereference.py create mode 100644 tests/server/middleware/test_dereference.py diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index f91734ba6..67474880d 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -70,6 +70,16 @@ Background tasks now use a distributed Redis notification queue for reliable del Auth check functions can now be `async`, enabling authorization decisions that depend on asynchronous operations like reading server state via `Context.get_state` or calling external services ([#3150](https://github.com/jlowin/fastmcp/issues/3150)). Sync and async checks can be freely mixed. Previously, passing an async function as an auth check would silently pass (coroutine objects are truthy). +### Optional `$ref` Dereferencing in Schemas + +Schema `$ref` dereferencing — which inlines all `$defs` for compatibility with MCP clients that don't handle `$ref` — is now controlled by the `dereference_schemas` constructor kwarg ([#3141](https://github.com/jlowin/fastmcp/issues/3141)). Default is `True` (dereference on) because the non-compliant clients are popular and the failure mode is silent breakage that server authors can't diagnose. Opt out when you know your clients handle `$ref` and want smaller schemas: + +```python +mcp = FastMCP("my-server", dereference_schemas=False) +``` + +Dereferencing is implemented as middleware (`DereferenceRefsMiddleware`) that runs at serve-time, so schemas are stored with `$ref` intact and only inlined when sent to clients. + ### Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed Sixteen deprecated keyword arguments have been removed from `FastMCP.__init__`. Passing any of them now raises `TypeError` with a migration hint. Environment variables (e.g., `FASTMCP_HOST`) continue to work — only the constructor kwargs moved. diff --git a/docs/python-sdk/fastmcp-server-middleware-dereference.mdx b/docs/python-sdk/fastmcp-server-middleware-dereference.mdx new file mode 100644 index 000000000..702c7a1d7 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-middleware-dereference.mdx @@ -0,0 +1,35 @@ +--- +title: dereference +sidebarTitle: dereference +--- + +# `fastmcp.server.middleware.dereference` + + +Middleware that dereferences $ref in JSON schemas before sending to clients. + +## Classes + +### `DereferenceRefsMiddleware` + + +Dereferences $ref in component schemas before sending to clients. + +Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref +properly. This middleware inlines all $ref definitions so schemas are +self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``. + + +**Methods:** + +#### `on_list_tools` + +```python +on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] +``` + +#### `on_list_resource_templates` + +```python +on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] +``` diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index efaa20d12..cf15f236b 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -64,49 +64,49 @@ Wrapper for stored context state values. **Methods:** -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -126,7 +126,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -138,7 +138,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -153,7 +153,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -165,7 +165,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -177,7 +177,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -190,7 +190,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -210,7 +210,7 @@ session transforms can override provider-level disables. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -223,7 +223,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -242,7 +242,7 @@ transforms (including session-level) have been applied. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -255,7 +255,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -274,7 +274,7 @@ all transforms (including session-level) have been applied. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -287,7 +287,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -306,19 +306,19 @@ transforms (including session-level) have been applied. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -348,19 +348,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -389,19 +389,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -431,7 +431,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -449,7 +449,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -465,19 +465,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -533,7 +533,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -548,7 +548,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -563,7 +563,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction] @@ -622,7 +622,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -637,19 +637,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -726,7 +726,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -773,7 +773,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -814,7 +814,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self @@ -843,7 +843,7 @@ response structure while still returning structured JSON. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -867,7 +867,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -885,7 +885,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index aa8c7b2d5..f45ea0161 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -60,17 +60,12 @@ the referenced definition while preserving $defs for nested references. ### `compress_schema` ```python -compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, 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, dereference: bool = False) -> dict[str, Any] ``` Compress and optimize a JSON schema for MCP compatibility. -This function dereferences all $ref entries (inlining definitions) to ensure -compatibility with MCP clients that don't properly handle $ref in schemas -(e.g., VS Code Copilot). It also applies various optimizations to reduce -schema size. - **Args:** - `schema`: The schema to compress - `prune_params`: List of parameter names to remove from properties @@ -78,4 +73,7 @@ schema size. 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`: Whether to dereference $ref by inlining definitions. +Defaults to False; dereferencing is typically handled by +middleware at serve-time instead. diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 98c8c0951..ee5004ae6 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -175,6 +175,12 @@ By default, FastMCP converts Python functions into MCP tools by inspecting the f FastMCP automatically dereferences `$ref` entries in tool schemas to ensure compatibility with MCP clients that don't fully support JSON Schema references (e.g., VS Code Copilot, Claude Desktop). This means complex Pydantic models with shared types are inlined in the schema rather than using `$defs` references. + +Dereferencing happens at serve-time via middleware, so your schemas are stored with `$ref` intact and only inlined when sent to clients. If you know your clients handle `$ref` correctly and prefer smaller schemas, you can opt out: + +```python +mcp = FastMCP("my-server", dereference_schemas=False) +``` ### Type Annotations diff --git a/src/fastmcp/server/middleware/dereference.py b/src/fastmcp/server/middleware/dereference.py new file mode 100644 index 000000000..89150d655 --- /dev/null +++ b/src/fastmcp/server/middleware/dereference.py @@ -0,0 +1,78 @@ +"""Middleware that dereferences $ref in JSON schemas before sending to clients.""" + +from collections.abc import Sequence +from typing import Any + +import mcp.types as mt +from typing_extensions import override + +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.tool import Tool +from fastmcp.utilities.json_schema import dereference_refs + + +class DereferenceRefsMiddleware(Middleware): + """Dereferences $ref in component schemas before sending to clients. + + Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref + properly. This middleware inlines all $ref definitions so schemas are + self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``. + """ + + @override + async def on_list_tools( + self, + context: MiddlewareContext[mt.ListToolsRequest], + call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], + ) -> Sequence[Tool]: + tools = await call_next(context) + return [_dereference_tool(tool) for tool in tools] + + @override + async def on_list_resource_templates( + self, + context: MiddlewareContext[mt.ListResourceTemplatesRequest], + call_next: CallNext[ + mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] + ], + ) -> Sequence[ResourceTemplate]: + templates = await call_next(context) + return [_dereference_resource_template(t) for t in templates] + + +def _dereference_tool(tool: Tool) -> Tool: + """Return a copy of the tool with dereferenced schemas.""" + updates: dict[str, object] = {} + if "$defs" in tool.parameters or _has_ref(tool.parameters): + updates["parameters"] = dereference_refs(tool.parameters) + if tool.output_schema is not None and ( + "$defs" in tool.output_schema or _has_ref(tool.output_schema) + ): + updates["output_schema"] = dereference_refs(tool.output_schema) + if updates: + return tool.model_copy(update=updates) + return tool + + +def _dereference_resource_template(template: ResourceTemplate) -> ResourceTemplate: + """Return a copy of the template with dereferenced schemas.""" + if "$defs" in template.parameters or _has_ref(template.parameters): + return template.model_copy( + update={"parameters": dereference_refs(template.parameters)} + ) + return template + + +def _has_ref(schema: dict[str, Any]) -> bool: + """Check if a schema contains any $ref.""" + if "$ref" in schema: + return True + for value in schema.values(): + if isinstance(value, dict) and _has_ref(value): + return True + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and _has_ref(item): + return True + return False diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index bd0e5e264..9d1347789 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -229,6 +229,7 @@ class FastMCP( tools: Sequence[Tool | Callable[..., Any]] | None = None, on_duplicate: DuplicateBehavior | None = None, mask_error_details: bool | None = None, + dereference_schemas: bool = True, strict_input_validation: bool | None = None, list_page_size: int | None = None, tasks: bool | None = None, @@ -322,6 +323,13 @@ class FastMCP( self.middleware: list[Middleware] = list(middleware or []) + if dereference_schemas: + from fastmcp.server.middleware.dereference import ( + DereferenceRefsMiddleware, + ) + + self.middleware.append(DereferenceRefsMiddleware()) + # Set up MCP protocol handlers self._setup_handlers() diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index f302e1cd1..da713f802 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -366,15 +366,11 @@ def compress_schema( prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, + dereference: bool = False, ) -> dict[str, Any]: """ Compress and optimize a JSON schema for MCP compatibility. - This function dereferences all $ref entries (inlining definitions) to ensure - compatibility with MCP clients that don't properly handle $ref in schemas - (e.g., VS Code Copilot). It also applies various optimizations to reduce - schema size. - Args: schema: The schema to compress prune_params: List of parameter names to remove from properties @@ -382,22 +378,27 @@ def compress_schema( 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: Whether to dereference $ref by inlining definitions. + Defaults to False; dereferencing is typically handled by + middleware at serve-time instead. """ - # Dereference $ref - this inlines all definitions and removes $defs - # Required for MCP client compatibility - schema = dereference_refs(schema) + if dereference: + schema = dereference_refs(schema) + + # Resolve root-level $ref for MCP spec compliance (requires type: object at root) + schema = resolve_root_ref(schema) # Remove specific parameters if requested for param in prune_params or []: schema = _prune_param(schema, param=param) - # Apply combined optimizations in a single tree traversal - if prune_titles or prune_additional_properties: - schema = _single_pass_optimize( - schema, - prune_titles=prune_titles, - prune_additional_properties=prune_additional_properties, - prune_defs=False, - ) + # Apply combined optimizations in a single tree traversal. + # Always prune unused $defs to keep schemas clean after parameter removal. + schema = _single_pass_optimize( + schema, + prune_titles=prune_titles, + prune_additional_properties=prune_additional_properties, + prune_defs=True, + ) return schema diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index d326e8236..52e5c90c9 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -288,7 +288,7 @@ class TestResponseCachingMiddlewareIntegration: request: pytest.FixtureRequest, ): """Create a FastMCP server for caching tests.""" - mcp = FastMCP("CachingTestServer") + mcp = FastMCP("CachingTestServer", dereference_schemas=False) with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: disk_store: DiskStore = DiskStore(directory=temp_dir) diff --git a/tests/server/middleware/test_dereference.py b/tests/server/middleware/test_dereference.py new file mode 100644 index 000000000..0ababd7de --- /dev/null +++ b/tests/server/middleware/test_dereference.py @@ -0,0 +1,136 @@ +"""Tests for DereferenceRefsMiddleware.""" + +from enum import Enum + +import pydantic + +from fastmcp import Client, FastMCP + + +class Color(Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + +class PaintRequest(pydantic.BaseModel): + color: Color + opacity: float = 1.0 + + +class TestDereferenceRefsMiddleware: + """End-to-end tests for the dereference_schemas server kwarg.""" + + async def test_dereference_schemas_true_inlines_refs(self): + """With dereference_schemas=True (default), tool schemas have $ref inlined.""" + mcp = FastMCP("test", dereference_schemas=True) + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + # $defs should be removed — everything inlined + assert "$defs" not in schema + # The Color enum should be inlined into the request property + assert "$ref" not in str(schema) + + async def test_dereference_schemas_false_preserves_refs(self): + """With dereference_schemas=False, $ref and $defs are preserved.""" + mcp = FastMCP("test", dereference_schemas=False) + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + # $defs should still be present + assert "$defs" in schema + + async def test_default_is_true(self): + """Default behavior dereferences $ref.""" + mcp = FastMCP("test") + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + assert "$defs" not in schema + + async def test_does_not_mutate_original_tool(self): + """Middleware should not mutate the shared Tool object.""" + mcp = FastMCP("test", dereference_schemas=True) + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + # Get the original tool's parameters before middleware runs + original_tools = await mcp._local_provider._list_tools() + assert "$defs" in original_tools[0].parameters + + # List tools through the client (triggers middleware) + async with Client(mcp) as client: + await client.list_tools() + + # The original tool stored in the server should still have $defs + tools_after = await mcp._local_provider._list_tools() + assert "$defs" in tools_after[0].parameters + + async def test_output_schema_dereferenced(self): + """Middleware also dereferences output_schema when present.""" + mcp = FastMCP("test", dereference_schemas=True) + + @mcp.tool + def paint(request: PaintRequest) -> PaintRequest: + return request + + async with Client(mcp) as client: + tools = await client.list_tools() + + tool = tools[0] + # Both input and output schemas should be dereferenced + assert "$defs" not in tool.inputSchema + if tool.outputSchema is not None: + assert "$defs" not in tool.outputSchema + + async def test_resource_templates_dereferenced(self): + """Middleware dereferences resource template schemas.""" + mcp = FastMCP("test", dereference_schemas=True) + + @mcp.resource("paint://{color}") + def get_paint(color: Color) -> str: + return f"paint: {color}" + + async with Client(mcp) as client: + templates = await client.list_resource_templates() + + # Resource templates also get their schemas dereferenced + # (only if the template parameters have $ref) + assert len(templates) == 1 + + async def test_no_ref_schemas_unchanged(self): + """Tools without $ref should pass through unmodified.""" + mcp = FastMCP("test", dereference_schemas=True) + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + # Simple schema should not have $defs regardless + assert "$defs" not in schema + assert schema["properties"]["a"]["type"] == "integer" diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py index dd75d7443..e2976b674 100644 --- a/tests/tools/tool/test_tool.py +++ b/tests/tools/tool/test_tool.py @@ -196,9 +196,8 @@ class TestToolFromFunction: "description": "Create a new user.", "tags": set(), "parameters": { - "additionalProperties": False, - "properties": { - "user": { + "$defs": { + "UserInput": { "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, @@ -206,6 +205,10 @@ class TestToolFromFunction: "required": ["name", "age"], "type": "object", }, + }, + "additionalProperties": False, + "properties": { + "user": {"$ref": "#/$defs/UserInput"}, "flag": {"type": "boolean"}, }, "required": ["user", "flag"], diff --git a/tests/tools/tool_transform/test_schemas.py b/tests/tools/tool_transform/test_schemas.py index 51cb89f76..41aa7bbe8 100644 --- a/tests/tools/tool_transform/test_schemas.py +++ b/tests/tools/tool_transform/test_schemas.py @@ -346,8 +346,8 @@ class TestInputSchema: def test_merge_schema_with_defs_precedence(self): """Test _merge_schema_with_precedence merges $defs correctly. - Note: This tests the raw merge behavior before dereferencing. - The final schema output will be dereferenced by compress_schema. + Note: compress_schema no longer dereferences $ref by default. + Used definitions are kept in $defs; unused definitions are pruned. """ base_schema = { "type": "object", @@ -374,13 +374,17 @@ class TestInputSchema: # SharedType should no longer be present on the schema (unused) assert "SharedType" not in transformed_tool_schema.get("$defs", {}) - # Schema is dereferenced so no $defs in final output + # $ref and $defs are preserved for used definitions assert transformed_tool_schema == snapshot( { "type": "object", "properties": { - "field1": {"type": "string", "description": "base"}, - "field2": {"type": "boolean"}, + "field1": {"$ref": "#/$defs/BaseType"}, + "field2": {"$ref": "#/$defs/OverrideType"}, + }, + "$defs": { + "BaseType": {"type": "string", "description": "base"}, + "OverrideType": {"type": "boolean"}, }, "required": [], "additionalProperties": False, @@ -390,8 +394,8 @@ class TestInputSchema: def test_transform_tool_with_complex_defs_pruning(self): """Test that tool transformation properly handles hidden params. - With schema dereferencing, unused types are automatically removed - since $defs is eliminated entirely. + Unused type definitions are pruned from $defs when their + corresponding parameters are hidden. Used types remain as $ref. """ class UsedType(BaseModel): @@ -411,18 +415,21 @@ class TestInputSchema: complex_tool, transform_args={"unused_param": ArgTransform(hide=True)} ) - # Schema is dereferenced - no $defs - assert "$defs" not in transformed_tool.parameters + # UnusedType should be pruned from $defs, but UsedType remains + assert "UnusedType" not in transformed_tool.parameters.get("$defs", {}) assert transformed_tool.parameters == snapshot( { "type": "object", "properties": { - "used_param": { + "used_param": {"$ref": "#/$defs/UsedType"}, + }, + "$defs": { + "UsedType": { "properties": {"value": {"type": "string"}}, "required": ["value"], "type": "object", - } + }, }, "required": ["used_param"], "additionalProperties": False, @@ -430,7 +437,7 @@ class TestInputSchema: ) def test_transform_with_custom_function_preserves_needed_types(self): - """Test that custom transform functions preserve necessary types inline.""" + """Test that custom transform functions preserve necessary type definitions.""" class InputType(BaseModel): data: str @@ -452,18 +459,19 @@ class TestInputSchema: transform_args={"input_data": ArgTransform(name="renamed_input")}, ) - # Schema is dereferenced - types are inlined - assert "$defs" not in transformed.parameters - + # Used type definitions are preserved as $ref/$defs assert transformed.parameters == snapshot( { "type": "object", "properties": { - "renamed_input": { + "renamed_input": {"$ref": "#/$defs/InputType"}, + }, + "$defs": { + "InputType": { "properties": {"data": {"type": "string"}}, "required": ["data"], "type": "object", - } + }, }, "required": ["renamed_input"], "additionalProperties": False, @@ -471,7 +479,7 @@ class TestInputSchema: ) def test_chained_transforms_inline_types(self): - """Test that chained transformations produce correct inlined schemas.""" + """Test that chained transformations produce correct schemas with $ref/$defs.""" class TypeA(BaseModel): a: str @@ -492,19 +500,23 @@ class TestInputSchema: transform_args={"param_c": ArgTransform(hide=True, default=TypeC(c=True))}, ) - # Schema is dereferenced - types are inlined - assert "$defs" not in transform1.parameters + # TypeC should be pruned from $defs, TypeA and TypeB remain + assert "TypeC" not in transform1.parameters.get("$defs", {}) assert transform1.parameters == snapshot( { "type": "object", "properties": { - "param_a": { + "param_a": {"$ref": "#/$defs/TypeA"}, + "param_b": {"$ref": "#/$defs/TypeB"}, + }, + "$defs": { + "TypeA": { "properties": {"a": {"type": "string"}}, "required": ["a"], "type": "object", }, - "param_b": { + "TypeB": { "properties": {"b": {"type": "integer"}}, "required": ["b"], "type": "object", @@ -521,17 +533,21 @@ class TestInputSchema: transform_args={"param_b": ArgTransform(hide=True, default=TypeB(b=42))}, ) - assert "$defs" not in transform2.parameters + # TypeB should be pruned from $defs, only TypeA remains + assert "TypeB" not in transform2.parameters.get("$defs", {}) assert transform2.parameters == snapshot( { "type": "object", "properties": { - "param_a": { + "param_a": {"$ref": "#/$defs/TypeA"}, + }, + "$defs": { + "TypeA": { "properties": {"a": {"type": "string"}}, "required": ["a"], "type": "object", - } + }, }, "required": ["param_a"], "additionalProperties": False, diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index bc3247323..47ab1853b 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -205,10 +205,11 @@ async def test_hidden_param_prunes_defs(): schema = new_tool.parameters # Only 'a' should be visible assert list(schema["properties"].keys()) == ["a"] - # Schema should be fully dereferenced (no $defs) - assert "$defs" not in schema - # VisibleType should be inlined in the property - assert schema["properties"]["a"] == { + # HiddenType should be pruned from $defs + assert "HiddenType" not in schema.get("$defs", {}) + # VisibleType should remain in $defs and be referenced via $ref + assert schema["properties"]["a"] == {"$ref": "#/$defs/VisibleType"} + assert schema["$defs"]["VisibleType"] == { "properties": {"x": {"type": "integer"}}, "required": ["x"], "type": "object", @@ -396,10 +397,8 @@ def test_transform_args_with_parent_defaults(): new_tool = Tool.from_tool(tool) - # Both tools should have the same dereferenced schema + # Both tools should have the same schema (with $ref/$defs preserved) assert new_tool.parameters == tool.parameters - # Schema should be fully dereferenced (no $defs) - assert "$defs" not in new_tool.parameters def test_transform_args_validation_unknown_arg(add_tool): diff --git a/tests/utilities/openapi/test_schemas.py b/tests/utilities/openapi/test_schemas.py index 54644bce6..ceac2bdd6 100644 --- a/tests/utilities/openapi/test_schemas.py +++ b/tests/utilities/openapi/test_schemas.py @@ -581,7 +581,7 @@ class TestEdgeCases: ) # Should have some properties from one of the content types def test_oneof_reference_dereferenced(self): - """Test that schemas referenced in oneOf are dereferenced.""" + """Test that schemas referenced in oneOf are preserved and unused defs pruned.""" schema = { "type": "object", @@ -594,14 +594,15 @@ class TestEdgeCases: result = compress_schema(schema) - # $defs should be removed (all refs dereferenced) - assert "$defs" not in result + # UnusedSchema should be pruned, TestSchema should be kept + assert "UnusedSchema" not in result.get("$defs", {}) + assert result["$defs"]["TestSchema"] == {"type": "string"} - # TestSchema should be inlined in oneOf - assert result["properties"]["data"]["oneOf"] == [{"type": "string"}] + # $ref should be preserved in oneOf + assert result["properties"]["data"]["oneOf"] == [{"$ref": "#/$defs/TestSchema"}] def test_anyof_reference_dereferenced(self): - """Test that schemas referenced in anyOf are dereferenced.""" + """Test that schemas referenced in anyOf are preserved and unused defs pruned.""" schema = { "type": "object", @@ -614,14 +615,15 @@ class TestEdgeCases: result = compress_schema(schema) - # $defs should be removed (all refs dereferenced) - assert "$defs" not in result + # UnusedSchema should be pruned, TestSchema should be kept + assert "UnusedSchema" not in result.get("$defs", {}) + assert result["$defs"]["TestSchema"] == {"type": "string"} - # TestSchema should be inlined in anyOf - assert result["properties"]["data"]["anyOf"] == [{"type": "string"}] + # $ref should be preserved in anyOf + assert result["properties"]["data"]["anyOf"] == [{"$ref": "#/$defs/TestSchema"}] def test_allof_reference_dereferenced(self): - """Test that schemas referenced in allOf are dereferenced.""" + """Test that schemas referenced in allOf are preserved and unused defs pruned.""" schema = { "type": "object", @@ -634,8 +636,9 @@ class TestEdgeCases: result = compress_schema(schema) - # $defs should be removed (all refs dereferenced) - assert "$defs" not in result + # UnusedSchema should be pruned, TestSchema should be kept + assert "UnusedSchema" not in result.get("$defs", {}) + assert result["$defs"]["TestSchema"] == {"type": "string"} - # TestSchema should be inlined in allOf - assert result["properties"]["data"]["allOf"] == [{"type": "string"}] + # $ref should be preserved in allOf + assert result["properties"]["data"]["allOf"] == [{"$ref": "#/$defs/TestSchema"}] diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 436beb6a2..a337156d6 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -196,8 +196,8 @@ class TestDereferenceRefs: class TestCompressSchema: """Tests for the compress_schema function.""" - def test_dereferences_by_default(self): - """Test that compress_schema dereferences $refs by default.""" + def test_preserves_refs_by_default(self): + """Test that compress_schema preserves $refs by default.""" schema = { "properties": { "foo": {"$ref": "#/$defs/foo_def"}, @@ -208,10 +208,9 @@ class TestCompressSchema: } result = compress_schema(schema) - # $ref should be inlined - assert result["properties"]["foo"] == {"type": "string"} - # $defs should be removed - assert "$defs" not in result + # $ref should be preserved (dereferencing is handled by middleware) + assert result["properties"]["foo"] == {"$ref": "#/$defs/foo_def"} + assert "$defs" in result def test_prune_params(self): """Test pruning parameters with compress_schema.""" @@ -271,7 +270,7 @@ class TestCompressSchema: assert "remove" not in result["properties"] # Check that required list was updated assert result["required"] == ["keep"] - # Check that $defs was removed (dereferenced) + # All $defs entries are now unreferenced after pruning "remove", so they're cleaned up assert "$defs" not in result # Check that additionalProperties was removed assert "additionalProperties" not in result @@ -442,6 +441,46 @@ class TestCompressSchema: ) +class TestCompressSchemaDereference: + """Tests for the dereference parameter of compress_schema.""" + + SCHEMA_WITH_REFS = { + "properties": { + "foo": {"$ref": "#/$defs/foo_def"}, + }, + "$defs": { + "foo_def": {"type": "string"}, + }, + } + + def test_dereference_true_inlines_refs(self): + result = compress_schema(self.SCHEMA_WITH_REFS, dereference=True) + assert result["properties"]["foo"] == {"type": "string"} + assert "$defs" not in result + + def test_dereference_false_preserves_refs(self): + result = compress_schema(self.SCHEMA_WITH_REFS, dereference=False) + assert result["properties"]["foo"] == {"$ref": "#/$defs/foo_def"} + assert "$defs" in result + + def test_other_optimizations_still_apply_without_dereference(self): + schema = { + "properties": { + "foo": {"$ref": "#/$defs/foo_def"}, + "bar": {"type": "integer", "title": "Bar"}, + }, + "$defs": { + "foo_def": {"type": "string"}, + }, + } + result = compress_schema( + schema, dereference=False, prune_params=["bar"], prune_titles=True + ) + assert "bar" not in result["properties"] + assert "$ref" in result["properties"]["foo"] + assert "$defs" in result + + class TestResolveRootRef: """Tests for the resolve_root_ref function.