From fd0297014f5f165efa8294a4c42c70799333f4c2 Mon Sep 17 00:00:00 2001
From: "marvin-context-protocol[bot]"
<225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Date: Sat, 22 Nov 2025 12:30:09 -0500
Subject: [PATCH 01/13] chore: Update SDK documentation (#2365)
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
---
docs/docs.json | 1 +
.../python-sdk/fastmcp-cli-install-cursor.mdx | 8 +-
docs/python-sdk/fastmcp-client-auth-oauth.mdx | 24 ++--
docs/python-sdk/fastmcp-client-client.mdx | 19 +++-
docs/python-sdk/fastmcp-server-auth-auth.mdx | 28 ++---
.../fastmcp-server-auth-oauth_proxy.mdx | 42 +++----
.../fastmcp-server-auth-oidc_proxy.mdx | 4 +-
.../fastmcp-server-auth-providers-descope.mdx | 17 +--
...astmcp-server-auth-providers-in_memory.mdx | 16 +--
.../fastmcp-server-auth-providers-oci.mdx | 103 ++++++++++++++++++
...fastmcp-server-auth-providers-scalekit.mdx | 8 +-
...fastmcp-server-auth-providers-supabase.mdx | 16 ++-
docs/python-sdk/fastmcp-server-context.mdx | 78 ++++++++-----
.../fastmcp-server-dependencies.mdx | 8 +-
docs/python-sdk/fastmcp-server-server.mdx | 56 +++++-----
docs/python-sdk/fastmcp-tools-tool.mdx | 30 ++---
docs/python-sdk/fastmcp-utilities-logging.mdx | 2 +-
docs/python-sdk/fastmcp-utilities-types.mdx | 31 ++++--
18 files changed, 325 insertions(+), 166 deletions(-)
create mode 100644 docs/python-sdk/fastmcp-server-auth-providers-oci.mdx
diff --git a/docs/docs.json b/docs/docs.json
index de206e6c0..856a61d65 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -363,6 +363,7 @@
"python-sdk/fastmcp-server-auth-providers-in_memory",
"python-sdk/fastmcp-server-auth-providers-introspection",
"python-sdk/fastmcp-server-auth-providers-jwt",
+ "python-sdk/fastmcp-server-auth-providers-oci",
"python-sdk/fastmcp-server-auth-providers-scalekit",
"python-sdk/fastmcp-server-auth-providers-supabase",
"python-sdk/fastmcp-server-auth-providers-workos"
diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx
index 0463c3a05..40c0c5c6a 100644
--- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx
+++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx
@@ -27,7 +27,7 @@ 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
@@ -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-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx
index 6da25abbf..40d890ddf 100644
--- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx
+++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx
@@ -7,7 +7,7 @@ sidebarTitle: oauth
## Functions
-### `check_if_auth_required`
+### `check_if_auth_required`
```python
check_if_auth_required(mcp_url: str, httpx_kwargs: dict[str, Any] | None = None) -> bool
@@ -22,47 +22,47 @@ Check if the MCP endpoint requires authentication by making a test request.
## Classes
-### `ClientNotFoundError`
+### `ClientNotFoundError`
Raised when OAuth client credentials are not found on the server.
-### `TokenStorageAdapter`
+### `TokenStorageAdapter`
**Methods:**
-#### `clear`
+#### `clear`
```python
clear(self) -> None
```
-#### `get_tokens`
+#### `get_tokens`
```python
get_tokens(self) -> OAuthToken | None
```
-#### `set_tokens`
+#### `set_tokens`
```python
set_tokens(self, tokens: OAuthToken) -> None
```
-#### `get_client_info`
+#### `get_client_info`
```python
get_client_info(self) -> OAuthClientInformationFull | None
```
-#### `set_client_info`
+#### `set_client_info`
```python
set_client_info(self, client_info: OAuthClientInformationFull) -> None
```
-### `OAuth`
+### `OAuth`
OAuth client provider for MCP servers with browser-based authentication.
@@ -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-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx
index 1b915ca6f..cbb1599f9 100644
--- a/docs/python-sdk/fastmcp-client-client.mdx
+++ b/docs/python-sdk/fastmcp-client-client.mdx
@@ -476,7 +476,7 @@ Retrieve a list of tools available on the server.
#### `call_tool_mcp`
```python
-call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.CallToolResult
+call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, meta: dict[str, Any] | None = None) -> mcp.types.CallToolResult
```
Send a tools/call request and return the complete MCP protocol result.
@@ -489,6 +489,10 @@ and other metadata. It does not raise an exception if the tool call results in a
- `arguments`: Arguments to pass to the tool.
- `timeout`: The timeout for the tool call. Defaults to None.
- `progress_handler`: The progress handler to use for the tool call. Defaults to None.
+- `meta`: Additional metadata to include with the request.
+This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
+that shouldn't be tool arguments but may influence server-side processing. The server
+can access this via `context.request_context.meta`. Defaults to None.
**Returns:**
- mcp.types.CallToolResult: The complete response object from the protocol,
@@ -498,10 +502,10 @@ containing the tool result and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `call_tool`
+#### `call_tool`
```python
-call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True) -> CallToolResult
+call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True, meta: dict[str, Any] | None = None) -> CallToolResult
```
Call a tool on the server.
@@ -513,6 +517,11 @@ Unlike call_tool_mcp, this method raises a ToolError if the tool call results in
- `arguments`: Arguments to pass to the tool. Defaults to None.
- `timeout`: The timeout for the tool call. Defaults to None.
- `progress_handler`: The progress handler to use for the tool call. Defaults to None.
+- `raise_on_error`: Whether to raise a ToolError if the tool call results in an error. Defaults to True.
+- `meta`: Additional metadata to include with the request.
+This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
+that shouldn't be tool arguments but may influence server-side processing. The server
+can access this via `context.request_context.meta`. Defaults to None.
**Returns:**
-
@@ -528,10 +537,10 @@ raw result object.
- `RuntimeError`: If called while the client is not connected.
-#### `generate_name`
+#### `generate_name`
```python
generate_name(cls, name: str | None = None) -> str
```
-### `CallToolResult`
+### `CallToolResult`
diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx
index 2e6fed379..b5002a805 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.
-### `AuthProvider`
+### `AuthProvider`
Base class for all FastMCP authentication providers.
@@ -26,7 +26,7 @@ custom authentication routes.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -43,7 +43,7 @@ All auth providers must implement token verification.
- AccessToken object if valid, None if invalid or expired
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]
@@ -67,7 +67,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]
@@ -95,7 +95,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
@@ -107,7 +107,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).
@@ -118,7 +118,7 @@ Token verifiers typically don't provide authentication routes by default.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -127,7 +127,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.
@@ -144,7 +144,7 @@ the authorization servers that issue valid tokens.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -153,7 +153,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]
@@ -164,7 +164,7 @@ Get routes for this provider.
Creates protected resource metadata routes (RFC 9728).
-### `OAuthProvider`
+### `OAuthProvider`
OAuth Authorization Server provider.
@@ -175,7 +175,7 @@ authorization flows, token issuance, and token verification.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -193,7 +193,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]
diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
index cc676e63e..96d81714d 100644
--- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
@@ -26,7 +26,7 @@ production use with enterprise identity providers.
## Functions
-### `create_consent_html`
+### `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) -> str
@@ -36,7 +36,7 @@ create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id
Create a styled HTML consent page for OAuth authorization requests.
-### `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
@@ -58,7 +58,7 @@ Create a styled HTML error page for OAuth errors.
## Classes
-### `OAuthTransaction`
+### `OAuthTransaction`
OAuth transaction state for consent flow.
@@ -67,7 +67,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.
@@ -76,7 +76,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.
@@ -86,7 +86,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.
@@ -95,7 +95,7 @@ This allows stateless JWT validation while still being able to look up
the corresponding upstream token when tools need to access upstream APIs.
-### `ProxyDCRClient`
+### `ProxyDCRClient`
Client for DCR proxy with configurable redirect URI validation.
@@ -125,7 +125,7 @@ arise from accepting arbitrary redirect URIs.
**Methods:**
-#### `validate_redirect_uri`
+#### `validate_redirect_uri`
```python
validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl
@@ -139,7 +139,7 @@ This is essential for cached token scenarios where the client may
reconnect with a different port.
-### `TokenHandler`
+### `TokenHandler`
TokenHandler that returns OAuth 2.1 compliant error responses.
@@ -162,7 +162,7 @@ Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response."
**Methods:**
-#### `response`
+#### `response`
```python
response(self, obj: TokenSuccessResponse | TokenErrorResponse)
@@ -171,7 +171,7 @@ response(self, obj: TokenSuccessResponse | TokenErrorResponse)
Override response method to provide OAuth 2.1 compliant error handling.
-### `OAuthProxy`
+### `OAuthProxy`
OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs.
@@ -281,7 +281,7 @@ Handles provider-specific requirements:
**Methods:**
-#### `get_client`
+#### `get_client`
```python
get_client(self, client_id: str) -> OAuthClientInformationFull | None
@@ -293,7 +293,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
@@ -307,7 +307,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
@@ -324,7 +324,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
@@ -336,7 +336,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
@@ -354,7 +354,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
@@ -363,7 +363,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str)
Load refresh token from local storage.
-#### `exchange_refresh_token`
+#### `exchange_refresh_token`
```python
exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken
@@ -380,7 +380,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
@@ -399,7 +399,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
@@ -411,7 +411,7 @@ Removes tokens from local storage and attempts to revoke them with
the upstream server if a revocation endpoint is configured.
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]
diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx
index 2aa18f10a..573aea7bb 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-descope.mdx b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx
index 8b282c398..e6a51c7c4 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx
@@ -5,17 +5,20 @@ sidebarTitle: descope
# `fastmcp.server.auth.providers.descope`
+
Descope authentication provider for FastMCP.
This module provides DescopeProvider - a complete authentication solution that integrates
with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR)
for seamless MCP client authentication.
+
## Classes
-### `DescopeProviderSettings`
+### `DescopeProviderSettings`
+
+### `DescopeProvider`
-### `DescopeProvider`
Descope metadata provider for DCR (Dynamic Client Registration).
@@ -27,7 +30,6 @@ as a resource server.
IMPORTANT SETUP REQUIREMENTS:
1. Create an MCP Server in Descope Console:
-
- Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console
- Create a new MCP Server
- Ensure that **Dynamic Client Registration (DCR)** is enabled
@@ -35,14 +37,15 @@ IMPORTANT SETUP REQUIREMENTS:
2. Note your Well-Known URL:
- Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers)
- - Format: `https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`
+ - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration``
For detailed setup instructions, see:
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
+
**Methods:**
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]
@@ -54,6 +57,6 @@ This returns the standard protected resource routes plus an authorization server
metadata endpoint that forwards Descope's OAuth metadata to clients.
**Args:**
-
- `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp")
- This is used to advertise the resource URL in metadata.
+This is used to advertise the resource URL in metadata.
+
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx
index cb97653c8..0071ebaa9 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx
@@ -28,7 +28,7 @@ get_client(self, client_id: str) -> OAuthClientInformationFull | None
register_client(self, client_info: OAuthClientInformationFull) -> None
```
-#### `authorize`
+#### `authorize`
```python
authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str
@@ -38,37 +38,37 @@ Simulates user authorization and generates an authorization code.
Returns a redirect URI with the code and state.
-#### `load_authorization_code`
+#### `load_authorization_code`
```python
load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None
```
-#### `exchange_authorization_code`
+#### `exchange_authorization_code`
```python
exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken
```
-#### `load_refresh_token`
+#### `load_refresh_token`
```python
load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None
```
-#### `exchange_refresh_token`
+#### `exchange_refresh_token`
```python
exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken
```
-#### `load_access_token`
+#### `load_access_token`
```python
load_access_token(self, token: str) -> AccessToken | None
```
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -86,7 +86,7 @@ to our existing load_access_token method.
- AccessToken object if valid, None if invalid or expired
-#### `revoke_token`
+#### `revoke_token`
```python
revoke_token(self, token: AccessToken | RefreshToken) -> None
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx b/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx
new file mode 100644
index 000000000..dd42817b6
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx
@@ -0,0 +1,103 @@
+---
+title: oci
+sidebarTitle: oci
+---
+
+# `fastmcp.server.auth.providers.oci`
+
+
+OCI OIDC provider for FastMCP.
+
+The pull request for the provider is submitted to fastmcp.
+
+This module provides OIDC Implementation to integrate MCP servers with OCI.
+You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL.
+
+Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane.
+You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs.
+The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object.
+You can use the signer object to create OCI service object.
+
+Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.auth.providers.oci import OCIProvider
+ from fastmcp.server.dependencies import get_access_token
+ from fastmcp.utilities.logging import get_logger
+
+ import os
+
+ # Load configuration from environment
+ FASTMCP_SERVER_AUTH_OCI_CONFIG_URL = os.environ["FASTMCP_SERVER_AUTH_OCI_CONFIG_URL"]
+ FASTMCP_SERVER_AUTH_OCI_CLIENT_ID = os.environ["FASTMCP_SERVER_AUTH_OCI_CLIENT_ID"]
+ FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET = os.environ["FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET"]
+ FASTMCP_SERVER_AUTH_OCI_IAM_GUID = os.environ["FASTMCP_SERVER_AUTH_OCI_IAM_GUID"]
+
+ import oci
+ from oci.auth.signers import TokenExchangeSigner
+
+ logger = get_logger(__name__)
+
+ # Simple OCI OIDC protection
+ auth = OCIProvider(
+ config_url=FASTMCP_SERVER_AUTH_OCI_CONFIG_URL, #config URL is the OCI IAM Domain OIDC discovery URL.
+ client_id=FASTMCP_SERVER_AUTH_OCI_CLIENT_ID, #This is same as the client ID configured for the OCI IAM Domain Integrated Application
+ client_secret=FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET, #This is same as the client secret configured for the OCI IAM Domain Integrated Application
+ required_scopes=["openid", "profile", "email"],
+ redirect_path="/auth/callback",
+ base_url="http://localhost:8000",
+ )
+
+ # NOTE: For production use, replace this with a thread-safe cache implementation
+ # such as threading.Lock-protected dict or a proper caching library
+ _global_token_cache = {} #In memory cache for OCI session token signer
+
+ def get_oci_signer() -> TokenExchangeSigner:
+
+ authntoken = get_access_token()
+ tokenID = authntoken.claims.get("jti")
+ token = authntoken.token
+
+ #Check if the signer exists for the token ID in memory cache
+ cached_signer = _global_token_cache.get(tokenID)
+ logger.debug(f"Global cached signer: {cached_signer}")
+ if cached_signer:
+ logger.debug(f"Using globally cached signer for token ID: {tokenID}")
+ return cached_signer
+
+ #If the signer is not yet created for the token then create new OCI signer object
+ logger.debug(f"Creating new signer for token ID: {tokenID}")
+ signer = TokenExchangeSigner(
+ jwt_or_func=token,
+ oci_domain_id=FASTMCP_SERVER_AUTH_OCI_IAM_GUID.split(".")[0], #This is same as IAM GUID configured for the OCI IAM Domain
+ client_id=FASTMCP_SERVER_AUTH_OCI_CLIENT_ID, #This is same as the client ID configured for the OCI IAM Domain Integrated Application
+ client_secret=FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET #This is same as the client secret configured for the OCI IAM Domain Integrated Application
+ )
+ logger.debug(f"Signer {signer} created for token ID: {tokenID}")
+
+ #Cache the signer object in memory cache
+ _global_token_cache[tokenID] = signer
+ logger.debug(f"Signer cached for token ID: {tokenID}")
+
+ return signer
+
+ mcp = FastMCP("My Protected Server", auth=auth)
+ ```
+
+
+## Classes
+
+### `OCIProviderSettings`
+
+
+Settings for OCI IAM domain OIDC provider.
+
+
+### `OCIProvider`
+
+
+An OCI IAM Domain provider implementation for FastMCP.
+
+This provider is a complete OCI integration that's ready to use with
+just the configuration URL, client ID, client secret, and base URL.
+
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx
index 546b07b06..448ad7e9e 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx
@@ -15,9 +15,9 @@ authentication for seamless MCP client authentication.
## Classes
-### `ScalekitProviderSettings`
+### `ScalekitProviderSettings`
-### `ScalekitProvider`
+### `ScalekitProvider`
Scalekit resource server provider for OAuth 2.1 authentication.
@@ -39,7 +39,6 @@ IMPORTANT SETUP REQUIREMENTS:
- Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com)
- Set SCALEKIT_RESOURCE_ID from your created resource
- Set BASE_URL to your FastMCP server's public URL
- - (Optional) Set SCALEKIT_REQUIRED_SCOPES to enforce token scopes
For detailed setup instructions, see:
https://docs.scalekit.com/mcp/overview/
@@ -47,7 +46,7 @@ https://docs.scalekit.com/mcp/overview/
**Methods:**
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]
@@ -61,3 +60,4 @@ metadata endpoint that forwards Scalekit's OAuth metadata to clients.
**Args:**
- `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp")
This is used to advertise the resource URL in metadata.
+
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx
index 603672d13..a5c35964e 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx
@@ -15,9 +15,9 @@ for seamless MCP client authentication.
## Classes
-### `SupabaseProviderSettings`
+### `SupabaseProviderSettings`
-### `SupabaseProvider`
+### `SupabaseProvider`
Supabase metadata provider for DCR (Dynamic Client Registration).
@@ -31,13 +31,19 @@ IMPORTANT SETUP REQUIREMENTS:
1. Supabase Project Setup:
- Create a Supabase project at https://supabase.com
- Note your project URL (e.g., "https://abc123.supabase.co")
- - For projects created after May 1st, 2025, asymmetric RS256 keys are used by default
- - For older projects, consider migrating to asymmetric keys for better security
+ - Configure your JWT algorithm in Supabase Auth settings (HS256, RS256, or ES256)
+ - Asymmetric keys (RS256/ES256) are recommended for production
2. JWT Verification:
- FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json
- JWTs are issued by {project_url}/auth/v1
- Tokens are cached for up to 10 minutes by Supabase's edge servers
+ - Algorithm must match your Supabase Auth configuration
+
+3. Authorization:
+ - Supabase uses Row Level Security (RLS) policies for database authorization
+ - OAuth-level scopes are an upcoming feature in Supabase Auth
+ - Both approaches will be supported once scope handling is available
For detailed setup instructions, see:
https://supabase.com/docs/guides/auth/jwts
@@ -45,7 +51,7 @@ https://supabase.com/docs/guides/auth/jwts
**Methods:**
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]
diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx
index dd186188c..ca1f8f4c8 100644
--- a/docs/python-sdk/fastmcp-server-context.mdx
+++ b/docs/python-sdk/fastmcp-server-context.mdx
@@ -84,15 +84,33 @@ Get the FastMCP instance.
#### `request_context`
```python
-request_context(self) -> RequestContext[ServerSession, Any, Request]
+request_context(self) -> RequestContext[ServerSession, Any, Request] | None
```
Access to the underlying request context.
-If called outside of a request context, this will raise a ValueError.
+Returns None when the MCP session has not been established yet.
+Returns the full RequestContext once the MCP session is available.
+
+For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies,
+which works whether or not the MCP session is available.
+
+Example in middleware:
+```python
+async def on_request(self, context, call_next):
+ ctx = context.fastmcp_context
+ if ctx.request_context:
+ # MCP session available - can access session_id, request_id, etc.
+ session_id = ctx.session_id
+ else:
+ # MCP session not available yet - use HTTP helpers
+ from fastmcp.server.dependencies import get_http_request
+ request = get_http_request()
+ return await call_next(context)
+```
-#### `report_progress`
+#### `report_progress`
```python
report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None
@@ -105,7 +123,7 @@ Report progress for the current operation.
- `total`: Optional total value e.g. 100
-#### `list_resources`
+#### `list_resources`
```python
list_resources(self) -> list[MCPResource]
@@ -117,7 +135,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[MCPPrompt]
@@ -129,7 +147,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
@@ -145,7 +163,7 @@ Get a prompt by name with optional arguments.
- The prompt result
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]
@@ -160,7 +178,7 @@ Read a resource by URI.
- The resource content as either text or bytes
-#### `log`
+#### `log`
```python
log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -178,7 +196,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien
- `extra`: Optional mapping for additional arguments
-#### `client_id`
+#### `client_id`
```python
client_id(self) -> str | None
@@ -187,7 +205,7 @@ client_id(self) -> str | None
Get the client ID if available.
-#### `request_id`
+#### `request_id`
```python
request_id(self) -> str
@@ -195,8 +213,10 @@ request_id(self) -> str
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
@@ -213,7 +233,7 @@ the same client session.
- for other transports.
-#### `session`
+#### `session`
```python
session(self) -> ServerSession
@@ -221,8 +241,10 @@ session(self) -> ServerSession
Access to the underlying session for advanced usage.
+Raises RuntimeError if MCP request context is not available.
-#### `debug`
+
+#### `debug`
```python
debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -233,7 +255,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
@@ -244,7 +266,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
@@ -255,7 +277,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
@@ -266,7 +288,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]
@@ -275,7 +297,7 @@ list_roots(self) -> list[Root]
List the roots available to the server, as indicated by the client.
-#### `send_tool_list_changed`
+#### `send_tool_list_changed`
```python
send_tool_list_changed(self) -> None
@@ -284,7 +306,7 @@ send_tool_list_changed(self) -> None
Send a tool list changed notification to the client.
-#### `send_resource_list_changed`
+#### `send_resource_list_changed`
```python
send_resource_list_changed(self) -> None
@@ -293,7 +315,7 @@ send_resource_list_changed(self) -> None
Send a resource list changed notification to the client.
-#### `send_prompt_list_changed`
+#### `send_prompt_list_changed`
```python
send_prompt_list_changed(self) -> None
@@ -302,7 +324,7 @@ send_prompt_list_changed(self) -> None
Send a prompt list changed notification to the client.
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent | AudioContent
@@ -315,25 +337,25 @@ completion from the client. The client must be appropriately configured,
or the request will error.
-#### `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: type[T] | list[str] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
@@ -362,7 +384,7 @@ type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
-#### `get_http_request`
+#### `get_http_request`
```python
get_http_request(self) -> Request
@@ -371,7 +393,7 @@ get_http_request(self) -> Request
Get the active starlette request.
-#### `set_state`
+#### `set_state`
```python
set_state(self, key: str, value: Any) -> None
@@ -380,7 +402,7 @@ set_state(self, key: str, value: Any) -> None
Set a value in the context state.
-#### `get_state`
+#### `get_state`
```python
get_state(self, key: str) -> Any
diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx
index 3c50f6421..7db6f2f0a 100644
--- a/docs/python-sdk/fastmcp-server-dependencies.mdx
+++ b/docs/python-sdk/fastmcp-server-dependencies.mdx
@@ -7,19 +7,19 @@ sidebarTitle: dependencies
## Functions
-### `get_context`
+### `get_context`
```python
get_context() -> Context
```
-### `get_http_request`
+### `get_http_request`
```python
get_http_request() -> Request
```
-### `get_http_headers`
+### `get_http_headers`
```python
get_http_headers(include_all: bool = False) -> dict[str, str]
@@ -35,7 +35,7 @@ By default, strips problematic headers like `content-length` that cause issues i
If `include_all` is True, all headers are returned.
-### `get_access_token`
+### `get_access_token`
```python
get_access_token() -> AccessToken | None
diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx
index 3f550d232..086f6072b 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.
-### `add_resource_prefix`
+### `add_resource_prefix`
```python
add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str
@@ -64,7 +64,7 @@ add_resource_prefix("resource:///absolute/path", "prefix")
- `ValueError`: If the URI doesn't match the expected protocol\://path format
-### `remove_resource_prefix`
+### `remove_resource_prefix`
```python
remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str
@@ -103,7 +103,7 @@ remove_resource_prefix("resource://prefix//absolute/path", "prefix")
- `ValueError`: If the URI doesn't match the expected protocol\://path format
-### `has_resource_prefix`
+### `has_resource_prefix`
```python
has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> bool
@@ -392,7 +392,9 @@ This decorator supports multiple calling patterns:
- `tags`: Optional set of tags for categorizing the tool
- `output_schema`: Optional JSON schema for the tool's output
- `annotations`: Optional annotations about the tool's behavior
-- `exclude_args`: Optional list of argument names to exclude from the tool schema
+- `exclude_args`: Optional list of argument names to exclude from the tool schema.
+Note\: `exclude_args` will be deprecated in FastMCP 2.14 in favor of dependency
+injection with `Depends()` for better lifecycle management.
- `meta`: Optional meta information about the tool
- `enabled`: Optional boolean to enable or disable the tool
@@ -422,7 +424,7 @@ server.tool(my_function, name="custom_name")
```
-#### `add_resource`
+#### `add_resource`
```python
add_resource(self, resource: Resource) -> Resource
@@ -437,7 +439,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
@@ -452,7 +454,7 @@ Add a resource template to the server.
- The template instance that was added to the server.
-#### `add_resource_fn`
+#### `add_resource_fn`
```python
add_resource_fn(self, fn: AnyFunction, uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> None
@@ -472,7 +474,7 @@ has parameters, it will be registered as a template resource.
- `tags`: Optional set of tags for categorizing the resource
-#### `resource`
+#### `resource`
```python
resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate]
@@ -532,7 +534,7 @@ async def get_weather(city: str) -> str:
```
-#### `add_prompt`
+#### `add_prompt`
```python
add_prompt(self, prompt: Prompt) -> Prompt
@@ -547,19 +549,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
@@ -637,7 +639,7 @@ Decorator to register a prompt.
```
-#### `run_stdio_async`
+#### `run_stdio_async`
```python
run_stdio_async(self, show_banner: bool = True, log_level: str | None = None) -> None
@@ -650,7 +652,7 @@ Run the server using stdio transport.
- `log_level`: Log level for the server
-#### `run_http_async`
+#### `run_http_async`
```python
run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None) -> None
@@ -670,7 +672,7 @@ Run the server using HTTP transport.
- `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http)
-#### `run_sse_async`
+#### `run_sse_async`
```python
run_sse_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None
@@ -679,7 +681,7 @@ run_sse_async(self, host: str | None = None, port: int | None = None, log_level:
Run the server using SSE transport.
-#### `sse_app`
+#### `sse_app`
```python
sse_app(self, path: str | None = None, message_path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan
@@ -693,7 +695,7 @@ Create a Starlette app for the SSE server.
- `middleware`: A list of middleware to apply to the app
-#### `streamable_http_app`
+#### `streamable_http_app`
```python
streamable_http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan
@@ -706,7 +708,7 @@ Create a Starlette app for the StreamableHTTP server.
- `middleware`: A list of middleware to apply to the app
-#### `http_app`
+#### `http_app`
```python
http_app(self, 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') -> StarletteWithLifespan
@@ -723,13 +725,13 @@ Create a Starlette app using the specified HTTP transport.
- A Starlette application configured with the specified transport
-#### `run_streamable_http_async`
+#### `run_streamable_http_async`
```python
run_streamable_http_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None
```
-#### `mount`
+#### `mount`
```python
mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None
@@ -783,7 +785,7 @@ automatically determined based on whether the server has a custom lifespan
- `prompt_separator`: Deprecated. Separator character for prompt names.
-#### `import_server`
+#### `import_server`
```python
import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None) -> None
@@ -824,7 +826,7 @@ applied using the protocol\://prefix/path format
- `prompt_separator`: Deprecated. Separator for prompt names.
-#### `from_openapi`
+#### `from_openapi`
```python
from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew
@@ -833,7 +835,7 @@ from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route
Create a FastMCP server from an OpenAPI specification.
-#### `from_fastapi`
+#### `from_fastapi`
```python
from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew
@@ -842,7 +844,7 @@ from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap]
Create a FastMCP server from a FastAPI application.
-#### `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
@@ -856,7 +858,7 @@ instance or any value accepted as the `transport` argument of
`fastmcp.client.Client` constructor.
-#### `from_client`
+#### `from_client`
```python
from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPProxy
@@ -865,10 +867,10 @@ from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPPr
Create a FastMCP proxy server from a FastMCP client.
-#### `generate_name`
+#### `generate_name`
```python
generate_name(cls, name: str | None = None) -> str
```
-### `MountedServer`
+### `MountedServer`
diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx
index b55865525..aa1f33624 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,19 +33,19 @@ Internal tool registration info.
**Methods:**
-#### `enable`
+#### `enable`
```python
enable(self) -> None
```
-#### `disable`
+#### `disable`
```python
disable(self) -> None
```
-#### `to_mcp_tool`
+#### `to_mcp_tool`
```python
to_mcp_tool(self, **overrides: Any) -> MCPTool
@@ -54,7 +54,7 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool
Convert the FastMCP tool to an MCP tool.
-#### `from_function`
+#### `from_function`
```python
from_function(fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool
@@ -63,7 +63,7 @@ from_function(fn: Callable[..., Any], name: str | None = None, title: str | None
Create a Tool from a function.
-#### `run`
+#### `run`
```python
run(self, arguments: dict[str, Any]) -> ToolResult
@@ -78,17 +78,17 @@ implemented by subclasses.
(list of ContentBlocks, dict of structured output).
-#### `from_tool`
+#### `from_tool`
```python
from_tool(cls, tool: Tool) -> TransformedTool
```
-### `FunctionTool`
+### `FunctionTool`
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool
@@ -97,7 +97,7 @@ from_function(cls, fn: Callable[..., Any], name: str | None = None, title: str |
Create a Tool from a function.
-#### `run`
+#### `run`
```python
run(self, arguments: dict[str, Any]) -> ToolResult
@@ -106,11 +106,11 @@ run(self, arguments: dict[str, Any]) -> ToolResult
Run the tool with arguments.
-### `ParsedFunction`
+### `ParsedFunction`
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True) -> ParsedFunction
diff --git a/docs/python-sdk/fastmcp-utilities-logging.mdx b/docs/python-sdk/fastmcp-utilities-logging.mdx
index fe976dcad..ef3264dbb 100644
--- a/docs/python-sdk/fastmcp-utilities-logging.mdx
+++ b/docs/python-sdk/fastmcp-utilities-logging.mdx
@@ -41,7 +41,7 @@ Configure logging for FastMCP.
- `rich_kwargs`: the parameters to use for creating RichHandler
-### `temporary_log_level`
+### `temporary_log_level`
```python
temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any)
diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx
index 23f694228..0026885eb 100644
--- a/docs/python-sdk/fastmcp-utilities-types.mdx
+++ b/docs/python-sdk/fastmcp-utilities-types.mdx
@@ -64,7 +64,20 @@ Find the name of the kwarg that is of type kwarg_type.
Includes union types that contain the kwarg_type, as well as Annotated types.
-### `replace_type`
+### `create_function_without_params`
+
+```python
+create_function_without_params(fn: Callable[..., Any], exclude_params: list[str]) -> Callable[..., Any]
+```
+
+
+Create a new function with the same code but without the specified parameters in annotations.
+
+This is used to exclude parameters from type adapter processing when they can't be serialized.
+The excluded parameters are removed from the function's __annotations__ dictionary.
+
+
+### `replace_type`
```python
replace_type(type_, type_map: dict[type, type])
@@ -99,7 +112,7 @@ list[list[str]]
Base model for FastMCP models.
-### `Image`
+### `Image`
Helper class for returning images from tools.
@@ -107,7 +120,7 @@ Helper class for returning images from tools.
**Methods:**
-#### `to_image_content`
+#### `to_image_content`
```python
to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.ImageContent
@@ -116,7 +129,7 @@ to_image_content(self, mime_type: str | None = None, annotations: Annotations |
Convert to MCP ImageContent.
-#### `to_data_uri`
+#### `to_data_uri`
```python
to_data_uri(self, mime_type: str | None = None) -> str
@@ -125,7 +138,7 @@ to_data_uri(self, mime_type: str | None = None) -> str
Get image as a data URI.
-### `Audio`
+### `Audio`
Helper class for returning audio from tools.
@@ -133,13 +146,13 @@ Helper class for returning audio from tools.
**Methods:**
-#### `to_audio_content`
+#### `to_audio_content`
```python
to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.AudioContent
```
-### `File`
+### `File`
Helper class for returning file data from tools.
@@ -147,10 +160,10 @@ Helper class for returning file data from tools.
**Methods:**
-#### `to_resource_content`
+#### `to_resource_content`
```python
to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.EmbeddedResource
```
-### `ContextSamplingFallbackProtocol`
+### `ContextSamplingFallbackProtocol`
From 25166afe9836feb2059ce12e89e675d5c3e52533 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 26 Nov 2025 16:50:49 -0500
Subject: [PATCH 02/13] Bump actions/checkout from 5 to 6 (#2474)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: '6'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/auto-close-duplicates.yml | 2 +-
.github/workflows/auto-close-needs-mre.yml | 2 +-
.github/workflows/martian-issue-triage.yml | 2 +-
.github/workflows/martian-test-failure.yml | 2 +-
.github/workflows/marvin-dedupe-issues.yml | 2 +-
.github/workflows/marvin-label-triage.yml | 2 +-
.github/workflows/marvin.yml | 2 +-
.github/workflows/publish.yml | 2 +-
.github/workflows/run-static.yml | 2 +-
.github/workflows/run-tests.yml | 6 +++---
.github/workflows/update-config-schema.yml | 2 +-
.github/workflows/update-sdk-docs.yml | 2 +-
12 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml
index c2ee3dfb3..d358a3a1e 100644
--- a/.github/workflows/auto-close-duplicates.yml
+++ b/.github/workflows/auto-close-duplicates.yml
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Generate Marvin App token
id: marvin-token
diff --git a/.github/workflows/auto-close-needs-mre.yml b/.github/workflows/auto-close-needs-mre.yml
index 98d26ccff..4338c58d8 100644
--- a/.github/workflows/auto-close-needs-mre.yml
+++ b/.github/workflows/auto-close-needs-mre.yml
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Generate Marvin App token
id: marvin-token
diff --git a/.github/workflows/martian-issue-triage.yml b/.github/workflows/martian-issue-triage.yml
index 8669eefdd..d9c58220c 100644
--- a/.github/workflows/martian-issue-triage.yml
+++ b/.github/workflows/martian-issue-triage.yml
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout base repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }}
diff --git a/.github/workflows/martian-test-failure.yml b/.github/workflows/martian-test-failure.yml
index bd12de7e3..bd0df80a2 100644
--- a/.github/workflows/martian-test-failure.yml
+++ b/.github/workflows/martian-test-failure.yml
@@ -23,7 +23,7 @@ jobs:
actions: read # Required for Claude to read CI results
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 1
diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml
index 0f8b5c315..d95460ecd 100644
--- a/.github/workflows/marvin-dedupe-issues.yml
+++ b/.github/workflows/marvin-dedupe-issues.yml
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Generate Marvin App token
id: marvin-token
diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml
index 8c13ef64a..e04cb8137 100644
--- a/.github/workflows/marvin-label-triage.yml
+++ b/.github/workflows/marvin-label-triage.yml
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout base repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }}
diff --git a/.github/workflows/marvin.yml b/.github/workflows/marvin.yml
index 51674e615..2693e42f4 100644
--- a/.github/workflows/marvin.yml
+++ b/.github/workflows/marvin.yml
@@ -31,7 +31,7 @@ jobs:
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'marvin')
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
# Install UV package manager
- name: Install UV
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2a7f3adc9..5f2fe8d53 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,7 +12,7 @@ jobs:
id-token: write # For PyPI's trusted publishing
steps:
- name: Checkout
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 0
diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml
index 2551c49b1..b8e230902 100644
--- a/.github/workflows/run-static.yml
+++ b/.github/workflows/run-static.yml
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 75ba3365e..5c6d4d892 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -34,7 +34,7 @@ jobs:
timeout-minutes: 10
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -60,7 +60,7 @@ jobs:
timeout-minutes: 10
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -85,7 +85,7 @@ jobs:
timeout-minutes: 10
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml
index 1ebe92ec1..9a7900cb8 100644
--- a/.github/workflows/update-config-schema.yml
+++ b/.github/workflows/update-config-schema.yml
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Generate Marvin App token
id: marvin-token
diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml
index 8d1996f7a..aa5a44bd9 100644
--- a/.github/workflows/update-sdk-docs.yml
+++ b/.github/workflows/update-sdk-docs.yml
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Generate Marvin App token
id: marvin-token
From 256f3769884efdefe3fb01edaf39421c76424db9 Mon Sep 17 00:00:00 2001
From: SHAIK AYESHA <2400032689@kluniversity.in>
Date: Thu, 27 Nov 2025 03:21:18 +0530
Subject: [PATCH 03/13] Fix version number in VersionBadge: change 2.14.0 to
2.13.0 (#2491)
Corrects the typo in the VersionBadge component in docs/servers/icons.mdx. The version number was incorrectly displayed as 2.14.0 but should be 2.13.0 to match the actual current version of FastMCP.
Fixes issue #2487
---
docs/servers/icons.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/servers/icons.mdx b/docs/servers/icons.mdx
index 838dd8739..a7d038f6a 100644
--- a/docs/servers/icons.mdx
+++ b/docs/servers/icons.mdx
@@ -7,7 +7,7 @@ tag: NEW
import { VersionBadge } from '/snippets/version-badge.mdx'
-
+
Icons provide visual representations for your MCP servers and components, helping client applications present better user interfaces. When displayed in MCP clients, icons help users quickly identify and navigate your server's capabilities.
From d770a76c79a0b504444fa75731abc862dde370bc Mon Sep 17 00:00:00 2001
From: jason
Date: Wed, 26 Nov 2025 16:52:01 -0500
Subject: [PATCH 04/13] fix: prevent $defs mutation in Tool.from_tool
transforms (#2493)
Deep copy parent_defs before passing to compress_schema to prevent
mutation from affecting parent tool schemas when child tools hide
parameters that remove all $ref usage.
---
src/fastmcp/tools/tool_transform.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py
index efbd1fb8c..ded56a22e 100644
--- a/src/fastmcp/tools/tool_transform.py
+++ b/src/fastmcp/tools/tool_transform.py
@@ -4,6 +4,7 @@ import inspect
import warnings
from collections.abc import Callable
from contextvars import ContextVar
+from copy import deepcopy
from dataclasses import dataclass
from typing import Annotated, Any, Literal, cast
@@ -620,7 +621,8 @@ class TransformedTool(Tool):
"""
# Build transformed schema and mapping
- parent_defs = parent_tool.parameters.get("$defs", {})
+ # Deep copy to prevent compress_schema from mutating parent tool's $defs
+ parent_defs = deepcopy(parent_tool.parameters.get("$defs", {}))
parent_props = parent_tool.parameters.get("properties", {}).copy()
parent_required = set(parent_tool.parameters.get("required", []))
From ba69fba3055db6938ba368dc17275a85ca626ae3 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Wed, 26 Nov 2025 16:53:40 -0500
Subject: [PATCH 05/13] Add consent_csp_policy parameter for CSP customization
(#2484)
* Add consent_csp_policy parameter to OAuthProxy
Allows customization or disabling of CSP directives on the consent page.
Fixes #2476.
* Add consent_csp_policy to OIDCProxy and update docs
* Fix HTML injection vulnerability in CSP policy
HTML-escape the CSP policy value before inserting into meta tag to prevent HTML injection when CSP policies contain quotes.
---
docs/servers/auth/oauth-proxy.mdx | 18 ++
docs/servers/auth/oidc-proxy.mdx | 14 ++
src/fastmcp/server/auth/oauth_proxy.py | 48 +++--
src/fastmcp/server/auth/oidc_proxy.py | 6 +
src/fastmcp/utilities/ui.py | 13 +-
tests/server/auth/test_oauth_consent_flow.py | 176 +++++++++++++++++++
6 files changed, 260 insertions(+), 15 deletions(-)
diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index 652e487fb..9e8fed2c9 100644
--- a/docs/servers/auth/oauth-proxy.mdx
+++ b/docs/servers/auth/oauth-proxy.mdx
@@ -299,6 +299,24 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients.
+
+
+ Content Security Policy for the consent page.
+
+ - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission
+ - Empty string `""`: Disables CSP entirely (no meta tag rendered)
+ - Custom string: Uses the provided value as the CSP policy
+
+ This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives.
+
+ ```python
+ # Disable CSP entirely (let org CSP policies apply)
+ auth = OAuthProxy(..., consent_csp_policy="")
+
+ # Use custom CSP policy
+ auth = OAuthProxy(..., consent_csp_policy="default-src 'self'; style-src 'unsafe-inline'")
+ ```
+
### Using Built-in Providers
diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx
index df25ca1ea..729f9884d 100644
--- a/docs/servers/auth/oidc-proxy.mdx
+++ b/docs/servers/auth/oidc-proxy.mdx
@@ -191,6 +191,20 @@ auth = OIDCProxy(
```
+
+
+ Whether to require user consent before authorizing MCP clients. When enabled (default), users see a consent screen that displays which client is requesting access. See [OAuthProxy documentation](/servers/auth/oauth-proxy#confused-deputy-attacks) for details on confused deputy attack protection.
+
+
+
+ Content Security Policy for the consent page.
+
+ - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission
+ - Empty string `""`: Disables CSP entirely (no meta tag rendered)
+ - Custom string: Uses the provided value as the CSP policy
+
+ This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives.
+
### Using Built-in Providers
diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py
index c526ddcd3..544da7063 100644
--- a/src/fastmcp/server/auth/oauth_proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy.py
@@ -246,8 +246,16 @@ def create_consent_html(
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 a styled HTML consent page for OAuth authorization requests."""
+ """Create a styled HTML consent page for OAuth authorization requests.
+
+ Args:
+ csp_policy: Content Security Policy override.
+ If None, uses the built-in CSP policy with appropriate directives.
+ If empty string "", disables CSP entirely (no meta tag is rendered).
+ If a non-empty string, uses that as the CSP policy value.
+ """
import html as html_module
client_display = html_module.escape(client_name or client_id)
@@ -368,20 +376,25 @@ def create_consent_html(
+ TOOLTIP_STYLES
)
- # Need to allow form-action for form submission
- # Chrome requires explicit scheme declarations in CSP form-action when redirect chains
- # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
- parsed_redirect = urlparse(redirect_uri)
- redirect_scheme = parsed_redirect.scheme.lower()
+ # Determine CSP policy to use
+ # If csp_policy is None, build the default CSP policy
+ # If csp_policy is empty string, CSP will be disabled entirely in create_page
+ # If csp_policy is a non-empty string, use it as-is
+ if csp_policy is None:
+ # Need to allow form-action for form submission
+ # Chrome requires explicit scheme declarations in CSP form-action when redirect chains
+ # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
+ parsed_redirect = urlparse(redirect_uri)
+ redirect_scheme = parsed_redirect.scheme.lower()
- # Build form-action directive with standard schemes plus custom protocol if present
- form_action_schemes = ["https:", "http:"]
- if redirect_scheme and redirect_scheme not in ("http", "https"):
- # Custom protocol scheme (e.g., cursor:, vscode:, etc.)
- form_action_schemes.append(f"{redirect_scheme}:")
+ # Build form-action directive with standard schemes plus custom protocol if present
+ form_action_schemes = ["https:", "http:"]
+ if redirect_scheme and redirect_scheme not in ("http", "https"):
+ # Custom protocol scheme (e.g., cursor:, vscode:, etc.)
+ form_action_schemes.append(f"{redirect_scheme}:")
- form_action_directive = " ".join(form_action_schemes)
- csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
+ form_action_directive = " ".join(form_action_schemes)
+ csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
return create_page(
content=content,
@@ -672,6 +685,7 @@ class OAuthProxy(OAuthProvider):
jwt_signing_key: str | bytes | None = None,
# Consent screen configuration
require_authorization_consent: bool = True,
+ consent_csp_policy: str | None = None,
):
"""Initialize the OAuth proxy provider.
@@ -715,6 +729,12 @@ class OAuthProxy(OAuthProvider):
When True, users see a consent screen before being redirected to the upstream IdP.
When False, authorization proceeds directly without user confirmation.
SECURITY WARNING: Only disable for local development or testing environments.
+ consent_csp_policy: Content Security Policy for the consent page.
+ If None (default), uses the built-in CSP policy with appropriate directives.
+ If empty string "", disables CSP entirely (no meta tag is rendered).
+ If a non-empty string, uses that as the CSP policy value.
+ This allows organizations with their own CSP policies to override or disable
+ the built-in CSP directives.
"""
# Always enable DCR since we implement it locally for MCP clients
@@ -775,6 +795,7 @@ class OAuthProxy(OAuthProvider):
# Consent screen configuration
self._require_authorization_consent: bool = require_authorization_consent
+ self._consent_csp_policy: str | None = consent_csp_policy
if not require_authorization_consent:
logger.warning(
"Authorization consent screen disabled - only use for local development or testing. "
@@ -2106,6 +2127,7 @@ class OAuthProxy(OAuthProvider):
server_name=server_name,
server_icon_url=server_icon_url,
server_website_url=server_website_url,
+ csp_policy=self._consent_csp_policy,
)
response = create_secure_html_response(html)
# Store CSRF in cookie with short lifetime
diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py
index 95b5e9b0e..0fc801107 100644
--- a/src/fastmcp/server/auth/oidc_proxy.py
+++ b/src/fastmcp/server/auth/oidc_proxy.py
@@ -222,6 +222,7 @@ class OIDCProxy(OAuthProxy):
token_endpoint_auth_method: str | None = None,
# Consent screen configuration
require_authorization_consent: bool = True,
+ consent_csp_policy: str | None = None,
# Extra parameters
extra_authorize_params: dict[str, str] | None = None,
extra_token_params: dict[str, str] | None = None,
@@ -262,6 +263,10 @@ class OIDCProxy(OAuthProxy):
When True, users see a consent screen before being redirected to the upstream IdP.
When False, authorization proceeds directly without user confirmation.
SECURITY WARNING: Only disable for local development or testing environments.
+ consent_csp_policy: Content Security Policy for the consent page.
+ If None (default), uses the built-in CSP policy with appropriate directives.
+ If empty string "", disables CSP entirely (no meta tag is rendered).
+ If a non-empty string, uses that as the CSP policy value.
extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint.
Useful for provider-specific parameters like prompt=consent or access_type=offline.
Example: {"prompt": "consent", "access_type": "offline"}
@@ -338,6 +343,7 @@ class OIDCProxy(OAuthProxy):
"jwt_signing_key": jwt_signing_key,
"token_endpoint_auth_method": token_endpoint_auth_method,
"require_authorization_consent": require_authorization_consent,
+ "consent_csp_policy": consent_csp_policy,
}
if redirect_path:
diff --git a/src/fastmcp/utilities/ui.py b/src/fastmcp/utilities/ui.py
index 2ada4c83b..8baacddf2 100644
--- a/src/fastmcp/utilities/ui.py
+++ b/src/fastmcp/utilities/ui.py
@@ -463,12 +463,21 @@ def create_page(
content: HTML content to place inside the page
title: Page title
additional_styles: Extra CSS to include
- csp_policy: Content Security Policy header value
+ csp_policy: Content Security Policy header value.
+ If empty string "", the CSP meta tag is omitted entirely.
Returns:
Complete HTML page as string
"""
title = html.escape(title)
+
+ # Only include CSP meta tag if policy is non-empty
+ csp_meta = (
+ f''
+ if csp_policy
+ else ""
+ )
+
return f"""
@@ -480,7 +489,7 @@ def create_page(
{BASE_STYLES}
{additional_styles}
-
+ {csp_meta}
{content}
diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py
index ae539b464..afe7eab80 100644
--- a/tests/server/auth/test_oauth_consent_flow.py
+++ b/tests/server/auth/test_oauth_consent_flow.py
@@ -884,3 +884,179 @@ class TestConsentPageServerIcon:
'alt="<script>alert("xss")</script>Server"'
in response.text
)
+
+
+class TestConsentCSPPolicy:
+ """Tests for Content Security Policy customization on consent page."""
+
+ async def test_default_csp_includes_form_action(self):
+ """Test that default CSP includes form-action directive."""
+ from unittest.mock import Mock
+
+ from fastmcp import FastMCP
+
+ verifier = Mock(spec=TokenVerifier)
+ verifier.required_scopes = ["read"]
+ verifier.verify_token = Mock(return_value=None)
+
+ # Create OAuthProxy with default CSP (no custom CSP)
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=verifier,
+ base_url="https://proxy.example.com",
+ client_storage=MemoryStore(),
+ jwt_signing_key="test-secret",
+ )
+
+ server = FastMCP(name="Test Server", auth=proxy)
+ app = server.http_app()
+
+ client_info = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+ await proxy.register_client(client_info)
+
+ from fastmcp.server.auth.oauth_proxy import OAuthTransaction
+
+ txn_id = "test-txn-id"
+ transaction = OAuthTransaction(
+ txn_id=txn_id,
+ client_id="test-client",
+ client_redirect_uri="http://localhost:12345/callback",
+ client_state="client-state",
+ code_challenge="challenge",
+ code_challenge_method="S256",
+ scopes=["read"],
+ created_at=time.time(),
+ )
+ await proxy._transaction_store.put(key=txn_id, value=transaction)
+
+ with TestClient(app) as client:
+ response = client.get(f"/consent?txn_id={txn_id}")
+
+ assert response.status_code == 200
+ # Default CSP should be present with form-action
+ assert 'http-equiv="Content-Security-Policy"' in response.text
+ assert "form-action" in response.text
+
+ async def test_empty_csp_disables_csp_meta_tag(self):
+ """Test that empty string CSP disables CSP meta tag entirely."""
+ from unittest.mock import Mock
+
+ from fastmcp import FastMCP
+
+ verifier = Mock(spec=TokenVerifier)
+ verifier.required_scopes = ["read"]
+ verifier.verify_token = Mock(return_value=None)
+
+ # Create OAuthProxy with empty CSP to disable it
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=verifier,
+ base_url="https://proxy.example.com",
+ client_storage=MemoryStore(),
+ jwt_signing_key="test-secret",
+ consent_csp_policy="", # Empty string disables CSP
+ )
+
+ server = FastMCP(name="Test Server", auth=proxy)
+ app = server.http_app()
+
+ client_info = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+ await proxy.register_client(client_info)
+
+ from fastmcp.server.auth.oauth_proxy import OAuthTransaction
+
+ txn_id = "test-txn-id"
+ transaction = OAuthTransaction(
+ txn_id=txn_id,
+ client_id="test-client",
+ client_redirect_uri="http://localhost:12345/callback",
+ client_state="client-state",
+ code_challenge="challenge",
+ code_challenge_method="S256",
+ scopes=["read"],
+ created_at=time.time(),
+ )
+ await proxy._transaction_store.put(key=txn_id, value=transaction)
+
+ with TestClient(app) as client:
+ response = client.get(f"/consent?txn_id={txn_id}")
+
+ assert response.status_code == 200
+ # CSP meta tag should NOT be present
+ assert 'http-equiv="Content-Security-Policy"' not in response.text
+
+ async def test_custom_csp_policy_is_used(self):
+ """Test that custom CSP policy is applied to consent page."""
+ from unittest.mock import Mock
+
+ from fastmcp import FastMCP
+
+ verifier = Mock(spec=TokenVerifier)
+ verifier.required_scopes = ["read"]
+ verifier.verify_token = Mock(return_value=None)
+
+ # Create OAuthProxy with custom CSP policy
+ custom_csp = "default-src 'self'; script-src 'none'"
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=verifier,
+ base_url="https://proxy.example.com",
+ client_storage=MemoryStore(),
+ jwt_signing_key="test-secret",
+ consent_csp_policy=custom_csp,
+ )
+
+ server = FastMCP(name="Test Server", auth=proxy)
+ app = server.http_app()
+
+ client_info = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+ await proxy.register_client(client_info)
+
+ from fastmcp.server.auth.oauth_proxy import OAuthTransaction
+
+ txn_id = "test-txn-id"
+ transaction = OAuthTransaction(
+ txn_id=txn_id,
+ client_id="test-client",
+ client_redirect_uri="http://localhost:12345/callback",
+ client_state="client-state",
+ code_challenge="challenge",
+ code_challenge_method="S256",
+ scopes=["read"],
+ created_at=time.time(),
+ )
+ await proxy._transaction_store.put(key=txn_id, value=transaction)
+
+ with TestClient(app) as client:
+ response = client.get(f"/consent?txn_id={txn_id}")
+
+ assert response.status_code == 200
+ # Custom CSP should be present (HTML-escaped)
+ assert 'http-equiv="Content-Security-Policy"' in response.text
+ # Check for the HTML-escaped version (single quotes become ')
+ import html
+
+ assert html.escape(custom_csp, quote=True) in response.text
+ # Default form-action should NOT be present (we're using custom)
+ assert "form-action" not in response.text
From adbb7d6e53bb0f7db4d87a0ee999109d793bca25 Mon Sep 17 00:00:00 2001
From: Muspi Merol
Date: Mon, 1 Dec 2025 21:48:42 +0800
Subject: [PATCH 06/13] =?UTF-8?q?Add=20`title`=20attribute=20to=20`ProxyTo?=
=?UTF-8?q?ol`,=20`ProxyResource`,=20=E2=80=A6=20(#2497)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: add title attribute to ProxyTool, ProxyResource, ProxyTemplate, and ProxyPrompt
* test: add title assertions for proxy tools, resources, and prompts
---
src/fastmcp/server/proxy.py | 5 +++++
tests/server/proxy/test_proxy_server.py | 12 ++++++++----
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
index deba4eec1..b78176c6c 100644
--- a/src/fastmcp/server/proxy.py
+++ b/src/fastmcp/server/proxy.py
@@ -270,6 +270,7 @@ class ProxyTool(Tool, MirroredComponent):
return cls(
client=client,
name=mcp_tool.name,
+ title=mcp_tool.title,
description=mcp_tool.description,
parameters=mcp_tool.inputSchema,
annotations=mcp_tool.annotations,
@@ -329,6 +330,7 @@ class ProxyResource(Resource, MirroredComponent):
client=client,
uri=mcp_resource.uri,
name=mcp_resource.name,
+ title=mcp_resource.title,
description=mcp_resource.description,
mime_type=mcp_resource.mimeType or "text/plain",
meta=mcp_resource.meta,
@@ -369,6 +371,7 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
client=client,
uri_template=mcp_template.uriTemplate,
name=mcp_template.name,
+ title=mcp_template.title,
description=mcp_template.description,
mime_type=mcp_template.mimeType or "text/plain",
parameters={}, # Remote templates don't have local parameters
@@ -404,6 +407,7 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
client=self._client,
uri=parameterized_uri,
name=self.name,
+ title=self.title,
description=self.description,
mime_type=result[0].mimeType,
meta=self.meta,
@@ -439,6 +443,7 @@ class ProxyPrompt(Prompt, MirroredComponent):
return cls(
client=client,
name=mcp_prompt.name,
+ title=mcp_prompt.title,
description=mcp_prompt.description,
arguments=arguments,
meta=mcp_prompt.meta,
diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py
index fd82a7f9b..0d840ed60 100644
--- a/tests/server/proxy/test_proxy_server.py
+++ b/tests/server/proxy/test_proxy_server.py
@@ -30,7 +30,7 @@ def fastmcp_server():
# --- Tools ---
- @server.tool(tags={"greet"})
+ @server.tool(tags={"greet"}, title="Greet")
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@@ -51,7 +51,7 @@ def fastmcp_server():
# --- Resources ---
- @server.resource(uri="resource://wave", tags={"wave"})
+ @server.resource(uri="resource://wave", tags={"wave"}, title="Wave")
def wave() -> str:
return "π"
@@ -59,13 +59,13 @@ def fastmcp_server():
async def get_users() -> list[dict[str, Any]]:
return USERS
- @server.resource(uri="data://user/{user_id}", tags={"users"})
+ @server.resource(uri="data://user/{user_id}", tags={"users"}, title="User Template")
async def get_user(user_id: str) -> dict[str, Any] | None:
return next((user for user in USERS if user["id"] == user_id), None)
# --- Prompts ---
- @server.prompt(tags={"welcome"})
+ @server.prompt(tags={"welcome"}, title="Welcome")
def welcome(name: str) -> str:
return f"Welcome to FastMCP, {name}!"
@@ -141,6 +141,7 @@ class TestTools:
async def test_get_tools_meta(self, proxy_server):
tools = await proxy_server.get_tools()
greet_tool = tools["greet"]
+ assert greet_tool.title == "Greet"
assert greet_tool.meta == {"_fastmcp": {"tags": ["greet"]}}
async def test_get_transformed_tools(
@@ -263,6 +264,7 @@ class TestResources:
async def test_get_resources_meta(self, proxy_server):
resources = await proxy_server.get_resources()
wave_resource = resources["resource://wave"]
+ assert wave_resource.title == "Wave"
assert wave_resource.meta == {"_fastmcp": {"tags": ["wave"]}}
async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
@@ -362,6 +364,7 @@ class TestResourceTemplates:
async def test_get_resource_templates_meta(self, proxy_server):
templates = await proxy_server.get_resource_templates()
get_user_template = templates["data://user/{user_id}"]
+ assert get_user_template.title == "User Template"
assert get_user_template.meta == {"_fastmcp": {"tags": ["users"]}}
async def test_list_resource_templates_same_as_original(
@@ -466,6 +469,7 @@ class TestPrompts:
async def test_get_prompts_meta(self, proxy_server):
prompts = await proxy_server.get_prompts()
welcome_prompt = prompts["welcome"]
+ assert welcome_prompt.title == "Welcome"
assert welcome_prompt.meta == {"_fastmcp": {"tags": ["welcome"]}}
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
From 3341c0c89358f5fd6a3cfddd6e21a8969c37292a Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 1 Dec 2025 08:55:51 -0500
Subject: [PATCH 07/13] Add icons support to proxy classes (#2502)
---
src/fastmcp/server/proxy.py | 5 ++++
tests/server/proxy/test_proxy_server.py | 35 ++++++++++++++++++++++---
2 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
index b78176c6c..1aeb02854 100644
--- a/src/fastmcp/server/proxy.py
+++ b/src/fastmcp/server/proxy.py
@@ -275,6 +275,7 @@ class ProxyTool(Tool, MirroredComponent):
parameters=mcp_tool.inputSchema,
annotations=mcp_tool.annotations,
output_schema=mcp_tool.outputSchema,
+ icons=mcp_tool.icons,
meta=mcp_tool.meta,
tags=(mcp_tool.meta or {}).get("_fastmcp", {}).get("tags", []),
_mirrored=True,
@@ -333,6 +334,7 @@ class ProxyResource(Resource, MirroredComponent):
title=mcp_resource.title,
description=mcp_resource.description,
mime_type=mcp_resource.mimeType or "text/plain",
+ icons=mcp_resource.icons,
meta=mcp_resource.meta,
tags=(mcp_resource.meta or {}).get("_fastmcp", {}).get("tags", []),
_mirrored=True,
@@ -374,6 +376,7 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
title=mcp_template.title,
description=mcp_template.description,
mime_type=mcp_template.mimeType or "text/plain",
+ icons=mcp_template.icons,
parameters={}, # Remote templates don't have local parameters
meta=mcp_template.meta,
tags=(mcp_template.meta or {}).get("_fastmcp", {}).get("tags", []),
@@ -410,6 +413,7 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
title=self.title,
description=self.description,
mime_type=result[0].mimeType,
+ icons=self.icons,
meta=self.meta,
tags=(self.meta or {}).get("_fastmcp", {}).get("tags", []),
_value=value,
@@ -446,6 +450,7 @@ class ProxyPrompt(Prompt, MirroredComponent):
title=mcp_prompt.title,
description=mcp_prompt.description,
arguments=arguments,
+ icons=mcp_prompt.icons,
meta=mcp_prompt.meta,
tags=(mcp_prompt.meta or {}).get("_fastmcp", {}).get("tags", []),
_mirrored=True,
diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py
index 0d840ed60..9373da6a6 100644
--- a/tests/server/proxy/test_proxy_server.py
+++ b/tests/server/proxy/test_proxy_server.py
@@ -6,6 +6,7 @@ import pytest
from anyio import create_task_group
from dirty_equals import Contains
from mcp import McpError
+from mcp.types import Icon
from pydantic import AnyUrl
from fastmcp import FastMCP
@@ -30,7 +31,11 @@ def fastmcp_server():
# --- Tools ---
- @server.tool(tags={"greet"}, title="Greet")
+ @server.tool(
+ tags={"greet"},
+ title="Greet",
+ icons=[Icon(src="https://example.com/greet-icon.png")],
+ )
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@@ -51,7 +56,12 @@ def fastmcp_server():
# --- Resources ---
- @server.resource(uri="resource://wave", tags={"wave"}, title="Wave")
+ @server.resource(
+ uri="resource://wave",
+ tags={"wave"},
+ title="Wave",
+ icons=[Icon(src="https://example.com/wave-icon.png")],
+ )
def wave() -> str:
return "π"
@@ -59,13 +69,22 @@ def fastmcp_server():
async def get_users() -> list[dict[str, Any]]:
return USERS
- @server.resource(uri="data://user/{user_id}", tags={"users"}, title="User Template")
+ @server.resource(
+ uri="data://user/{user_id}",
+ tags={"users"},
+ title="User Template",
+ icons=[Icon(src="https://example.com/user-icon.png")],
+ )
async def get_user(user_id: str) -> dict[str, Any] | None:
return next((user for user in USERS if user["id"] == user_id), None)
# --- Prompts ---
- @server.prompt(tags={"welcome"}, title="Welcome")
+ @server.prompt(
+ tags={"welcome"},
+ title="Welcome",
+ icons=[Icon(src="https://example.com/welcome-icon.png")],
+ )
def welcome(name: str) -> str:
return f"Welcome to FastMCP, {name}!"
@@ -143,6 +162,7 @@ class TestTools:
greet_tool = tools["greet"]
assert greet_tool.title == "Greet"
assert greet_tool.meta == {"_fastmcp": {"tags": ["greet"]}}
+ assert greet_tool.icons == [Icon(src="https://example.com/greet-icon.png")]
async def test_get_transformed_tools(
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
@@ -266,6 +286,7 @@ class TestResources:
wave_resource = resources["resource://wave"]
assert wave_resource.title == "Wave"
assert wave_resource.meta == {"_fastmcp": {"tags": ["wave"]}}
+ assert wave_resource.icons == [Icon(src="https://example.com/wave-icon.png")]
async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
assert (
@@ -366,6 +387,9 @@ class TestResourceTemplates:
get_user_template = templates["data://user/{user_id}"]
assert get_user_template.title == "User Template"
assert get_user_template.meta == {"_fastmcp": {"tags": ["users"]}}
+ assert get_user_template.icons == [
+ Icon(src="https://example.com/user-icon.png")
+ ]
async def test_list_resource_templates_same_as_original(
self, fastmcp_server, proxy_server
@@ -471,6 +495,9 @@ class TestPrompts:
welcome_prompt = prompts["welcome"]
assert welcome_prompt.title == "Welcome"
assert welcome_prompt.meta == {"_fastmcp": {"tags": ["welcome"]}}
+ assert welcome_prompt.icons == [
+ Icon(src="https://example.com/welcome-icon.png")
+ ]
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
async with Client(fastmcp_server) as client:
From 01ecc918074eb0418fb39200aa94fb787623106a Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 1 Dec 2025 09:26:27 -0500
Subject: [PATCH 08/13] Fix OAuth proxy refresh token storage for
multi-instance deployments (#2483)
* Fix OAuth proxy refresh token storage for multi-instance deployments
- Use pluggable client_storage instead of local dict for refresh tokens
- Store refresh tokens by SHA-256 hash for defense in depth
- Remove unused access token and relationship mapping stores
- Simplify revocation logic
* Address review feedback for refresh token storage
- Use calculated refresh_expires_in for TTL instead of hardcoded 30 days
- Populate expires_at field with actual expiry timestamp
- Add client_id validation in load_refresh_token to prevent cross-client token usage
---
src/fastmcp/server/auth/oauth_proxy.py | 161 ++++++++++++++-----------
1 file changed, 92 insertions(+), 69 deletions(-)
diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py
index 544da7063..a7acf1368 100644
--- a/src/fastmcp/server/auth/oauth_proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy.py
@@ -179,6 +179,28 @@ class JTIMapping(BaseModel):
created_at: float # Unix timestamp
+class RefreshTokenMetadata(BaseModel):
+ """Metadata for a refresh token, stored keyed by token hash.
+
+ We store only metadata (not the token itself) for security - if storage
+ is compromised, attackers get hashes they can't reverse into usable tokens.
+ """
+
+ client_id: str
+ scopes: list[str]
+ expires_at: int | None = None
+ created_at: float
+
+
+def _hash_token(token: str) -> str:
+ """Hash a token for secure storage lookup.
+
+ Uses SHA-256 to create a one-way hash. The original token cannot be
+ recovered from the hash, providing defense in depth if storage is compromised.
+ """
+ return hashlib.sha256(token.encode()).hexdigest()
+
+
class ProxyDCRClient(OAuthClientInformationFull):
"""Client for DCR proxy with configurable redirect URI validation.
@@ -624,14 +646,18 @@ class OAuthProxy(OAuthProvider):
State Management
---------------
- The proxy maintains minimal but crucial state:
+ The proxy maintains minimal but crucial state via pluggable storage (client_storage):
- _oauth_transactions: Active authorization flows with client context
- _client_codes: Authorization codes with PKCE challenges and upstream tokens
- - _access_tokens, _refresh_tokens: Token storage for revocation
- - Token relationship mappings for cleanup and rotation
+ - _jti_mapping_store: Maps FastMCP token JTIs to upstream token IDs
+ - _refresh_token_store: Refresh token metadata (keyed by token hash)
+
+ All state is stored in the configured client_storage backend (Redis, disk, etc.)
+ enabling horizontal scaling across multiple instances.
Security Considerations
----------------------
+ - Refresh tokens stored by hash only (defense in depth if storage compromised)
- PKCE enforced end-to-end (client to proxy, proxy to upstream)
- Authorization codes are single-use with short expiry
- Transaction IDs are cryptographically random
@@ -895,13 +921,17 @@ class OAuthProxy(OAuthProvider):
raise_on_validation_error=True,
)
- # Local state for token bookkeeping only (no client caching)
- self._access_tokens: dict[str, AccessToken] = {}
- self._refresh_tokens: dict[str, RefreshToken] = {}
-
- # Token relation mappings for cleanup
- self._access_to_refresh: dict[str, str] = {}
- self._refresh_to_access: dict[str, str] = {}
+ # Refresh token metadata storage, keyed by token hash for security.
+ # We only store metadata (not the token itself) - if storage is compromised,
+ # attackers get hashes they can't reverse into usable tokens.
+ self._refresh_token_store: PydanticAdapter[RefreshTokenMetadata] = (
+ PydanticAdapter[RefreshTokenMetadata](
+ key_value=self._client_storage,
+ pydantic_model=RefreshTokenMetadata,
+ default_collection="mcp-refresh-tokens",
+ raise_on_validation_error=True,
+ )
+ )
# Use the provided token validator
self._token_validator: TokenVerifier = token_verifier
@@ -1254,25 +1284,18 @@ class OAuthProxy(OAuthProvider):
ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days)
)
- # Store FastMCP access token for MCP framework validation
- self._access_tokens[fastmcp_access_token] = AccessToken(
- token=fastmcp_access_token,
- client_id=client.client_id,
- scopes=authorization_code.scopes,
- expires_at=int(time.time() + expires_in),
- )
-
- # Store FastMCP refresh token if provided
- if fastmcp_refresh_token:
- self._refresh_tokens[fastmcp_refresh_token] = RefreshToken(
- token=fastmcp_refresh_token,
- client_id=client.client_id,
- scopes=authorization_code.scopes,
- expires_at=None,
+ # Store refresh token metadata (keyed by hash for security)
+ if fastmcp_refresh_token and refresh_expires_in:
+ await self._refresh_token_store.put(
+ key=_hash_token(fastmcp_refresh_token),
+ value=RefreshTokenMetadata(
+ client_id=client.client_id,
+ scopes=authorization_code.scopes,
+ expires_at=int(time.time()) + refresh_expires_in,
+ created_at=time.time(),
+ ),
+ ttl=refresh_expires_in,
)
- # Maintain token relationships for cleanup
- self._access_to_refresh[fastmcp_access_token] = fastmcp_refresh_token
- self._refresh_to_access[fastmcp_refresh_token] = fastmcp_access_token
logger.debug(
"Issued FastMCP tokens for client=%s (access_jti=%s, refresh_jti=%s)",
@@ -1316,8 +1339,29 @@ class OAuthProxy(OAuthProvider):
client: OAuthClientInformationFull,
refresh_token: str,
) -> RefreshToken | None:
- """Load refresh token from local storage."""
- return self._refresh_tokens.get(refresh_token)
+ """Load refresh token metadata from distributed storage.
+
+ Looks up by token hash and reconstructs the RefreshToken object.
+ Validates that the token belongs to the requesting client.
+ """
+ token_hash = _hash_token(refresh_token)
+ metadata = await self._refresh_token_store.get(key=token_hash)
+ if not metadata:
+ return None
+ # Verify token belongs to this client (prevents cross-client token usage)
+ if metadata.client_id != client.client_id:
+ logger.warning(
+ "Refresh token client_id mismatch: expected %s, got %s",
+ client.client_id,
+ metadata.client_id,
+ )
+ return None
+ return RefreshToken(
+ token=refresh_token,
+ client_id=metadata.client_id,
+ scopes=metadata.scopes,
+ expires_at=metadata.expires_at,
+ )
async def exchange_refresh_token(
self,
@@ -1488,30 +1532,20 @@ class OAuthProxy(OAuthProvider):
"Rotated refresh token (old JTI invalidated - one-time use enforced)"
)
- # Update local token tracking
- self._access_tokens[new_fastmcp_access] = AccessToken(
- token=new_fastmcp_access,
- client_id=client.client_id,
- scopes=scopes,
- expires_at=int(time.time() + new_expires_in),
- )
- self._refresh_tokens[new_fastmcp_refresh] = RefreshToken(
- token=new_fastmcp_refresh,
- client_id=client.client_id,
- scopes=scopes,
- expires_at=None,
+ # Store new refresh token metadata (keyed by hash)
+ await self._refresh_token_store.put(
+ key=_hash_token(new_fastmcp_refresh),
+ value=RefreshTokenMetadata(
+ client_id=client.client_id,
+ scopes=scopes,
+ expires_at=int(time.time()) + refresh_ttl,
+ created_at=time.time(),
+ ),
+ ttl=refresh_ttl,
)
- # Update token relationship mappings
- self._access_to_refresh[new_fastmcp_access] = new_fastmcp_refresh
- self._refresh_to_access[new_fastmcp_refresh] = new_fastmcp_access
-
- # Clean up old token from in-memory tracking
- self._refresh_tokens.pop(refresh_token.token, None)
- old_access = self._refresh_to_access.pop(refresh_token.token, None)
- if old_access:
- self._access_tokens.pop(old_access, None)
- self._access_to_refresh.pop(old_access, None)
+ # Delete old refresh token (by hash)
+ await self._refresh_token_store.delete(key=_hash_token(refresh_token.token))
logger.info(
"Issued new FastMCP tokens (rotated refresh) for client=%s (access_jti=%s, refresh_jti=%s)",
@@ -1592,24 +1626,13 @@ class OAuthProxy(OAuthProvider):
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
"""Revoke token locally and with upstream server if supported.
- Removes tokens from local storage and attempts to revoke them with
- the upstream server if a revocation endpoint is configured.
+ For refresh tokens, removes from local storage by hash.
+ For all tokens, attempts upstream revocation if endpoint is configured.
+ Access token JTI mappings expire via TTL.
"""
- # Clean up local token storage
- if isinstance(token, AccessToken):
- self._access_tokens.pop(token.token, None)
- # Also remove associated refresh token
- paired_refresh = self._access_to_refresh.pop(token.token, None)
- if paired_refresh:
- self._refresh_tokens.pop(paired_refresh, None)
- self._refresh_to_access.pop(paired_refresh, None)
- else: # RefreshToken
- self._refresh_tokens.pop(token.token, None)
- # Also remove associated access token
- paired_access = self._refresh_to_access.pop(token.token, None)
- if paired_access:
- self._access_tokens.pop(paired_access, None)
- self._access_to_refresh.pop(paired_access, None)
+ # For refresh tokens, delete from local storage by hash
+ if isinstance(token, RefreshToken):
+ await self._refresh_token_store.delete(key=_hash_token(token.token))
# Attempt upstream revocation if endpoint is configured
if self._upstream_revocation_endpoint:
From 246a0adefd03d033f78ce2964fe102728019ed94 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 1 Dec 2025 10:51:02 -0500
Subject: [PATCH 09/13] Fix get_access_token() returning stale token after
OAuth refresh (#2505)
* Fix get_access_token() returning stale token after OAuth refresh
Fixes #1863
* Update dependencies.py
---
src/fastmcp/server/dependencies.py | 30 +++-
tests/server/http/test_stale_access_token.py | 159 +++++++++++++++++++
2 files changed, 185 insertions(+), 4 deletions(-)
create mode 100644 tests/server/http/test_stale_access_token.py
diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index 7b3386b42..68f22e606 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
from mcp.server.auth.middleware.auth_context import (
get_access_token as _sdk_get_access_token,
)
+from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import (
AccessToken as _SDKAccessToken,
)
@@ -111,17 +112,38 @@ def get_access_token() -> AccessToken | None:
"""
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.
+
Returns:
The access token if an authenticated user is available, None otherwise.
"""
- #
- access_token: _SDKAccessToken | None = _sdk_get_access_token()
+ access_token: _SDKAccessToken | None = None
+
+ # First, try to get from current HTTP request's scope (issue #1863)
+ # This is more reliable than auth_context_var for Streamable HTTP sessions
+ # where tokens may be refreshed between MCP messages
+ try:
+ request = get_http_request()
+ user = request.scope.get("user")
+ if isinstance(user, AuthenticatedUser):
+ access_token = user.access_token
+ except RuntimeError:
+ # No HTTP request available, fall back to context var
+ pass
+
+ # Fall back to SDK's context var if we didn't get a token from the request
+ if access_token is None:
+ access_token = _sdk_get_access_token()
if access_token is None or isinstance(access_token, AccessToken):
return access_token
- # If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible
- # This is a workaround for the case where the SDK returns a different type
+ # If the object is not a FastMCP AccessToken, convert it to one if the
+ # fields are compatible (e.g. `claims` is not present in the SDK's AccessToken).
+ # This is a workaround for the case where the SDK or auth provider returns a different type
# If it fails, it will raise a TypeError
try:
access_token_as_dict = access_token.model_dump()
diff --git a/tests/server/http/test_stale_access_token.py b/tests/server/http/test_stale_access_token.py
new file mode 100644
index 000000000..34f271e79
--- /dev/null
+++ b/tests/server/http/test_stale_access_token.py
@@ -0,0 +1,159 @@
+"""
+Test for issue #1863: get_access_token() returns stale token after OAuth refresh.
+
+This test demonstrates the bug where auth_context_var holds a stale token,
+but the current HTTP request (via request_ctx) has a fresh token.
+
+The test should FAIL with the current implementation and PASS after the fix.
+"""
+
+from unittest.mock import MagicMock
+
+from mcp.server.auth.middleware.auth_context import auth_context_var
+from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
+from mcp.server.lowlevel.server import request_ctx
+from mcp.shared.context import RequestContext
+from starlette.requests import Request
+
+from fastmcp.server.auth import AccessToken
+from fastmcp.server.dependencies import get_access_token
+
+
+class TestStaleAccessToken:
+ """Test that get_access_token returns fresh token from request scope."""
+
+ def test_get_access_token_prefers_request_scope_over_stale_context_var(self):
+ """
+ Regression test for issue #1863.
+
+ Scenario:
+ - auth_context_var has a STALE token (set at HTTP middleware level)
+ - request_ctx.request.scope["user"] has a FRESH token (per MCP message)
+ - get_access_token() should return the FRESH token
+
+ This simulates the case where:
+ 1. A Streamable HTTP session was established with token A
+ 2. auth_context_var was set to token A during session setup
+ 3. Token expired, client refreshed, got token B
+ 4. New MCP message arrives with token B in the request
+ 5. get_access_token() should return token B, not stale token A
+ """
+ # Create STALE token (in auth_context_var)
+ # Using FastMCP's AccessToken to avoid conversion issues
+ stale_token = AccessToken(
+ token="stale-token-from-initial-auth",
+ client_id="test-client",
+ scopes=["read"],
+ )
+ stale_user = AuthenticatedUser(stale_token)
+
+ # Create FRESH token (in request.scope["user"])
+ fresh_token = AccessToken(
+ token="fresh-token-after-refresh",
+ client_id="test-client",
+ scopes=["read"],
+ )
+ fresh_user = AuthenticatedUser(fresh_token)
+
+ # Create a mock request with fresh token in scope
+ scope = {
+ "type": "http",
+ "user": fresh_user,
+ "auth": MagicMock(),
+ }
+ mock_request = Request(scope)
+
+ # Create a mock RequestContext with the request
+ mock_request_context = MagicMock(spec=RequestContext)
+ mock_request_context.request = mock_request
+
+ # Set up the context vars:
+ # - auth_context_var has STALE token
+ # - request_ctx has request with FRESH token
+ auth_token = auth_context_var.set(stale_user)
+ request_token = request_ctx.set(mock_request_context)
+
+ try:
+ # Call get_access_token - should return FRESH token
+ result = get_access_token()
+
+ # Assert we get the FRESH token, not the stale one
+ assert result is not None, "Expected an access token but got None"
+ assert result.token == "fresh-token-after-refresh", (
+ f"Expected fresh token 'fresh-token-after-refresh' but got '{result.token}'. "
+ "get_access_token() is returning the stale token from auth_context_var "
+ "instead of the fresh token from request.scope['user']."
+ )
+ finally:
+ # Clean up context vars
+ auth_context_var.reset(auth_token)
+ request_ctx.reset(request_token)
+
+ def test_get_access_token_falls_back_to_context_var_when_no_request(self):
+ """
+ Verify that get_access_token falls back to auth_context_var
+ when there's no HTTP request available.
+ """
+ # Create token in auth_context_var using FastMCP's AccessToken
+ token = AccessToken(
+ token="context-var-token",
+ client_id="test-client",
+ scopes=["read"],
+ )
+ user = AuthenticatedUser(token)
+
+ # Set up auth_context_var but NOT request_ctx
+ auth_token = auth_context_var.set(user)
+
+ try:
+ result = get_access_token()
+
+ assert result is not None
+ assert result.token == "context-var-token"
+ finally:
+ auth_context_var.reset(auth_token)
+
+ def test_get_access_token_returns_none_when_no_auth(self):
+ """
+ Verify that get_access_token returns None when there's no
+ authenticated user anywhere.
+ """
+ result = get_access_token()
+ assert result is None
+
+ def test_get_access_token_falls_back_when_scope_user_is_not_authenticated(self):
+ """
+ Verify that get_access_token falls back to auth_context_var when
+ scope["user"] exists but is not an AuthenticatedUser (e.g., UnauthenticatedUser).
+ """
+ from starlette.authentication import UnauthenticatedUser
+
+ # Create token in auth_context_var
+ token = AccessToken(
+ token="context-var-token",
+ client_id="test-client",
+ scopes=["read"],
+ )
+ user = AuthenticatedUser(token)
+
+ # Create request with UnauthenticatedUser in scope
+ scope = {
+ "type": "http",
+ "user": UnauthenticatedUser(),
+ }
+ mock_request = Request(scope)
+ mock_request_context = MagicMock(spec=RequestContext)
+ mock_request_context.request = mock_request
+
+ auth_token = auth_context_var.set(user)
+ request_token = request_ctx.set(mock_request_context)
+
+ try:
+ result = get_access_token()
+
+ # Should fall back to auth_context_var since scope user is unauthenticated
+ assert result is not None
+ assert result.token == "context-var-token"
+ finally:
+ auth_context_var.reset(auth_token)
+ request_ctx.reset(request_token)
From e1d41f5e3b51cef93d11cd9c2864a4407d8d7858 Mon Sep 17 00:00:00 2001
From: Ayesha Shafique <79274585+Aisha630@users.noreply.github.com>
Date: Mon, 1 Dec 2025 11:58:29 -0600
Subject: [PATCH 10/13] Add Discord OAuth provider and corresponding tests
(#2428)
* Add Discord OAuth provider and corresponding tests
* Update DiscordProvider client_secret and required_scopes documentation
* Add Discord to authentication support list in README
* Fix Discord token verifier to match actual API response format
Discord's /api/oauth2/@me endpoint returns:
- "scopes" as a list, not "scope" as a space-separated string
- "expires" as ISO timestamp, not "expires_in" as seconds
- "user" data directly in the response (no need for extra API call)
* Simplify Discord token verifier
---------
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
---
README.md | 1 +
src/fastmcp/server/auth/providers/discord.py | 308 +++++++++++++++++++
tests/server/auth/providers/test_discord.py | 119 +++++++
3 files changed, 428 insertions(+)
create mode 100644 src/fastmcp/server/auth/providers/discord.py
create mode 100644 tests/server/auth/providers/test_discord.py
diff --git a/README.md b/README.md
index ca00ef3d0..bad8b2626 100644
--- a/README.md
+++ b/README.md
@@ -316,6 +316,7 @@ FastMCP provides comprehensive authentication support that sets it apart from ba
- **Auth0**
- **WorkOS**
- **Descope**
+- **Discord**
- **JWT/Custom**
- **API Keys**
diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py
new file mode 100644
index 000000000..f92a378bc
--- /dev/null
+++ b/src/fastmcp/server/auth/providers/discord.py
@@ -0,0 +1,308 @@
+"""Discord OAuth provider for FastMCP.
+
+This module provides a complete Discord OAuth integration that's ready to use
+with just a client ID and client secret. It handles all the complexity of
+Discord's OAuth flow, token validation, and user management.
+
+Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.auth.providers.discord import DiscordProvider
+
+ # Simple Discord OAuth protection
+ auth = DiscordProvider(
+ client_id="your-discord-client-id",
+ client_secret="your-discord-client-secret"
+ )
+
+ mcp = FastMCP("My Protected Server", auth=auth)
+ ```
+"""
+
+from __future__ import annotations
+
+import time
+from datetime import datetime
+
+import httpx
+from key_value.aio.protocols import AsyncKeyValue
+from pydantic import AnyHttpUrl, SecretStr, field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from fastmcp.server.auth import TokenVerifier
+from fastmcp.server.auth.auth import AccessToken
+from fastmcp.server.auth.oauth_proxy import OAuthProxy
+from fastmcp.settings import ENV_FILE
+from fastmcp.utilities.auth import parse_scopes
+from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.types import NotSet, NotSetT
+
+logger = get_logger(__name__)
+
+
+class DiscordProviderSettings(BaseSettings):
+ """Settings for Discord OAuth provider."""
+
+ model_config = SettingsConfigDict(
+ env_prefix="FASTMCP_SERVER_AUTH_DISCORD_",
+ env_file=ENV_FILE,
+ extra="ignore",
+ )
+
+ client_id: str | None = None
+ client_secret: SecretStr | None = None
+ base_url: AnyHttpUrl | str | None = None
+ issuer_url: AnyHttpUrl | str | None = None
+ redirect_path: str | None = None
+ required_scopes: list[str] | None = None
+ timeout_seconds: int | None = None
+ allowed_client_redirect_uris: list[str] | None = None
+ jwt_signing_key: str | None = None
+
+ @field_validator("required_scopes", mode="before")
+ @classmethod
+ def _parse_scopes(cls, v):
+ return parse_scopes(v)
+
+
+class DiscordTokenVerifier(TokenVerifier):
+ """Token verifier for Discord OAuth tokens.
+
+ Discord OAuth tokens are opaque (not JWTs), so we verify them
+ by calling Discord's tokeninfo API to check if they're valid and get user info.
+ """
+
+ def __init__(
+ self,
+ *,
+ required_scopes: list[str] | None = None,
+ timeout_seconds: int = 10,
+ ):
+ """Initialize the Discord token verifier.
+
+ Args:
+ required_scopes: Required OAuth scopes (e.g., ['email'])
+ timeout_seconds: HTTP request timeout
+ """
+ super().__init__(required_scopes=required_scopes)
+ self.timeout_seconds = timeout_seconds
+
+ async def verify_token(self, token: str) -> AccessToken | None:
+ """Verify Discord OAuth token by calling Discord's tokeninfo API."""
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ # Use Discord's tokeninfo endpoint to validate the token
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "User-Agent": "FastMCP-Discord-OAuth",
+ }
+ response = await client.get(
+ "https://discord.com/api/oauth2/@me",
+ headers=headers,
+ )
+
+ if response.status_code != 200:
+ logger.debug(
+ "Discord token verification failed: %d",
+ response.status_code,
+ )
+ return None
+
+ token_info = response.json()
+
+ # Check if token is expired (Discord returns ISO timestamp)
+ expires_str = token_info.get("expires")
+ expires_at = None
+ if expires_str:
+ expires_dt = datetime.fromisoformat(
+ expires_str.replace("Z", "+00:00")
+ )
+ expires_at = int(expires_dt.timestamp())
+ if expires_at <= int(time.time()):
+ logger.debug("Discord token has expired")
+ return None
+
+ token_scopes = token_info.get("scopes", [])
+
+ # Check required scopes
+ if self.required_scopes:
+ token_scopes_set = set(token_scopes)
+ required_scopes_set = set(self.required_scopes)
+ if not required_scopes_set.issubset(token_scopes_set):
+ logger.debug(
+ "Discord token missing required scopes. Has %d, needs %d",
+ len(token_scopes_set),
+ len(required_scopes_set),
+ )
+ return None
+
+ user_data = token_info.get("user", {})
+ application = token_info.get("application") or {}
+ client_id = str(application.get("id", "unknown"))
+
+ # Create AccessToken with Discord user info
+ access_token = AccessToken(
+ token=token,
+ client_id=client_id,
+ scopes=token_scopes,
+ expires_at=expires_at,
+ claims={
+ "sub": user_data.get("id"),
+ "username": user_data.get("username"),
+ "discriminator": user_data.get("discriminator"),
+ "avatar": user_data.get("avatar"),
+ "email": user_data.get("email"),
+ "verified": user_data.get("verified"),
+ "locale": user_data.get("locale"),
+ "discord_user": user_data,
+ "discord_token_info": token_info,
+ },
+ )
+ logger.debug("Discord token verified successfully")
+ return access_token
+
+ except httpx.RequestError as e:
+ logger.debug("Failed to verify Discord token: %s", e)
+ return None
+ except Exception as e:
+ logger.debug("Discord token verification error: %s", e)
+ return None
+
+
+class DiscordProvider(OAuthProxy):
+ """Complete Discord OAuth provider for FastMCP.
+
+ This provider makes it trivial to add Discord OAuth protection to any
+ FastMCP server. Just provide your Discord OAuth app credentials and
+ a base URL, and you're ready to go.
+
+ Features:
+ - Transparent OAuth proxy to Discord
+ - Automatic token validation via Discord's API
+ - User information extraction from Discord APIs
+ - Minimal configuration required
+
+ Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.auth.providers.discord import DiscordProvider
+
+ auth = DiscordProvider(
+ client_id="123456789",
+ client_secret="discord-client-secret-abc123...",
+ base_url="https://my-server.com"
+ )
+
+ mcp = FastMCP("My App", auth=auth)
+ ```
+ """
+
+ def __init__(
+ self,
+ *,
+ client_id: str | NotSetT = NotSet,
+ client_secret: str | NotSetT = NotSet,
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
+ issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
+ redirect_path: str | NotSetT = NotSet,
+ required_scopes: list[str] | NotSetT = NotSet,
+ timeout_seconds: int | NotSetT = NotSet,
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
+ client_storage: AsyncKeyValue | None = None,
+ jwt_signing_key: str | bytes | NotSetT = NotSet,
+ require_authorization_consent: bool = True,
+ ):
+ """Initialize Discord OAuth provider.
+
+ Args:
+ client_id: Discord OAuth client ID (e.g., "123456789")
+ client_secret: Discord OAuth client secret (e.g., "S....")
+ base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
+ to avoid 404s during discovery when mounting under a path.
+ redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback")
+ required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include:
+ - "identify" for profile info (default)
+ - "email" for email access
+ - "guilds" for server membership info
+ timeout_seconds: HTTP request timeout for Discord API calls
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
+ client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
+ If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The
+ disk store will be encrypted using a key derived from the JWT Signing Key.
+ jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
+ they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
+ provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
+ require_authorization_consent: Whether to require user consent before authorizing clients (default True).
+ When True, users see a consent screen before being redirected to Discord.
+ When False, authorization proceeds directly without user confirmation.
+ SECURITY WARNING: Only disable for local development or testing environments.
+ """
+
+ settings = DiscordProviderSettings.model_validate(
+ {
+ k: v
+ for k, v in {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "base_url": base_url,
+ "issuer_url": issuer_url,
+ "redirect_path": redirect_path,
+ "required_scopes": required_scopes,
+ "timeout_seconds": timeout_seconds,
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
+ "jwt_signing_key": jwt_signing_key,
+ }.items()
+ if v is not NotSet
+ }
+ )
+
+ # Validate required settings
+ if not settings.client_id:
+ raise ValueError(
+ "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID"
+ )
+ if not settings.client_secret:
+ raise ValueError(
+ "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET"
+ )
+
+ # Apply defaults
+ timeout_seconds_final = settings.timeout_seconds or 10
+ required_scopes_final = settings.required_scopes or ["identify"]
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
+
+ # Create Discord token verifier
+ token_verifier = DiscordTokenVerifier(
+ required_scopes=required_scopes_final,
+ timeout_seconds=timeout_seconds_final,
+ )
+
+ # Extract secret string from SecretStr
+ client_secret_str = (
+ settings.client_secret.get_secret_value() if settings.client_secret else ""
+ )
+
+ # Initialize OAuth proxy with Discord endpoints
+ super().__init__(
+ upstream_authorization_endpoint="https://discord.com/oauth2/authorize",
+ upstream_token_endpoint="https://discord.com/api/oauth2/token",
+ upstream_client_id=settings.client_id,
+ upstream_client_secret=client_secret_str,
+ token_verifier=token_verifier,
+ base_url=settings.base_url,
+ redirect_path=settings.redirect_path,
+ issuer_url=settings.issuer_url
+ or settings.base_url, # Default to base_url if not specified
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
+ client_storage=client_storage,
+ jwt_signing_key=settings.jwt_signing_key,
+ require_authorization_consent=require_authorization_consent,
+ )
+
+ logger.debug(
+ "Initialized Discord OAuth provider for client %s with scopes: %s",
+ settings.client_id,
+ required_scopes_final,
+ )
diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py
new file mode 100644
index 000000000..ee07e6c60
--- /dev/null
+++ b/tests/server/auth/providers/test_discord.py
@@ -0,0 +1,119 @@
+"""Tests for Discord OAuth provider."""
+
+import os
+from unittest.mock import patch
+
+import pytest
+
+from fastmcp.server.auth.providers.discord import DiscordProvider
+
+
+class TestDiscordProvider:
+ """Test Discord OAuth provider functionality."""
+
+ def test_init_with_explicit_params(self):
+ """Test DiscordProvider initialization with explicit parameters."""
+ provider = DiscordProvider(
+ client_id="env_client_id",
+ client_secret="GOCSPX-test123",
+ base_url="https://myserver.com",
+ required_scopes=["email", "identify"],
+ jwt_signing_key="test-secret",
+ )
+
+ 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/"
+
+ @pytest.mark.parametrize(
+ "scopes_env",
+ [
+ "identify,email",
+ '["identify", "email"]',
+ ],
+ )
+ def test_init_with_env_vars(self, scopes_env):
+ """Test DiscordProvider initialization from environment variables."""
+ with patch.dict(
+ os.environ,
+ {
+ "FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID": "env_client_id",
+ "FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET": "GOCSPX-env456",
+ "FASTMCP_SERVER_AUTH_DISCORD_BASE_URL": "https://envserver.com",
+ "FASTMCP_SERVER_AUTH_DISCORD_REQUIRED_SCOPES": scopes_env,
+ "FASTMCP_SERVER_AUTH_DISCORD_JWT_SIGNING_KEY": "test-secret",
+ },
+ ):
+ provider = DiscordProvider()
+
+ assert provider._upstream_client_id == "env_client_id"
+ assert (
+ provider._upstream_client_secret.get_secret_value() == "GOCSPX-env456"
+ )
+ assert str(provider.base_url) == "https://envserver.com/"
+ assert provider._token_validator.required_scopes == [
+ "identify",
+ "email",
+ ]
+
+ def test_init_missing_client_id_raises_error(self):
+ """Test that missing client_id raises ValueError."""
+ # Clear environment variables to test proper error handling
+ with patch.dict(os.environ, {}, clear=True):
+ with pytest.raises(ValueError, match="client_id is required"):
+ DiscordProvider(client_secret="GOCSPX-test123")
+
+ def test_init_missing_client_secret_raises_error(self):
+ """Test that missing client_secret raises ValueError."""
+ # Clear environment variables to test proper error handling
+ with patch.dict(os.environ, {}, clear=True):
+ with pytest.raises(ValueError, match="client_secret is required"):
+ DiscordProvider(client_id="env_client_id")
+
+ def test_init_defaults(self):
+ """Test that default values are applied correctly."""
+ provider = DiscordProvider(
+ client_id="env_client_id",
+ client_secret="GOCSPX-test123",
+ jwt_signing_key="test-secret",
+ )
+
+ # Check defaults
+ assert provider.base_url is None
+ assert provider._redirect_path == "/auth/callback"
+
+ def test_oauth_endpoints_configured_correctly(self):
+ """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",
+ )
+
+ # Check that endpoints use Discord's OAuth2 endpoints
+ assert (
+ provider._upstream_authorization_endpoint
+ == "https://discord.com/oauth2/authorize"
+ )
+ assert (
+ provider._upstream_token_endpoint == "https://discord.com/api/oauth2/token"
+ )
+ # Discord provider doesn't currently set a revocation endpoint
+ assert provider._upstream_revocation_endpoint is None
+
+ def test_discord_specific_scopes(self):
+ """Test handling of Discord-specific scope formats."""
+ # Just test that the provider accepts Discord-specific scopes without error
+ provider = DiscordProvider(
+ client_id="env_client_id",
+ client_secret="GOCSPX-test123",
+ required_scopes=[
+ "identify",
+ "email",
+ ],
+ jwt_signing_key="test-secret",
+ )
+
+ # Provider should initialize successfully with these scopes
+ assert provider is not None
From aa53bdf53ef7ba12456fd72191a70543a6269261 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 1 Dec 2025 13:34:33 -0500
Subject: [PATCH 11/13] Add Discord OAuth integration documentation (#2508)
---
docs/docs.json | 1 +
docs/integrations/discord.mdx | 259 ++++++++++++++++++++++++++++++++++
2 files changed, 260 insertions(+)
create mode 100644 docs/integrations/discord.mdx
diff --git a/docs/docs.json b/docs/docs.json
index 856a61d65..76488b529 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -197,6 +197,7 @@
"integrations/aws-cognito",
"integrations/azure",
"integrations/descope",
+ "integrations/discord",
"integrations/github",
"integrations/scalekit",
"integrations/google",
diff --git a/docs/integrations/discord.mdx b/docs/integrations/discord.mdx
new file mode 100644
index 000000000..9e8338c84
--- /dev/null
+++ b/docs/integrations/discord.mdx
@@ -0,0 +1,259 @@
+---
+title: Discord OAuth π€ FastMCP
+sidebarTitle: Discord
+description: Secure your FastMCP server with Discord OAuth
+icon: discord
+tag: NEW
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+This guide shows you how to secure your FastMCP server using **Discord OAuth**. Since Discord doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Discord's traditional OAuth with MCP's authentication requirements.
+
+## Configuration
+
+### Prerequisites
+
+Before you begin, you will need:
+1. A **[Discord Account](https://discord.com/)** with access to create applications
+2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`)
+
+### Step 1: Create a Discord Application
+
+Create an application in the Discord Developer Portal to get the credentials needed for authentication:
+
+
+
+ Go to the [Discord Developer Portal](https://discord.com/developers/applications).
+
+ Click **"New Application"** and give it a name users will recognize (e.g., "My FastMCP Server").
+
+
+
+ In the left sidebar, click **"OAuth2"**.
+
+ In the **Redirects** section, click **"Add Redirect"** and enter your callback URL:
+ - For development: `http://localhost:8000/auth/callback`
+ - For production: `https://your-domain.com/auth/callback`
+
+
+ The redirect URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. Discord allows `http://localhost` URLs for development. For production, use HTTPS.
+
+
+
+
+ On the same OAuth2 page, you'll find:
+
+ - **Client ID**: A numeric string like `12345`
+ - **Client Secret**: Click "Reset Secret" to generate one
+
+
+ Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production.
+
+
+
+
+### Step 2: FastMCP Configuration
+
+Create your FastMCP server using the `DiscordProvider`, which handles Discord's OAuth flow automatically:
+
+```python server.py
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.discord import DiscordProvider
+
+auth_provider = DiscordProvider(
+ client_id="12345", # Your Discord Application Client ID
+ client_secret="your-client-secret", # Your Discord OAuth Client Secret
+ base_url="http://localhost:8000", # Must match your OAuth configuration
+)
+
+mcp = FastMCP(name="Discord Secured App", auth=auth_provider)
+
+@mcp.tool
+async def get_user_info() -> dict:
+ """Returns information about the authenticated Discord user."""
+ from fastmcp.server.dependencies import get_access_token
+
+ token = get_access_token()
+ return {
+ "discord_id": token.claims.get("sub"),
+ "username": token.claims.get("username"),
+ "avatar": token.claims.get("avatar"),
+ }
+```
+
+## Testing
+
+### Running the Server
+
+Start your FastMCP server with HTTP transport to enable OAuth flows:
+
+```bash
+fastmcp run server.py --transport http --port 8000
+```
+
+Your server is now running and protected by Discord OAuth authentication.
+
+### Testing with a Client
+
+Create a test client that authenticates with your Discord-protected server:
+
+```python test_client.py
+from fastmcp import Client
+import asyncio
+
+async def main():
+ async with Client("http://localhost:8000/mcp", auth="oauth") as client:
+ print("β Authenticated with Discord!")
+
+ result = await client.call_tool("get_user_info")
+ print(f"Discord user: {result['username']}")
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+When you run the client for the first time:
+1. Your browser will open to Discord's authorization page
+2. Sign in with your Discord account and authorize the app
+3. After authorization, you'll be redirected back
+4. The client receives the token and can make authenticated requests
+
+
+The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache.
+
+
+## Discord Scopes
+
+Discord OAuth supports several scopes for accessing different types of user data:
+
+| Scope | Description |
+|-------|-------------|
+| `identify` | Access username, avatar, and discriminator (default) |
+| `email` | Access the user's email address |
+| `guilds` | Access the user's list of servers |
+| `guilds.join` | Ability to add the user to a server |
+
+To request additional scopes:
+
+```python
+auth_provider = DiscordProvider(
+ client_id="...",
+ client_secret="...",
+ base_url="http://localhost:8000",
+ required_scopes=["identify", "email"],
+)
+```
+
+## Production Configuration
+
+
+
+For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`:
+
+```python server.py
+import os
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.discord import DiscordProvider
+from key_value.aio.stores.redis import RedisStore
+from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
+from cryptography.fernet import Fernet
+
+auth_provider = DiscordProvider(
+ client_id="12345",
+ client_secret=os.environ["DISCORD_CLIENT_SECRET"],
+ base_url="https://your-production-domain.com",
+
+ jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
+ client_storage=FernetEncryptionWrapper(
+ key_value=RedisStore(
+ host=os.environ["REDIS_HOST"],
+ port=int(os.environ["REDIS_PORT"])
+ ),
+ fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"])
+ )
+)
+
+mcp = FastMCP(name="Production Discord App", auth=auth_provider)
+```
+
+
+Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments.
+
+For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters).
+
+
+## Environment Variables
+
+
+
+For production deployments, use environment variables instead of hardcoding credentials.
+
+### Provider Selection
+
+Setting this environment variable allows the Discord provider to be used automatically without explicitly instantiating it in code.
+
+
+
+Set to `fastmcp.server.auth.providers.discord.DiscordProvider` to use Discord authentication.
+
+
+
+### Discord-Specific Configuration
+
+These environment variables provide default values for the Discord provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`.
+
+
+
+Your Discord Application Client ID (e.g., `12345`)
+
+
+
+Your Discord OAuth Client Secret
+
+
+
+Public URL where OAuth endpoints will be accessible (includes any mount path)
+
+
+
+Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details.
+
+
+
+Redirect path configured in your Discord OAuth settings
+
+
+
+Comma-, space-, or JSON-separated list of required Discord scopes (e.g., `identify,email` or `["identify","email"]`)
+
+
+
+HTTP request timeout for Discord API calls
+
+
+
+Example `.env` file:
+```bash
+FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.discord.DiscordProvider
+
+FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID=12345
+FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET=your-client-secret
+FASTMCP_SERVER_AUTH_DISCORD_BASE_URL=https://your-server.com
+FASTMCP_SERVER_AUTH_DISCORD_REQUIRED_SCOPES=identify,email
+```
+
+With environment variables set, your server code simplifies to:
+
+```python server.py
+from fastmcp import FastMCP
+
+mcp = FastMCP(name="Discord Secured App")
+
+@mcp.tool
+async def protected_tool(query: str) -> str:
+ """A tool that requires Discord authentication to access."""
+ return f"Processing authenticated request: {query}"
+```
From 83085c3cd3d5f43addf973eb5ca06fec6e24f258 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 1 Dec 2025 13:39:43 -0500
Subject: [PATCH 12/13] Fix version badges for icons and website_url; add
Discord example (#2509)
* Fix version badges and remove redundant badges from Discord doc
* Add Discord OAuth example
---
docs/integrations/discord.mdx | 4 ---
docs/servers/prompts.mdx | 2 +-
docs/servers/resources.mdx | 2 +-
docs/servers/server.mdx | 4 +--
docs/servers/tools.mdx | 2 +-
examples/auth/discord_oauth/README.md | 33 +++++++++++++++++++++++++
examples/auth/discord_oauth/client.py | 32 ++++++++++++++++++++++++
examples/auth/discord_oauth/server.py | 35 +++++++++++++++++++++++++++
8 files changed, 105 insertions(+), 9 deletions(-)
create mode 100644 examples/auth/discord_oauth/README.md
create mode 100644 examples/auth/discord_oauth/client.py
create mode 100644 examples/auth/discord_oauth/server.py
diff --git a/docs/integrations/discord.mdx b/docs/integrations/discord.mdx
index 9e8338c84..4e3670358 100644
--- a/docs/integrations/discord.mdx
+++ b/docs/integrations/discord.mdx
@@ -149,8 +149,6 @@ auth_provider = DiscordProvider(
## Production Configuration
-
-
For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`:
```python server.py
@@ -187,8 +185,6 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s
## Environment Variables
-
-
For production deployments, use environment variables instead of hardcoding credentials.
### Provider Selection
diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx
index c122daa2b..710b35ce3 100644
--- a/docs/servers/prompts.mdx
+++ b/docs/servers/prompts.mdx
@@ -98,7 +98,7 @@ def data_analysis_prompt(
-
+
Optional list of icon representations for this prompt. See [Icons](/servers/icons) for detailed examples
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index d3c8e3cdd..86c043d35 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -107,7 +107,7 @@ def get_application_status() -> dict:
-
+
Optional list of icon representations for this resource or template. See [Icons](/servers/icons) for detailed examples
diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx
index 8c46654a0..f1f4ed6da 100644
--- a/docs/servers/server.mdx
+++ b/docs/servers/server.mdx
@@ -45,13 +45,13 @@ The `FastMCP` constructor accepts several arguments:
-
+
URL to a website with more information about your server. Displayed in client applications
-
+
List of icon representations for your server. Icons help users visually identify your server in client applications. See [Icons](/servers/icons) for detailed examples
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 92812378d..553f257dd 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -82,7 +82,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l
-
+
Optional list of icon representations for this tool. See [Icons](/servers/icons) for detailed examples
diff --git a/examples/auth/discord_oauth/README.md b/examples/auth/discord_oauth/README.md
new file mode 100644
index 000000000..74217f833
--- /dev/null
+++ b/examples/auth/discord_oauth/README.md
@@ -0,0 +1,33 @@
+# Discord OAuth Example
+
+Demonstrates FastMCP server protection with Discord OAuth.
+
+## Setup
+
+1. Create a Discord OAuth App:
+ - Go to https://discord.com/developers/applications
+ - Click "New Application" and give it a name
+ - Go to OAuth2 in the left sidebar
+ - Add a Redirect URL: `http://localhost:8000/auth/callback`
+ - Copy the Client ID and Client Secret
+
+2. Set environment variables:
+
+ ```bash
+ export FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID="your-client-id"
+ export FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET="your-client-secret"
+ ```
+
+3. Run the server:
+
+ ```bash
+ python server.py
+ ```
+
+4. In another terminal, run the client:
+
+ ```bash
+ python client.py
+ ```
+
+The client will open your browser for Discord authentication.
diff --git a/examples/auth/discord_oauth/client.py b/examples/auth/discord_oauth/client.py
new file mode 100644
index 000000000..880b86f4d
--- /dev/null
+++ b/examples/auth/discord_oauth/client.py
@@ -0,0 +1,32 @@
+"""Discord OAuth client example for connecting to FastMCP servers.
+
+This example demonstrates how to connect to a Discord OAuth-protected FastMCP server.
+
+To run:
+ python client.py
+"""
+
+import asyncio
+
+from fastmcp.client import Client
+
+SERVER_URL = "http://127.0.0.1:8000/mcp"
+
+
+async def main():
+ try:
+ async with Client(SERVER_URL, auth="oauth") as client:
+ assert await client.ping()
+ print("β
Successfully authenticated!")
+
+ tools = await client.list_tools()
+ print(f"π§ Available tools ({len(tools)}):")
+ for tool in tools:
+ print(f" - {tool.name}: {tool.description}")
+ except Exception as e:
+ print(f"β Authentication failed: {e}")
+ raise
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/auth/discord_oauth/server.py b/examples/auth/discord_oauth/server.py
new file mode 100644
index 000000000..424c97bdb
--- /dev/null
+++ b/examples/auth/discord_oauth/server.py
@@ -0,0 +1,35 @@
+"""Discord OAuth server example for FastMCP.
+
+This example demonstrates how to protect a FastMCP server with Discord OAuth.
+
+Required environment variables:
+- FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID: Your Discord OAuth app client ID
+- FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET: Your Discord OAuth app client secret
+
+To run:
+ python server.py
+"""
+
+import os
+
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.discord import DiscordProvider
+
+auth = DiscordProvider(
+ client_id=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID") or "",
+ client_secret=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET") or "",
+ base_url="http://localhost:8000",
+ # redirect_path="/auth/callback", # Default path - change if using a different callback URL
+)
+
+mcp = FastMCP("Discord OAuth Example Server", auth=auth)
+
+
+@mcp.tool
+def echo(message: str) -> str:
+ """Echo the provided message."""
+ return message
+
+
+if __name__ == "__main__":
+ mcp.run(transport="http", port=8000)
From 9c21754a457b63bdb5895ce109bebd94df87ed54 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 1 Dec 2025 13:42:36 -0500
Subject: [PATCH 13/13] Fix Azure provider OIDC scope handling (#2506)
* Fix Azure provider to handle OIDC scopes correctly
OIDC scopes (openid, profile, email, offline_access) were being
incorrectly prefixed with identifier_uri, causing Azure to reject
authorization requests. This fix:
- Detects OIDC scopes and sends them unprefixed to Azure
- Filters OIDC scopes from token validation (Azure doesn't include
them in access token scp claims)
- Still advertises OIDC scopes to clients via valid_scopes
- Also handles dot-notation scopes (e.g., User.Read) correctly
Fixes #2451, #2420
* Fix dot-notation scopes to be prefixed (custom scopes can have dots)
* Improve Azure scope handling docs with clear examples
---
docs/integrations/azure.mdx | 30 ++++
src/fastmcp/server/auth/providers/azure.py | 44 ++++-
tests/server/auth/providers/test_azure.py | 180 ++++++++++++++++++++-
3 files changed, 243 insertions(+), 11 deletions(-)
diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx
index 90dcfd1bc..e18843abf 100644
--- a/docs/integrations/azure.mdx
+++ b/docs/integrations/azure.mdx
@@ -164,6 +164,34 @@ Using your specific tenant ID is recommended for better security and control.
**Important**: The `required_scopes` parameter is **REQUIRED** and must include at least one scope. Azure's OAuth API requires the `scope` parameter in all authorization requests - you cannot authenticate without specifying at least one scope. Use the unprefixed scope names from your Azure App registration (e.g., `["read", "write"]`). These scopes must be created under **Expose an API** in your App registration.
+### Scope Handling
+
+FastMCP automatically prefixes `required_scopes` with your `identifier_uri` (e.g., `api://your-client-id`) since these are your custom API scopes. Scopes in `additional_authorize_scopes` are sent as-is since they target external resources like Microsoft Graph.
+
+**`required_scopes`** β Your custom API scopes, defined in Azure "Expose an API":
+
+| You write | Sent to Azure | Validated on tokens |
+|-----------|---------------|---------------------|
+| `mcp-read` | `api://xxx/mcp-read` | β |
+| `my.scope` | `api://xxx/my.scope` | β |
+| `openid` | `openid` | β (OIDC scope) |
+| `api://xxx/read` | `api://xxx/read` | β |
+
+**`additional_authorize_scopes`** β External scopes (e.g., Microsoft Graph) for server-side use:
+
+| You write | Sent to Azure | Validated on tokens |
+|-----------|---------------|---------------------|
+| `User.Read` | `User.Read` | β |
+| `Mail.Send` | `Mail.Send` | β |
+
+
+**Why aren't `additional_authorize_scopes` validated?** Azure issues separate tokens per resource. The access token FastMCP receives is for *your API*βGraph scopes aren't in its `scp` claim. To call Graph APIs, your server uses the upstream Azure token in an on-behalf-of (OBO) flow.
+
+
+
+OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are never prefixed and excluded from validation because Azure doesn't include them in access token `scp` claims.
+
+
## Testing
### Running the Server
@@ -304,6 +332,8 @@ Redirect path configured in your Azure App registration
Comma-, space-, or JSON-separated list of required scopes for your API (at least one scope required). These are validated on tokens and used as defaults if the client does not request specific scopes. Use unprefixed scope names from your Azure App registration (e.g., `read,write`).
+You can include standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) in `required_scopes`. FastMCP automatically handles them correctly: they're sent to Azure unprefixed and excluded from token validation (since Azure doesn't include OIDC scopes in access token `scp` claims).
+
Azure's OAuth API requires the `scope` parameter - you must provide at least one scope.
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index c216e351a..591919fa8 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -25,6 +25,11 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
+# Standard OIDC scopes that should never be prefixed with identifier_uri.
+# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc
+# "OIDC scopes are requested as simple string identifiers without resource prefixes"
+OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"})
+
class AzureProviderSettings(BaseSettings):
"""Settings for Azure OAuth provider."""
@@ -240,13 +245,25 @@ class AzureProvider(OAuthProxy):
f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys"
)
- # Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
+ # Azure access tokens only include custom API scopes in the `scp` claim,
+ # NOT standard OIDC scopes (openid, profile, email, offline_access).
+ # Filter out OIDC scopes from validation - they'll still be sent to Azure
+ # during authorization (handled by _prefix_scopes_for_azure).
+ validation_scopes = None
+ if settings.required_scopes:
+ validation_scopes = [
+ s for s in settings.required_scopes if s not in OIDC_SCOPES
+ ]
+ # If all scopes were OIDC scopes, use None (no scope validation)
+ if not validation_scopes:
+ validation_scopes = None
+
token_verifier = JWTVerifier(
jwks_uri=jwks_uri,
issuer=issuer,
audience=settings.client_id,
algorithm="RS256",
- required_scopes=settings.required_scopes, # Unprefixed scopes for validation
+ required_scopes=validation_scopes, # Only validate non-OIDC scopes
)
# Extract secret string from SecretStr
@@ -277,6 +294,8 @@ class AzureProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=settings.jwt_signing_key,
require_authorization_consent=require_authorization_consent,
+ # Advertise full scopes including OIDC (even though we only validate non-OIDC)
+ valid_scopes=settings.required_scopes,
)
authority_info = ""
@@ -328,11 +347,20 @@ class AzureProvider(OAuthProxy):
return f"{auth_url}{separator}prompt=select_account"
def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
- """Prefix unprefixed scopes with identifier_uri for Azure.
+ """Prefix unprefixed custom API scopes with identifier_uri for Azure.
This helper centralizes the scope prefixing logic used in both
authorization and token refresh flows.
+ Scopes that are NOT prefixed:
+ - Standard OIDC scopes (openid, profile, email, offline_access)
+ - Fully-qualified URIs (contain "://")
+ - Scopes with path component (contain "/")
+
+ Note: Microsoft Graph scopes (e.g., User.Read) should be passed via
+ `additional_authorize_scopes` or use fully-qualified format
+ (e.g., https://graph.microsoft.com/User.Read).
+
Args:
scopes: List of scopes, may be prefixed or unprefixed
@@ -341,11 +369,15 @@ class AzureProvider(OAuthProxy):
"""
prefixed = []
for scope in scopes:
- if "://" in scope or "/" in scope:
- # Already fully-qualified (e.g., "api://xxx/read" or "User.Read")
+ if scope in OIDC_SCOPES:
+ # Standard OIDC scopes - never prefix
+ prefixed.append(scope)
+ elif "://" in scope or "/" in scope:
+ # Already fully-qualified (e.g., "api://xxx/read" or
+ # "https://graph.microsoft.com/User.Read")
prefixed.append(scope)
else:
- # Unprefixed client scope - prefix with identifier_uri
+ # Unprefixed custom API scope - prefix with identifier_uri
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 70098725a..b8ead4655 100644
--- a/tests/server/auth/providers/test_azure.py
+++ b/tests/server/auth/providers/test_azure.py
@@ -9,7 +9,7 @@ from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
-from fastmcp.server.auth.providers.azure import AzureProvider
+from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider
class TestAzureProvider:
@@ -288,7 +288,7 @@ class TestAzureProvider:
redirect_uri_provided_explicitly=True,
scopes=[
"read",
- "profile",
+ "write",
], # Client sends unprefixed scopes (from PRM which advertises unprefixed)
state="abc",
code_challenge="xyz",
@@ -307,7 +307,7 @@ class TestAzureProvider:
transaction = await provider._transaction_store.get(key=txn_id)
assert transaction is not None
assert "read" in transaction.scopes
- assert "profile" in transaction.scopes
+ assert "write" in transaction.scopes
# Azure provider filters resource parameter (not stored in transaction)
assert transaction.resource is None
@@ -320,8 +320,8 @@ class TestAzureProvider:
or "api://my-api/read" in upstream_url
)
assert (
- "api%3A%2F%2Fmy-api%2Fprofile" in upstream_url
- or "api://my-api/profile" in upstream_url
+ "api%3A%2F%2Fmy-api%2Fwrite" in upstream_url
+ or "api://my-api/write" in upstream_url
)
async def test_authorize_appends_additional_scopes(self):
@@ -709,3 +709,173 @@ class TestAzureProvider:
# Should only have 2 items (read processed twice, but deduplicated)
assert len(result) == 2
assert result.count("api://my-api/read") == 1
+
+
+class TestOIDCScopeHandling:
+ """Tests for OIDC scope handling in Azure provider.
+
+ Azure access tokens do NOT include OIDC scopes (openid, profile, email,
+ offline_access) in the `scp` claim - they're only used during authorization.
+ These tests verify that:
+ 1. OIDC scopes are never prefixed with identifier_uri
+ 2. OIDC scopes are filtered from token validation
+ 3. OIDC scopes are still advertised to clients via valid_scopes
+ """
+
+ def test_oidc_scopes_constant(self):
+ """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):
+ """Test that _prefix_scopes_for_azure never prefixes OIDC scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ # All OIDC scopes should pass through unchanged
+ result = provider._prefix_scopes_for_azure(
+ ["openid", "profile", "email", "offline_access"]
+ )
+
+ assert result == ["openid", "profile", "email", "offline_access"]
+
+ def test_prefix_scopes_mixed_oidc_and_custom(self):
+ """Test prefixing with a mix of OIDC and custom scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ result = provider._prefix_scopes_for_azure(
+ ["read", "openid", "write", "profile"]
+ )
+
+ # Custom scopes should be prefixed, OIDC scopes should not
+ assert "api://my-api/read" in result
+ assert "api://my-api/write" in result
+ assert "openid" in result
+ assert "profile" in result
+ # Verify OIDC scopes are NOT prefixed
+ 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):
+ """Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph)."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ # Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph
+ # or fully-qualified format like https://graph.microsoft.com/User.Read
+ result = provider._prefix_scopes_for_azure(["my.scope", "admin.read"])
+
+ assert result == ["api://my-api/my.scope", "api://my-api/admin.read"]
+
+ def test_prefix_scopes_fully_qualified_graph_not_prefixed(self):
+ """Test that fully-qualified Graph scopes are not prefixed."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ result = provider._prefix_scopes_for_azure(
+ [
+ "https://graph.microsoft.com/User.Read",
+ "https://graph.microsoft.com/Mail.Send",
+ ]
+ )
+
+ # Fully-qualified URIs pass through unchanged
+ assert result == [
+ "https://graph.microsoft.com/User.Read",
+ "https://graph.microsoft.com/Mail.Send",
+ ]
+
+ def test_required_scopes_with_oidc_filters_validation(self):
+ """Test that OIDC scopes in required_scopes are filtered from token validation."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "openid", "profile"],
+ jwt_signing_key="test-secret",
+ )
+
+ # 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):
+ """Test that if all required_scopes are OIDC, no scope validation occurs."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["openid", "profile"],
+ jwt_signing_key="test-secret",
+ )
+
+ # Token validator should have empty required scopes (all were OIDC)
+ assert provider._token_validator.required_scopes == []
+
+ def test_valid_scopes_includes_oidc_scopes(self):
+ """Test that valid_scopes advertises OIDC scopes to clients."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "openid", "profile"],
+ jwt_signing_key="test-secret",
+ )
+
+ # required_scopes (used for validation) excludes OIDC scopes
+ assert provider.required_scopes == ["read"]
+ # But valid_scopes (advertised to clients) includes all scopes
+ assert provider.client_registration_options.valid_scopes == [
+ "read",
+ "openid",
+ "profile",
+ ]
+
+ def test_prepare_scopes_for_refresh_handles_oidc_scopes(self):
+ """Test that token refresh correctly handles OIDC scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ # Simulate stored scopes that include OIDC scopes
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "openid", "profile"]
+ )
+
+ # Custom scope should be prefixed, OIDC scopes should not
+ assert "api://my-api/read" in result
+ assert "openid" in result
+ assert "profile" in result
+ assert "api://my-api/openid" not in result
+ assert "api://my-api/profile" not in result