Compare commits

...

17 commits

Author SHA1 Message Date
Jeremiah Lowin
835ba07bcd
WIP auth provider plugins checkpoint 2026-05-10 09:13:02 -04:00
Jeremiah Lowin
428427220f
Avoid eager auth provider imports 2026-05-04 12:43:00 -04:00
Jeremiah Lowin
c945f3307b
Add first-party auth plugins 2026-05-04 12:23:20 -04:00
Jeremiah Lowin
5dc7baa5a8
Add plugin auth hook and install-time contributions (#4022) 2026-05-04 12:09:33 -04:00
Jeremiah Lowin
18ddf28b79
Convert skills providers to the Skills plugin (#4017) 2026-04-22 15:43:53 -04:00
Jeremiah Lowin
a82979e433
Convert OpenAPI provider to the OpenAPI plugin (#4015) 2026-04-22 13:56:56 -04:00
Jeremiah Lowin
03f4a90e60
Convert prompts-as-tools and resources-as-tools to plugins (#4012) 2026-04-22 10:29:16 -04:00
Jeremiah Lowin
19fa2fc33e
Convert code-mode to the CodeMode plugin (#4002) 2026-04-22 09:46:29 -04:00
Jeremiah Lowin
3c0d248526
Convert search transforms to the Search plugin (#3989) 2026-04-20 19:44:41 -04:00
Jeremiah Lowin
34cb2218dc
Make PluginMeta.version optional; bundled plugins default to None (#3991) 2026-04-20 14:40:25 -04:00
Jeremiah Lowin
67f226d453
Enforce JSON-serializable contract on Plugin Config (#3986) 2026-04-20 13:00:00 -04:00
Jeremiah Lowin
cc290b3a2e
Make Plugin generic over its Config model (#3983) 2026-04-20 10:39:58 -04:00
Jeremiah Lowin
ff0ae10d88
Add Plugin.capabilities() hook and auto-derive Plugin.meta (#3982) 2026-04-19 11:41:47 -04:00
Jeremiah Lowin
e2e49f77d2
Add PluginMeta.from_package() helper (#3974) 2026-04-19 08:56:42 -04:00
Jeremiah Lowin
769e998017
Reject plugin registration after the setup pass completes (#3973) 2026-04-18 20:22:27 -04:00
Jeremiah Lowin
54e83367a3
Replace plugin setup/teardown with run(server) async context manager (#3972) 2026-04-18 19:23:14 -04:00
Jeremiah Lowin
823ea4c5fc
Add new FastMCP Plugin support (#3970) 2026-04-18 19:10:03 -04:00
224 changed files with 15143 additions and 7700 deletions

View file

@ -13,8 +13,11 @@ repos:
types_or: [yaml, json5]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.10
# Ruff version. Keep in sync with the `ruff` pin in uv.lock so
# `uv run ruff format` locally and `prek run` / CI use the same
# ruleset — otherwise minor-version drift produces line-join and
# trailing-comma diffs that only show up in CI.
rev: v0.15.8
hooks:
# Run the linter.
- id: ruff-check

View file

@ -67,6 +67,7 @@ When modifying MCP functionality, changes typically need to be applied across al
- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so.
- **NEVER** merge a PR marked as do-not-merge or draft. Check title, body, AND labels for `[DNM]`, `DNM`, `DO NOT MERGE`, `DON'T MERGE`, `DONT MERGE`, `do-not-merge`, `dont-merge`, `[DRAFT]`, or `DRAFT` (case-insensitive, any variation — some authors use `[DRAFT]` in the title even when `isDraft` is false). Authors use these as hard stops — respect them even if CI is green and review looks clean. When triaging a batch of PRs, filter these out up front AND re-check each one's labels immediately before merging, since labels can change mid-session.
- **ALWAYS** read review-bot comments before approving a PR. CodeRabbit and chatgpt-codex-connector (Codex) leave substantive review comments on most PRs in this repo — these bots have read the diff and often flag real issues that aren't in the PR description. Use `gh pr view <num> --comments` and read the bot feedback as part of review. Unlike proposed solutions from issue reporters, review-bot feedback should be evaluated on its merits, not discounted.
- **Be constructively skeptical of bot review comments on your own PRs.** CodeRabbit, Codex, and claude[bot] run a fresh review pass on every push, which means a PR with active churn can accumulate bot comments in a stream that never really ends — each fix surfaces a new edge case the next pass can flag. Most of the early feedback is real and worth acting on; diminishing returns set in fast. Evaluate each comment on its merits, the same way you would a human reviewer: is this a real bug users will hit, or a hypothetical that requires an adversarial setup? Does the fix introduce more complexity than the problem? Has the bot missed context that's obvious to a human reader (a `*,` keyword-only marker, a design decision documented elsewhere, something already resolved on a later commit)? When a comment is pedantic, a false positive, or flagging something already fixed, reply on the thread explaining the reasoning and move on — don't keep iterating just because more comments arrive. If you find yourself three rounds deep and the feedback is shifting toward "what if someone does X" hypotheticals, you're past the point where each fix is improving the PR. Stop, document the contract as-is, and ship.
### Releases

View file

@ -478,7 +478,7 @@ When mounting an OAuth-protected server under a path prefix, declare your URLs u
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from starlette.applications import Starlette
from starlette.routing import Mount
@ -540,7 +540,7 @@ Here's a complete working example showing all the pieces together:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from starlette.applications import Starlette
from starlette.routing import Mount
import uvicorn

View file

@ -97,7 +97,7 @@ async def my_tool(
For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken`, which handles the On-Behalf-Of token exchange declaratively:
```python
from fastmcp.server.auth.providers.azure import EntraOBOToken
from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken
@mcp.tool()
async def get_emails(

View file

@ -172,6 +172,7 @@
"icon": "key",
"pages": [
"servers/auth/authentication",
"servers/auth/plugins",
"servers/auth/token-verification",
"servers/auth/remote-oauth",
"servers/auth/oauth-proxy",

View file

@ -252,7 +252,7 @@ auth = GitHubProvider()
# After (v3) — pass values explicitly
import os
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
auth = GitHubProvider(
client_id=os.environ["GITHUB_CLIENT_ID"],

View file

@ -47,7 +47,7 @@ Create an Application in your Auth0 settings to get the credentials needed for a
</Warning>
<Tip>
If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0Provider.
If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0 plugin.
</Tip>
</Step>
@ -76,23 +76,25 @@ Create an Application in your Auth0 settings to get the credentials needed for a
### Step 2: FastMCP Configuration
Create your FastMCP server using the `Auth0Provider`.
Create your FastMCP server using the `Auth0Auth` plugin.
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.auth0 import Auth0Provider
from fastmcp.server.plugins.auth.auth0 import Auth0Auth
# The Auth0Provider utilizes Auth0 OIDC configuration
auth_provider = Auth0Provider(
config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID
client_secret="vPYqbjemq...", # Your Auth0 application Client Secret
audience="https://...", # Your Auth0 API audience
base_url="http://localhost:8000", # Must match your application configuration
# redirect_path="/auth/callback" # Default value, customize if needed
# The Auth0 plugin utilizes Auth0 OIDC configuration
auth_plugin = Auth0Auth(
Auth0Auth.Config(
config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID
client_secret="vPYqbjemq...", # Your Auth0 application Client Secret
audience="https://...", # Your Auth0 API audience
base_url="http://localhost:8000", # Must match your application configuration
# redirect_path="/auth/callback" # Default value, customize if needed
)
)
mcp = FastMCP(name="Auth0 Secured App", auth=auth_provider)
mcp = FastMCP(name="Auth0 Secured App", plugins=[auth_plugin])
# Add a protected tool to test authentication
@mcp.tool
@ -157,21 +159,21 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.auth0 import Auth0Provider
from fastmcp.server.plugins.auth.auth0 import Auth0Auth
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
# Production setup with encrypted persistent token storage
auth_provider = Auth0Provider(
config_url="https://.../.well-known/openid-configuration",
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB",
client_secret="vPYqbjemq...",
audience="https://...",
base_url="https://your-production-domain.com",
# Production token management
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
auth_plugin = Auth0Auth(
Auth0Auth.Config(
config_url="https://.../.well-known/openid-configuration",
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB",
client_secret="vPYqbjemq...",
audience="https://...",
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"],
@ -181,7 +183,7 @@ auth_provider = Auth0Provider(
)
)
mcp = FastMCP(name="Production Auth0 App", auth=auth_provider)
mcp = FastMCP(name="Production Auth0 App", plugins=[auth_plugin])
```
<Note>

View file

@ -44,20 +44,22 @@ In the WorkOS Dashboard, go to **Connect → Configuration** and configure:
### Step 2: FastMCP Configuration
Create your FastMCP server file and use the `AuthKitProvider` to handle all the OAuth integration automatically:
Create your FastMCP server file and use the `AuthKitAuth` plugin to handle the OAuth integration automatically:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
from fastmcp.server.plugins.auth.authkit import AuthKitAuth
# AuthKitProvider automatically discovers WorkOS endpoints, configures JWT
# AuthKitAuth automatically discovers WorkOS endpoints, configures JWT
# validation, and binds the token audience to this server's resource URL.
auth_provider = AuthKitProvider(
authkit_domain="https://your-project-12345.authkit.app",
base_url="http://127.0.0.1:8000", # Use your actual server URL
auth_plugin = AuthKitAuth(
AuthKitAuth.Config(
authkit_domain="https://your-project-12345.authkit.app",
base_url="http://127.0.0.1:8000", # Use your actual server URL
)
)
mcp = FastMCP(name="AuthKit Secured App", auth=auth_provider)
mcp = FastMCP(name="AuthKit Secured App", plugins=[auth_plugin])
```
When the server starts, it logs the resource URL it is validating against. Paste that URL into your Dashboard's **MCP resource indicators** list.
@ -94,13 +96,15 @@ For production deployments, load sensitive configuration from environment variab
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
from fastmcp.server.plugins.auth.authkit import AuthKitAuth
# Load configuration from environment variables
auth = AuthKitProvider(
authkit_domain=os.environ.get("AUTHKIT_DOMAIN"),
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
auth_plugin = AuthKitAuth(
AuthKitAuth.Config(
authkit_domain=os.environ.get("AUTHKIT_DOMAIN"),
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
)
)
mcp = FastMCP(name="AuthKit Secured App", auth=auth)
mcp = FastMCP(name="AuthKit Secured App", plugins=[auth_plugin])
```

View file

@ -116,24 +116,26 @@ Set up AWS Cognito user pool with an app client to get the credentials needed fo
### Step 2: FastMCP Configuration
Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cognito's JWT tokens and user claims automatically:
Create your FastMCP server using the `AWSCognitoAuth` plugin, which handles AWS Cognito's JWT tokens and user claims automatically:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
from fastmcp.server.plugins.auth.aws import AWSCognitoAuth
from fastmcp.server.dependencies import get_access_token
# The AWSCognitoProvider handles JWT validation and user claims
auth_provider = AWSCognitoProvider(
user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID
aws_region="eu-central-1", # AWS region (defaults to eu-central-1)
client_id="your-app-client-id", # Your app client ID
client_secret="your-app-client-secret", # Your app client Secret
base_url="http://localhost:8000", # Must match your callback URL
# redirect_path="/auth/callback" # Default value, customize if needed
# The AWSCognitoAuth plugin handles JWT validation and user claims
auth_plugin = AWSCognitoAuth(
AWSCognitoAuth.Config(
user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID
aws_region="eu-central-1", # AWS region (defaults to eu-central-1)
client_id="your-app-client-id", # Your app client ID
client_secret="your-app-client-secret", # Your app client Secret
base_url="http://localhost:8000", # Must match your callback URL
# redirect_path="/auth/callback" # Default value, customize if needed
)
)
mcp = FastMCP(name="AWS Cognito Secured App", auth=auth_provider)
mcp = FastMCP(name="AWS Cognito Secured App", plugins=[auth_plugin])
# Add a protected tool to test authentication
@mcp.tool
@ -204,21 +206,21 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
from fastmcp.server.plugins.auth.aws import AWSCognitoAuth
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
# Production setup with encrypted persistent token storage
auth_provider = AWSCognitoProvider(
user_pool_id="eu-central-1_XXXXXXXXX",
aws_region="eu-central-1",
client_id="your-app-client-id",
client_secret="your-app-client-secret",
base_url="https://your-production-domain.com",
# Production token management
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
auth_plugin = AWSCognitoAuth(
AWSCognitoAuth.Config(
user_pool_id="eu-central-1_XXXXXXXXX",
aws_region="eu-central-1",
client_id="your-app-client-id",
client_secret="your-app-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"],
@ -228,7 +230,7 @@ auth_provider = AWSCognitoProvider(
)
)
mcp = FastMCP(name="Production AWS Cognito App", auth=auth_provider)
mcp = FastMCP(name="Production AWS Cognito App", plugins=[auth_plugin])
```
<Note>
@ -275,4 +277,4 @@ Perfect for enterprise environments with:
- **Multi-Factor Authentication (MFA)**: Leverage AWS Cognito's built-in MFA
- **User Groups**: Role-based access control through AWS Cognito groups
- **Custom Attributes**: Access custom user attributes defined in your AWS Cognito user pool
- **Compliance**: Meet enterprise security and compliance requirements
- **Compliance**: Meet enterprise security and compliance requirements

View file

@ -46,7 +46,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut
</Warning>
<Tip>
If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider.
If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the Azure plugin.
</Tip>
- **Expose an API**: Configure your Application ID URI and define scopes
@ -74,7 +74,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut
</Warning>
<Note>
In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`.
In FastMCP's Azure plugin, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`.
</Note>
@ -109,28 +109,30 @@ Create an App registration in Azure Portal to get the credentials needed for aut
### Step 2: FastMCP Configuration
Create your FastMCP server using the `AzureProvider`, which handles Azure's OAuth flow automatically:
Create your FastMCP server using the `AzureAuth` plugin, which handles Azure's OAuth flow automatically:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.plugins.auth.azure import AzureAuth
# The AzureProvider handles Azure's token format and validation
auth_provider = AzureProvider(
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID
client_secret="your-client-secret", # Your Azure App Client Secret
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED)
base_url="http://localhost:8000", # Must match your App registration
required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App
# identifier_uri defaults to api://{client_id}
# identifier_uri="api://your-api-id",
# Optional: request additional upstream scopes in the authorize request
# additional_authorize_scopes=["User.Read", "openid", "email"],
# redirect_path="/auth/callback" # Default value, customize if needed
# base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com)
# The AzureAuth plugin handles Azure's token format and validation
auth_plugin = AzureAuth(
AzureAuth.Config(
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID
client_secret="your-client-secret", # Your Azure App Client Secret
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED)
base_url="http://localhost:8000", # Must match your App registration
required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App
# identifier_uri defaults to api://{client_id}
# identifier_uri="api://your-api-id",
# Optional: request additional upstream scopes in the authorize request
# additional_authorize_scopes=["User.Read", "openid", "email"],
# redirect_path="/auth/callback" # Default value, customize if needed
# base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com)
)
)
mcp = FastMCP(name="Azure Secured App", auth=auth_provider)
mcp = FastMCP(name="Azure Secured App", plugins=[auth_plugin])
# Add a protected tool to test authentication
@mcp.tool
@ -139,7 +141,7 @@ async def get_user_info() -> dict:
from fastmcp.server.dependencies import get_access_token
token = get_access_token()
# The AzureProvider stores user data in token claims
# The Azure plugin stores user data in token claims
return {
"azure_id": token.claims.get("sub"),
"email": token.claims.get("email"),
@ -250,21 +252,21 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.plugins.auth.azure import AzureAuth
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
# Production setup with encrypted persistent token storage
auth_provider = AzureProvider(
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149",
client_secret="your-client-secret",
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5",
base_url="https://your-production-domain.com",
required_scopes=["your-scope"],
# Production token management
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
auth_plugin = AzureAuth(
AzureAuth.Config(
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149",
client_secret="your-client-secret",
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5",
base_url="https://your-production-domain.com",
required_scopes=["your-scope"],
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
),
client_storage=FernetEncryptionWrapper(
key_value=RedisStore(
host=os.environ["REDIS_HOST"],
@ -274,7 +276,7 @@ auth_provider = AzureProvider(
)
)
mcp = FastMCP(name="Production Azure App", auth=auth_provider)
mcp = FastMCP(name="Production Azure App", plugins=[auth_plugin])
```
<Note>
@ -287,7 +289,7 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s
<VersionBadge version="2.15.0" />
For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full `AzureProvider`.
For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full Azure auth plugin.
This pattern is ideal when:
- Your infrastructure handles authentication (e.g., Managed Identity)
@ -297,7 +299,7 @@ This pattern is ideal when:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.azure import AzureJWTVerifier
from fastmcp.server.plugins.auth.azure.provider import AzureJWTVerifier
from pydantic import AnyHttpUrl
tenant_id = "your-tenant-id"
@ -371,29 +373,31 @@ OBO requires additional configuration in your Azure App registration beyond basi
</Step>
</Steps>
### Configure AzureProvider for OBO
### Configure AzureAuth for OBO
The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later.
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.plugins.auth.azure import AzureAuth
auth_provider = AzureProvider(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
base_url="http://localhost:8000",
required_scopes=["mcp-access"], # Your API scope
# Include Graph scopes for OBO
additional_authorize_scopes=[
"https://graph.microsoft.com/Mail.Read",
"https://graph.microsoft.com/User.Read",
"offline_access", # Enables refresh tokens
],
auth_plugin = AzureAuth(
AzureAuth.Config(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
base_url="http://localhost:8000",
required_scopes=["mcp-access"], # Your API scope
# Include Graph scopes for OBO
additional_authorize_scopes=[
"https://graph.microsoft.com/Mail.Read",
"https://graph.microsoft.com/User.Read",
"offline_access", # Enables refresh tokens
],
)
)
mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider)
mcp = FastMCP(name="Graph-Enabled Server", plugins=[auth_plugin])
```
Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access.
@ -408,22 +412,25 @@ The `EntraOBOToken` dependency handles the complete OBO flow automatically. Decl
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken
from fastmcp.server.plugins.auth.azure import AzureAuth
from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken
import httpx
auth_provider = AzureProvider(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
base_url="http://localhost:8000",
required_scopes=["mcp-access"],
additional_authorize_scopes=[
"https://graph.microsoft.com/Mail.Read",
"https://graph.microsoft.com/User.Read",
],
auth_plugin = AzureAuth(
AzureAuth.Config(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
base_url="http://localhost:8000",
required_scopes=["mcp-access"],
additional_authorize_scopes=[
"https://graph.microsoft.com/Mail.Read",
"https://graph.microsoft.com/User.Read",
],
)
)
mcp = FastMCP(name="Email Reader", auth=auth_provider)
mcp = FastMCP(name="Email Reader", plugins=[auth_plugin])
@mcp.tool
async def get_recent_emails(

View file

@ -54,21 +54,23 @@ SERVER_URL=http://localhost:3000 # Your server's base URL
### Step 3: FastMCP Configuration
Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically:
Create your FastMCP server file and use the `DescopeAuth` plugin to handle the OAuth integration automatically:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.descope import DescopeProvider
from fastmcp.server.plugins.auth.descope import DescopeAuth
# The DescopeProvider automatically discovers Descope endpoints
# The DescopeAuth plugin automatically discovers Descope endpoints
# and configures JWT token validation
auth_provider = DescopeProvider(
config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL
base_url=SERVER_URL, # Your server's public URL
auth_plugin = DescopeAuth(
DescopeAuth.Config(
config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL
base_url=SERVER_URL, # Your server's public URL
)
)
# Create FastMCP server with auth
mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider)
# Create FastMCP server with auth plugin
mcp = FastMCP(name="My Descope Protected Server", plugins=[auth_plugin])
```
@ -101,13 +103,15 @@ For production deployments, load configuration from environment variables:
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.descope import DescopeProvider
from fastmcp.server.plugins.auth.descope import DescopeAuth
# Load configuration from environment variables
auth = DescopeProvider(
config_url=os.environ.get("DESCOPE_CONFIG_URL"),
base_url=os.environ.get("BASE_URL", "https://your-server.com")
auth_plugin = DescopeAuth(
DescopeAuth.Config(
config_url=os.environ.get("DESCOPE_CONFIG_URL"),
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
)
)
mcp = FastMCP(name="My Descope Protected Server", auth=auth)
mcp = FastMCP(name="My Descope Protected Server", plugins=[auth_plugin])
```

View file

@ -56,19 +56,21 @@ Create an application in the Discord Developer Portal to get the credentials nee
### Step 2: FastMCP Configuration
Create your FastMCP server using the `DiscordProvider`, which handles Discord's OAuth flow automatically:
Create your FastMCP server using the `DiscordAuth` plugin, which handles Discord's OAuth flow automatically:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.discord import DiscordProvider
from fastmcp.server.plugins.auth.discord import DiscordAuth
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
auth_plugin = DiscordAuth(
DiscordAuth.Config(
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 = FastMCP(name="Discord Secured App", plugins=[auth_plugin])
@mcp.tool
async def get_user_info() -> dict:
@ -138,11 +140,13 @@ Discord OAuth supports several scopes for accessing different types of user data
To request additional scopes:
```python
auth_provider = DiscordProvider(
client_id="...",
client_secret="...",
base_url="http://localhost:8000",
required_scopes=["identify", "email"],
auth_plugin = DiscordAuth(
DiscordAuth.Config(
client_id="...",
client_secret="...",
base_url="http://localhost:8000",
required_scopes=["identify", "email"],
)
)
```
@ -153,17 +157,18 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.discord import DiscordProvider
from fastmcp.server.plugins.auth.discord import DiscordAuth
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"],
auth_plugin = DiscordAuth(
DiscordAuth.Config(
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"],
@ -173,7 +178,7 @@ auth_provider = DiscordProvider(
)
)
mcp = FastMCP(name="Production Discord App", auth=auth_provider)
mcp = FastMCP(name="Production Discord App", plugins=[auth_plugin])
```
<Note>

View file

@ -42,7 +42,7 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au
</Warning>
<Tip>
If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubProvider.
If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHub plugin.
</Tip>
</Step>
@ -60,21 +60,23 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au
### Step 2: FastMCP Configuration
Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OAuth quirks automatically:
Create your FastMCP server using the `GitHubAuth` plugin, which handles GitHub's OAuth quirks automatically:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github import GitHubAuth
# The GitHubProvider handles GitHub's token format and validation
auth_provider = GitHubProvider(
client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID
client_secret="github_pat_...", # Your GitHub OAuth App Client Secret
base_url="http://localhost:8000", # Must match your OAuth App configuration
# redirect_path="/auth/callback" # Default value, customize if needed
# The GitHubAuth plugin handles GitHub's token format and validation
auth_plugin = GitHubAuth(
GitHubAuth.Config(
client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID
client_secret="github_pat_...", # Your GitHub OAuth App Client Secret
base_url="http://localhost:8000", # Must match your OAuth App configuration
# redirect_path="/auth/callback" # Default value, customize if needed
)
)
mcp = FastMCP(name="GitHub Secured App", auth=auth_provider)
mcp = FastMCP(name="GitHub Secured App", plugins=[auth_plugin])
# Add a protected tool to test authentication
@mcp.tool
@ -83,7 +85,7 @@ async def get_user_info() -> dict:
from fastmcp.server.dependencies import get_access_token
token = get_access_token()
# The GitHubProvider stores user data in token claims
# The GitHub auth plugin stores user data in token claims
return {
"github_user": token.claims.get("login"),
"name": token.claims.get("name"),
@ -143,19 +145,19 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github import GitHubAuth
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
# Production setup with encrypted persistent token storage
auth_provider = GitHubProvider(
client_id="Ov23liAbcDefGhiJkLmN",
client_secret="github_pat_...",
base_url="https://your-production-domain.com",
# Production token management
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
auth_plugin = GitHubAuth(
GitHubAuth.Config(
client_id="Ov23liAbcDefGhiJkLmN",
client_secret="github_pat_...",
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"],
@ -165,7 +167,7 @@ auth_provider = GitHubProvider(
)
)
mcp = FastMCP(name="Production GitHub App", auth=auth_provider)
mcp = FastMCP(name="Production GitHub App", plugins=[auth_plugin])
```
<Note>

View file

@ -45,7 +45,7 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential
</Warning>
<Tip>
If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleProvider.
If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the Google plugin.
</Tip>
</Step>
@ -65,25 +65,27 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential
### Step 2: FastMCP Configuration
Create your FastMCP server using the `GoogleProvider`, which handles Google's OAuth flow automatically:
Create your FastMCP server using the `GoogleAuth` plugin, which handles Google's OAuth flow automatically:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.google import GoogleProvider
from fastmcp.server.plugins.auth.google import GoogleAuth
# The GoogleProvider handles Google's token format and validation
auth_provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID
client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret
base_url="http://localhost:8000", # Must match your OAuth configuration
required_scopes=[ # Request user information
"openid",
"https://www.googleapis.com/auth/userinfo.email",
],
# redirect_path="/auth/callback" # Default value, customize if needed
# The GoogleAuth plugin handles Google's token format and validation
auth_plugin = GoogleAuth(
GoogleAuth.Config(
client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID
client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret
base_url="http://localhost:8000", # Must match your OAuth configuration
required_scopes=[ # Request user information
"openid",
"https://www.googleapis.com/auth/userinfo.email",
],
# redirect_path="/auth/callback" # Default value, customize if needed
)
)
mcp = FastMCP(name="Google Secured App", auth=auth_provider)
mcp = FastMCP(name="Google Secured App", plugins=[auth_plugin])
# Add a protected tool to test authentication
@mcp.tool
@ -92,7 +94,7 @@ async def get_user_info() -> dict:
from fastmcp.server.dependencies import get_access_token
token = get_access_token()
# The GoogleProvider stores user data in token claims
# The Google auth plugin stores user data in token claims
return {
"google_id": token.claims.get("sub"),
"email": token.claims.get("email"),
@ -156,20 +158,20 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.google import GoogleProvider
from fastmcp.server.plugins.auth.google import GoogleAuth
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
# Production setup with encrypted persistent token storage
auth_provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-abc123...",
base_url="https://your-production-domain.com",
required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"],
# Production token management
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
auth_plugin = GoogleAuth(
GoogleAuth.Config(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-abc123...",
base_url="https://your-production-domain.com",
required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"],
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
),
client_storage=FernetEncryptionWrapper(
key_value=RedisStore(
host=os.environ["REDIS_HOST"],
@ -179,11 +181,11 @@ auth_provider = GoogleProvider(
)
)
mcp = FastMCP(name="Production Google App", auth=auth_provider)
mcp = FastMCP(name="Production Google App", plugins=[auth_plugin])
```
<Note>
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).
</Note>
</Note>

View file

@ -27,22 +27,24 @@ Before you begin, you will need:
### FastMCP Configuration
Create your FastMCP server and use `KeycloakAuthProvider` to handle OAuth:
Create your FastMCP server and use the `KeycloakAuth` plugin to handle OAuth:
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
from fastmcp.server.plugins.auth.keycloak import KeycloakAuth
from fastmcp.server.dependencies import get_access_token
auth = KeycloakAuthProvider(
realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/myrealm",
base_url="http://localhost:8000",
# audience="http://localhost:8000", # Recommended for production
auth_plugin = KeycloakAuth(
KeycloakAuth.Config(
realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/myrealm",
base_url="http://localhost:8000",
# audience="http://localhost:8000", # Recommended for production
)
)
mcp = FastMCP("Keycloak Example Server", auth=auth)
mcp = FastMCP("Keycloak Example Server", plugins=[auth_plugin])
@mcp.tool
@ -124,7 +126,7 @@ async def admin_only_tool() -> str:
```python
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
from fastmcp.server.plugins.auth.keycloak import KeycloakAuth
custom_verifier = JWTVerifier(
jwks_uri="http://localhost:8080/realms/myrealm/protocol/openid-connect/certs",
@ -133,9 +135,11 @@ custom_verifier = JWTVerifier(
required_scopes=["api:read", "api:write"],
)
auth = KeycloakAuthProvider(
realm_url="http://localhost:8080/realms/myrealm",
base_url="http://localhost:8000",
auth_plugin = KeycloakAuth(
KeycloakAuth.Config(
realm_url="http://localhost:8080/realms/myrealm",
base_url="http://localhost:8000",
),
token_verifier=custom_verifier,
)
```

View file

@ -87,7 +87,7 @@ Follow the Steps as mentioned below to create an OAuth client.
Click on "Submit" button to update OAuth configuration for the client application.
**Note: You don't need to do any special configuration to support PKCE for the OAuth client.**
Make sure to Activate the client application.
Note down client ID and client secret for the application. You'll use these values when configuring the OCIProvider in your code.
Note down client ID and client secret for the application. You'll use these values when configuring the OCI plugin in your code.
</Step>
</Steps>
@ -209,7 +209,7 @@ For production deployments with persistent token management across server restar
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.oci import OCIProvider
from fastmcp.server.plugins.auth.oci import OCIAuth
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
@ -217,14 +217,14 @@ from cryptography.fernet import Fernet
# Load configuration from environment
# Production setup with encrypted persistent token storage
auth_provider = OCIProvider(
config_url=os.environ.get("OCI_CONFIG_URL"),
client_id=os.environ.get("OCI_CLIENT_ID"),
client_secret=os.environ.get("OCI_CLIENT_SECRET"),
base_url=os.environ.get("BASE_URL", "https://your-production-domain.com"),
# Production token management
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
auth_plugin = OCIAuth(
OCIAuth.Config(
config_url=os.environ.get("OCI_CONFIG_URL"),
client_id=os.environ.get("OCI_CLIENT_ID"),
client_secret=os.environ.get("OCI_CLIENT_SECRET"),
base_url=os.environ.get("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"],
@ -234,7 +234,7 @@ auth_provider = OCIProvider(
)
)
mcp = FastMCP(name="Production OCI App", auth=auth_provider)
mcp = FastMCP(name="Production OCI App", plugins=[auth_plugin])
```
<Note>
@ -245,4 +245,4 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s
<Info>
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.
</Info>
</Info>

View file

@ -67,22 +67,24 @@ SERVER_URL=http://localhost:8000 # Your server's base U
### Step 3: FastMCP Configuration
Create your FastMCP server file and use the PropelAuthProvider to handle all the OAuth integration automatically:
Create your FastMCP server file and use the `PropelAuth` plugin to handle the OAuth integration automatically:
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
from fastmcp.server.plugins.auth.propelauth import PropelAuth
auth_provider = PropelAuthProvider(
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
base_url=os.environ["SERVER_URL"],
required_scopes=["read:user_data"], # Optional scope enforcement
auth_plugin = PropelAuth(
PropelAuth.Config(
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
base_url=os.environ["SERVER_URL"],
required_scopes=["read:user_data"], # Optional scope enforcement
)
)
mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth_provider)
mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin])
```
## Testing
@ -114,18 +116,20 @@ You can use `get_access_token()` inside your tools to identify the authenticated
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
from fastmcp.server.plugins.auth.propelauth import PropelAuth
from fastmcp.server.dependencies import get_access_token
auth = PropelAuthProvider(
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
base_url=os.environ["SERVER_URL"],
required_scopes=["read:user_data"],
auth_plugin = PropelAuth(
PropelAuth.Config(
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
base_url=os.environ["SERVER_URL"],
required_scopes=["read:user_data"],
)
)
mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth)
mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin])
@mcp.tool
def whoami() -> dict:
@ -139,26 +143,26 @@ def whoami() -> dict:
## Advanced Configuration
The `PropelAuthProvider` supports optional overrides for token introspection behavior, including caching and request timeouts:
The `PropelAuth` plugin supports optional overrides for token introspection behavior, including caching and request timeouts:
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
from fastmcp.server.plugins.auth.propelauth import PropelAuth
auth = PropelAuthProvider(
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
required_scopes=["read:user_data"],
resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707)
token_introspection_overrides={
"cache_ttl_seconds": 300, # Cache introspection results for 5 minutes
"max_cache_size": 1000, # Maximum cached tokens
"timeout_seconds": 15, # HTTP request timeout
},
auth_plugin = PropelAuth(
PropelAuth.Config(
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
required_scopes=["read:user_data"],
resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707)
introspection_cache_ttl_seconds=300, # Cache introspection results for 5 minutes
introspection_max_cache_size=1000, # Maximum cached tokens
introspection_timeout_seconds=15, # HTTP request timeout
)
)
mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth)
mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin])
```

View file

@ -43,24 +43,26 @@ BASE_URL=http://localhost:8000/
### Step 2: Add auth to FastMCP server
Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically:
Create your FastMCP server file and use the `ScalekitAuth` plugin to handle the OAuth integration automatically:
> **Warning:** The legacy `mcp_url` and `client_id` parameters are deprecated and will be removed in a future release. Use `base_url` instead of `mcp_url` and remove `client_id` from your configuration.
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
from fastmcp.server.plugins.auth.scalekit import ScalekitAuth
# Discovers Scalekit endpoints and set up JWT token validation
auth_provider = ScalekitProvider(
environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL
resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID
base_url=SERVER_URL, # Public MCP endpoint
required_scopes=["read"], # Optional scope enforcement
auth_plugin = ScalekitAuth(
ScalekitAuth.Config(
environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL
resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID
base_url=SERVER_URL, # Public MCP endpoint
required_scopes=["read"], # Optional scope enforcement
)
)
# Create FastMCP server with auth
mcp = FastMCP(name="My Scalekit Protected Server", auth=auth_provider)
# Create FastMCP server with auth plugin
mcp = FastMCP(name="My Scalekit Protected Server", plugins=[auth_plugin])
@mcp.tool
def auth_status() -> dict:
@ -95,16 +97,18 @@ For production deployments, load configuration from environment variables:
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
from fastmcp.server.plugins.auth.scalekit import ScalekitAuth
# Load configuration from environment variables
auth = ScalekitProvider(
environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"),
resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"),
base_url=os.environ.get("BASE_URL", "https://your-server.com")
auth_plugin = ScalekitAuth(
ScalekitAuth.Config(
environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"),
resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"),
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
)
)
mcp = FastMCP(name="My Scalekit Protected Server", auth=auth)
mcp = FastMCP(name="My Scalekit Protected Server", plugins=[auth_plugin])
@mcp.tool
def protected_action() -> str:

View file

@ -19,7 +19,7 @@ Supabase Auth does not currently support [RFC 8707](https://www.rfc-editor.org/r
Supabase's OAuth Server delegates the user consent screen to your application. When an MCP client initiates authorization, Supabase authenticates the user and then redirects to your application at a configured callback URL (e.g., `https://your-app.com/oauth/callback?authorization_id=...`). Your application must host a page that calls Supabase's `approveAuthorization()` or `denyAuthorization()` APIs to complete the flow.
`SupabaseProvider` handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page.
The Supabase auth plugin handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page.
## Configuration
@ -49,18 +49,20 @@ In your Supabase Dashboard:
### Step 3: FastMCP Configuration
Create your FastMCP server using the `SupabaseProvider`:
Create your FastMCP server using the `SupabaseAuth` plugin:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.supabase import SupabaseProvider
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
auth = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="http://localhost:8000",
auth_plugin = SupabaseAuth(
SupabaseAuth.Config(
project_url="https://abc123.supabase.co",
base_url="http://localhost:8000",
)
)
mcp = FastMCP("Supabase Protected Server", auth=auth)
mcp = FastMCP("Supabase Protected Server", plugins=[auth_plugin])
@mcp.tool
def protected_tool(message: str) -> str:
@ -112,12 +114,14 @@ For production deployments, load configuration from environment variables:
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.supabase import SupabaseProvider
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
auth = SupabaseProvider(
project_url=os.environ["SUPABASE_PROJECT_URL"],
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
auth_plugin = SupabaseAuth(
SupabaseAuth.Config(
project_url=os.environ["SUPABASE_PROJECT_URL"],
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
)
)
mcp = FastMCP(name="Supabase Secured App", auth=auth)
mcp = FastMCP(name="Supabase Secured App", plugins=[auth_plugin])
```

View file

@ -56,22 +56,24 @@ The callback URL must match exactly. The default path is `/auth/callback`, but y
### Step 2: FastMCP Configuration
Create your FastMCP server using the `WorkOSProvider`:
Create your FastMCP server using the `WorkOSAuth` plugin:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import WorkOSProvider
from fastmcp.server.plugins.auth.workos import WorkOSAuth
# Configure WorkOS OAuth
auth = WorkOSProvider(
client_id="client_YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
authkit_domain="https://your-app.authkit.app",
base_url="http://localhost:8000",
required_scopes=["openid", "profile", "email"]
auth_plugin = WorkOSAuth(
WorkOSAuth.Config(
client_id="client_YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
authkit_domain="https://your-app.authkit.app",
base_url="http://localhost:8000",
required_scopes=["openid", "profile", "email"],
)
)
mcp = FastMCP("WorkOS Protected Server", auth=auth)
mcp = FastMCP("WorkOS Protected Server", plugins=[auth_plugin])
@mcp.tool
def protected_tool(message: str) -> str:
@ -134,21 +136,21 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import WorkOSProvider
from fastmcp.server.plugins.auth.workos import WorkOSAuth
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
# Production setup with encrypted persistent token storage
auth = WorkOSProvider(
client_id="client_YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
authkit_domain="https://your-app.authkit.app",
base_url="https://your-production-domain.com",
required_scopes=["openid", "profile", "email"],
# Production token management
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
auth_plugin = WorkOSAuth(
WorkOSAuth.Config(
client_id="client_YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
authkit_domain="https://your-app.authkit.app",
base_url="https://your-production-domain.com",
required_scopes=["openid", "profile", "email"],
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
),
client_storage=FernetEncryptionWrapper(
key_value=RedisStore(
host=os.environ["REDIS_HOST"],
@ -158,7 +160,7 @@ auth = WorkOSProvider(
)
)
mcp = FastMCP(name="Production WorkOS App", auth=auth)
mcp = FastMCP(name="Production WorkOS App", plugins=[auth_plugin])
```
<Note>
@ -197,4 +199,4 @@ OAuth callback path
<ParamField path="timeout_seconds" default="10">
API request timeout
</ParamField>
</Card>
</Card>

View file

@ -106,7 +106,7 @@ For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully sup
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
auth = AuthKitProvider(
authkit_domain="https://your-project.authkit.app",
@ -136,7 +136,7 @@ For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with Git
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
auth = GitHubProvider(
client_id="Ov23li...", # Your GitHub OAuth App ID
@ -221,7 +221,7 @@ For production deployments, load sensitive values like client secrets from envir
```python
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
# Load secrets from environment variables
auth = GitHubProvider(

View file

@ -355,7 +355,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
FastMCP includes pre-configured providers for common services:
```python
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
auth = GitHubProvider(
client_id="your-github-app-id",
@ -690,7 +690,7 @@ For production deployments, load sensitive credentials from environment variable
```python
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
# Load secrets from environment variables
auth = GitHubProvider(

View file

@ -218,7 +218,7 @@ auth = OIDCProxy(
FastMCP includes pre-configured OIDC providers for common services:
```python
from fastmcp.server.auth.providers.auth0 import Auth0Provider
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
auth = Auth0Provider(
config_url="https://.../.well-known/openid-configuration",
@ -262,7 +262,7 @@ For production deployments, load sensitive credentials from environment variable
```python
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.auth0 import Auth0Provider
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
# Load secrets from environment variables
auth = Auth0Provider(

View file

@ -0,0 +1,70 @@
---
title: Auth Plugins
description: Configure FastMCP authentication with first-party plugins.
icon: puzzle-piece
---
Auth plugins are the plugin-system entry point for FastMCP's built-in auth integrations. They wrap the existing auth providers and contribute exactly one provider through `Plugin.auth()`, so the server behavior is the same as passing `auth=...` directly.
Use an auth plugin when you want authentication to be configured alongside other plugins, especially in declarative environments such as Horizon or `plugins.json`-style loaders.
```python server.py
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.github import GitHubAuth
mcp = FastMCP(
"GitHub Protected Server",
plugins=[
GitHubAuth(
GitHubAuth.Config(
client_id="your-github-client-id",
client_secret="your-github-client-secret",
base_url="https://your-server.com",
)
)
],
)
```
Provider APIs remain available in each plugin's explicit `.provider` module for advanced direct auth wiring, but integrations should prefer the plugin form.
## Included Plugins
Each first-party auth plugin lives in its own module under `fastmcp.server.plugins.auth`, mirroring the provider package:
```python
from fastmcp.server.plugins.auth.auth0 import Auth0Auth
from fastmcp.server.plugins.auth.authkit import AuthKitAuth
from fastmcp.server.plugins.auth.aws import AWSCognitoAuth
from fastmcp.server.plugins.auth.azure import AzureAuth
from fastmcp.server.plugins.auth.clerk import ClerkAuth
from fastmcp.server.plugins.auth.descope import DescopeAuth
from fastmcp.server.plugins.auth.discord import DiscordAuth
from fastmcp.server.plugins.auth.github import GitHubAuth
from fastmcp.server.plugins.auth.google import GoogleAuth
from fastmcp.server.plugins.auth.keycloak import KeycloakAuth
from fastmcp.server.plugins.auth.oci import OCIAuth
from fastmcp.server.plugins.auth.propelauth import PropelAuth
from fastmcp.server.plugins.auth.scalekit import ScalekitAuth
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
from fastmcp.server.plugins.auth.workos import WorkOSAuth
```
Each plugin exposes its serializable configuration model as `Plugin.Config`. Config fields mirror the wrapped provider's constructor wherever the value can be represented as JSON. Python-only objects such as custom token verifiers, HTTP clients, and client storage are passed as constructor keyword arguments:
```python
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
auth_plugin = SupabaseAuth(
SupabaseAuth.Config(
project_url="https://abc123.supabase.co",
base_url="https://your-server.com",
required_scopes=["read"],
),
token_verifier=custom_verifier,
)
mcp = FastMCP("Supabase Protected Server", plugins=[auth_plugin])
```
Only one auth provider can be configured for a server. If a server already has `auth=...`, or if multiple plugins contribute auth, FastMCP raises during plugin installation.

View file

@ -197,7 +197,7 @@ WorkOS AuthKit provides an excellent example of remote OAuth integration. The `A
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
auth = AuthKitProvider(
authkit_domain="https://your-project.authkit.app",

View file

@ -111,7 +111,7 @@ For OAuth token storage:
```python
import os
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from key_value.aio.stores.redis import RedisStore
auth = GitHubProvider(
@ -163,7 +163,7 @@ By default, FastMCP automatically manages keys and storage based on your platfor
No configuration needed:
```python
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
auth = GitHubProvider(
client_id="your-id",
@ -178,7 +178,7 @@ For production deployments, configure explicit keys and persistent network-acces
```python
import os
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet

View file

@ -10,9 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.1.0" />
<Warning>
CodeMode is experimental. The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice.
</Warning>
<Note>
The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice.
</Note>
Standard MCP tool usage has two scaling problems. First, every tool in the catalog is loaded into the LLM's context upfront — with hundreds of tools, that's tens of thousands of tokens spent before the LLM even reads the user's request. Second, every tool call is a round-trip: the LLM calls a tool, the result passes back through the context window, the LLM reasons about it, calls another tool, and so on. Intermediate results that only exist to feed the next step still burn tokens flowing through the model.
@ -26,13 +26,13 @@ The approach was introduced by Cloudflare in [Code Mode](https://blog.cloudflare
CodeMode requires the `code-mode` extra for sandbox support. Install it with `pip install "fastmcp[code-mode]"`.
</Tip>
You take a normal server with normally registered tools and add a `CodeMode` transform. The transform wraps your existing tools in the code mode machinery — your tool functions don't change at all:
You take a normal server with normally registered tools and attach the `CodeMode` plugin. The plugin wraps your existing tools in the code mode machinery — your tool functions don't change at all:
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("Server", transforms=[CodeMode()])
mcp = FastMCP("Server", plugins=[CodeMode()])
@mcp.tool
def add(x: int, y: int) -> int:
@ -165,7 +165,7 @@ If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` para
`ListTools` isn't included in the defaults — for large catalogs, search-based discovery is more token-efficient. But for smaller catalogs (under ~20 tools), letting the LLM see everything upfront can be faster than multiple search round-trips:
```python
from fastmcp.experimental.transforms.code_mode import CodeMode, ListTools, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode, ListTools, GetSchemas
code_mode = CodeMode(
discovery_tools=[ListTools(), GetSchemas()],
@ -182,23 +182,23 @@ The default. The LLM searches for candidates, inspects schemas for the ones it w
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("Server", transforms=[CodeMode()])
mcp = FastMCP("Server", plugins=[CodeMode()])
```
If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure:
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import GetTags, Search, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import GetTags, Search, GetSchemas
code_mode = CodeMode(
discovery_tools=[GetTags(), Search(), GetSchemas()],
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
### Two-Stage
@ -207,14 +207,14 @@ Search returns parameter schemas inline, so the LLM can go straight from search
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import Search, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import Search, GetSchemas
code_mode = CodeMode(
discovery_tools=[Search(default_detail="detailed"), GetSchemas()],
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
`GetSchemas` is still available as a fallback — the LLM can call it with `detail="full"` if it encounters a tool with complex nested parameters where the compact markdown isn't enough.
@ -225,7 +225,7 @@ Skip discovery entirely and bake tool instructions into the execute tool's descr
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
code_mode = CodeMode(
discovery_tools=[],
@ -237,7 +237,7 @@ code_mode = CodeMode(
),
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
## Custom Discovery Tools
@ -247,8 +247,8 @@ Discovery tools are composable — you can mix the built-ins with your own. Each
Here's a minimal example:
```python
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import GetToolCatalog, GetSchemas
from fastmcp.server.context import Context
from fastmcp.tools.tool import Tool
@ -268,7 +268,7 @@ The LLM sees the docstring of each discovery tool's inner function as its descri
Discovery tools and the execute tool can also have custom names:
```python
from fastmcp.experimental.transforms.code_mode import Search, GetSchemas
from fastmcp.server.plugins.code_mode import Search, GetSchemas
code_mode = CodeMode(
discovery_tools=[
@ -278,7 +278,7 @@ code_mode = CodeMode(
execute_tool_name="run_workflow",
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
## Sandbox Configuration
@ -288,14 +288,14 @@ mcp = FastMCP("Server", transforms=[code_mode])
The default `MontySandboxProvider` can enforce execution limits — timeouts, memory caps, recursion depth, and more. Without limits, LLM-generated scripts can run indefinitely.
```python
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import MontySandboxProvider
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import MontySandboxProvider
sandbox = MontySandboxProvider(
limits={"max_duration_secs": 10, "max_memory": 50_000_000},
)
mcp = FastMCP("Server", transforms=[CodeMode(sandbox_provider=sandbox)])
mcp = FastMCP("Server", plugins=[CodeMode(sandbox_provider=sandbox)])
```
All keys are optional — omit any to leave that dimension uncapped:
@ -316,8 +316,8 @@ You can replace the default sandbox with any object implementing the `SandboxPro
from collections.abc import Callable
from typing import Any
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import SandboxProvider
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import SandboxProvider
class RemoteSandboxProvider:
async def run(
@ -332,7 +332,7 @@ class RemoteSandboxProvider:
mcp = FastMCP(
"Server",
transforms=[CodeMode(sandbox_provider=RemoteSandboxProvider())],
plugins=[CodeMode(sandbox_provider=RemoteSandboxProvider())],
)
```

View file

@ -24,14 +24,14 @@ This means any client that can call tools can now access prompts, even if the cl
Pass your FastMCP server to `PromptsAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to prompt operations automatically, exactly as it would for direct `prompts/get` calls.
<Note>
`PromptsAsTools` (and `ResourcesAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and apply the transform there.
`PromptsAsTools` is a plugin — register it on a FastMCP server (not a raw Provider). The generated tools call back into the server's middleware chain at runtime. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and register the plugin there.
</Note>
```python
from fastmcp import FastMCP
from fastmcp.server.transforms import PromptsAsTools
from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools
mcp = FastMCP("My Server")
mcp = FastMCP("My Server", plugins=[PromptsAsTools()])
@mcp.prompt
def analyze_code(code: str, language: str = "python") -> str:
@ -42,9 +42,6 @@ def analyze_code(code: str, language: str = "python") -> str:
def explain_concept(concept: str) -> str:
"""Explain a programming concept."""
return f"Explain: {concept}"
# Add the transform - creates list_prompts and get_prompt tools
mcp.add_transform(PromptsAsTools(mcp))
```
Clients now see three items: whatever tools you defined directly, plus `list_prompts` and `get_prompt`.

View file

@ -24,14 +24,14 @@ This means any client that can call tools can now access resources, even if the
Pass your FastMCP server to `ResourcesAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to resource operations automatically, exactly as it would for direct `resources/read` calls.
<Note>
`ResourcesAsTools` (and `PromptsAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and apply the transform there.
`ResourcesAsTools` is a plugin — register it on a FastMCP server (not a raw Provider). The generated tools call back into the server's middleware chain at runtime. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and register the plugin there.
</Note>
```python
from fastmcp import FastMCP
from fastmcp.server.transforms import ResourcesAsTools
from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools
mcp = FastMCP("My Server")
mcp = FastMCP("My Server", plugins=[ResourcesAsTools()])
@mcp.resource("config://app")
def app_config() -> str:
@ -42,9 +42,6 @@ def app_config() -> str:
def user_profile(user_id: str) -> str:
"""Get a user's profile by ID."""
return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}'
# Add the transform - creates list_resources and read_resource tools
mcp.add_transform(ResourcesAsTools(mcp))
```
Clients now see three tools: whatever tools you defined directly, plus `list_resources` and `read_resource`.

View file

@ -468,7 +468,7 @@ When mounting an OAuth-protected server under a path prefix, declare your URLs u
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from starlette.applications import Starlette
from starlette.routing import Mount
@ -530,7 +530,7 @@ Here's a complete working example showing all the pieces together:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from starlette.applications import Starlette
from starlette.routing import Mount
import uvicorn

View file

@ -81,7 +81,7 @@ Create your FastMCP server using the `Auth0Provider`.
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.auth0 import Auth0Provider
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
# The Auth0Provider utilizes Auth0 OIDC configuration
auth_provider = Auth0Provider(
@ -158,7 +158,7 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.auth0 import Auth0Provider
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
@ -205,7 +205,7 @@ Setting this environment variable allows the Auth0 provider to be used automatic
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.auth0.Auth0Provider` to use Auth0 authentication.
Set to `fastmcp.server.plugins.auth.auth0.provider.Auth0Provider` to use Auth0 authentication.
</ParamField>
</Card>
@ -250,7 +250,7 @@ Comma-, space-, or JSON-separated list of required AUth0 scopes (e.g., `openid e
Example `.env` file:
```bash
# Use the Auth0 provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.auth0.provider.Auth0Provider
# Auth0 configuration and credentials
FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration

View file

@ -43,7 +43,7 @@ Create your FastMCP server file and use the `AuthKitProvider` to handle all the
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
# The AuthKitProvider automatically discovers WorkOS endpoints
# and configures JWT token validation
@ -90,7 +90,7 @@ Setting this environment variable allows the AuthKit provider to be used automat
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.workos.AuthKitProvider` to use AuthKit authentication.
Set to `fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider` to use AuthKit authentication.
</ParamField>
</Card>
@ -115,7 +115,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid p
Example `.env` file:
```bash
# Use the AuthKit provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.AuthKitProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider
# AuthKit configuration
FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN=https://your-project-12345.authkit.app

View file

@ -121,7 +121,7 @@ Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cog
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
from fastmcp.server.dependencies import get_access_token
# The AWSCognitoProvider handles JWT validation and user claims
@ -205,7 +205,7 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
@ -248,7 +248,7 @@ Setting this environment variable allows the AWS Cognito provider to be used aut
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.aws.AWSCognitoProvider` to use AWS Cognito authentication.
Set to `fastmcp.server.plugins.auth.aws.provider.AWSCognitoProvider` to use AWS Cognito authentication.
</ParamField>
</Card>
@ -293,7 +293,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid e
Example `.env` file:
```bash
# Use the AWS Cognito provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.aws.AWSCognitoProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.aws.provider.AWSCognitoProvider
# AWS Cognito credentials
FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID=eu-central-1_XXXXXXXXX

View file

@ -114,7 +114,7 @@ Create your FastMCP server using the `AzureProvider`, which handles Azure's OAut
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
# The AzureProvider handles Azure's token format and validation
auth_provider = AzureProvider(
@ -247,7 +247,7 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
@ -292,7 +292,7 @@ Setting this environment variable allows the Azure provider to be used automatic
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.azure.AzureProvider` to use Azure authentication.
Set to `fastmcp.server.plugins.auth.azure.provider.AzureProvider` to use Azure authentication.
</ParamField>
</Card>
@ -360,7 +360,7 @@ This setting affects all Azure OAuth endpoints (authorization, token, issuer, JW
Example `.env` file:
```bash
# Use the Azure provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.azure.AzureProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.azure.provider.AzureProvider
# Azure OAuth credentials
FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149

View file

@ -59,7 +59,7 @@ Create your FastMCP server file and use the DescopeProvider to handle all the OA
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.descope import DescopeProvider
from fastmcp.server.plugins.auth.descope.provider import DescopeProvider
# The DescopeProvider automatically discovers Descope endpoints
# and configures JWT token validation
@ -105,7 +105,7 @@ Setting this environment variable allows the Descope provider to be used automat
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use
Set to `fastmcp.server.plugins.auth.descope.provider.DescopeProvider` to use
Descope authentication.
</ParamField>
</Card>
@ -129,7 +129,7 @@ Example `.env` file:
```bash
# Use the Descope provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.descope.DescopeProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.descope.provider.DescopeProvider
# Descope configuration
FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration

View file

@ -61,7 +61,7 @@ Create your FastMCP server using the `DiscordProvider`, which handles Discord's
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.discord import DiscordProvider
from fastmcp.server.plugins.auth.discord.provider import DiscordProvider
auth_provider = DiscordProvider(
client_id="12345", # Your Discord Application Client ID
@ -154,7 +154,7 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.discord import DiscordProvider
from fastmcp.server.plugins.auth.discord.provider import DiscordProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
@ -193,7 +193,7 @@ Setting this environment variable allows the Discord provider to be used automat
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.discord.DiscordProvider` to use Discord authentication.
Set to `fastmcp.server.plugins.auth.discord.provider.DiscordProvider` to use Discord authentication.
</ParamField>
</Card>
@ -233,7 +233,7 @@ HTTP request timeout for Discord API calls
Example `.env` file:
```bash
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.discord.DiscordProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.discord.provider.DiscordProvider
FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID=12345
FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET=your-client-secret

View file

@ -65,7 +65,7 @@ Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OA
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
# The GitHubProvider handles GitHub's token format and validation
auth_provider = GitHubProvider(
@ -144,7 +144,7 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
@ -187,7 +187,7 @@ Setting this environment variable allows the GitHub provider to be used automati
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.github.GitHubProvider` to use GitHub authentication.
Set to `fastmcp.server.plugins.auth.github.provider.GitHubProvider` to use GitHub authentication.
</ParamField>
</Card>
@ -228,7 +228,7 @@ HTTP request timeout for GitHub API calls
Example `.env` file:
```bash
# Use the GitHub provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider
# GitHub OAuth credentials
FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID=Ov23liAbcDefGhiJkLmN

View file

@ -70,7 +70,7 @@ Create your FastMCP server using the `GoogleProvider`, which handles Google's OA
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.google import GoogleProvider
from fastmcp.server.plugins.auth.google.provider import GoogleProvider
# The GoogleProvider handles Google's token format and validation
auth_provider = GoogleProvider(
@ -157,7 +157,7 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.google import GoogleProvider
from fastmcp.server.plugins.auth.google.provider import GoogleProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
@ -201,7 +201,7 @@ Setting this environment variable allows the Google provider to be used automati
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.google.GoogleProvider` to use Google authentication.
Set to `fastmcp.server.plugins.auth.google.provider.GoogleProvider` to use Google authentication.
</ParamField>
</Card>
@ -242,7 +242,7 @@ HTTP request timeout for Google API calls
Example `.env` file:
```bash
# Use the Google provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.google.provider.GoogleProvider
# Google OAuth credentials
FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com

View file

@ -213,7 +213,7 @@ For production deployments with persistent token management across server restar
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.oci import OCIProvider
from fastmcp.server.plugins.auth.oci.provider import OCIProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
@ -265,7 +265,7 @@ Setting this environment variable allows the OCI provider to be used automatical
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.oci.OCIProvider` to use OCI IAM authentication.
Set to `fastmcp.server.plugins.auth.oci.provider.OCIProvider` to use OCI IAM authentication.
</ParamField>
</Card>
@ -303,7 +303,7 @@ Redirect path configured in your OCI IAM Integrated Application
Example `.env` file:
```bash
# Use the OCI IAM provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.oci.OCIProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.oci.provider.OCIProvider
# OCI IAM configuration and credentials
FASTMCP_SERVER_AUTH_OCI_IAM_GUID=idcs-asaacasd1111.....

View file

@ -50,7 +50,7 @@ Create your FastMCP server file and use the ScalekitProvider to handle all the O
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider
# Discovers Scalekit endpoints and set up JWT token validation
auth_provider = ScalekitProvider(
@ -95,7 +95,7 @@ Setting this environment variable allows the Scalekit provider to be used automa
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.scalekit.ScalekitProvider` to use Scalekit authentication.
Set to `fastmcp.server.plugins.auth.scalekit.provider.ScalekitProvider` to use Scalekit authentication.
</ParamField>
</Card>
@ -127,7 +127,7 @@ Example `.env`:
```bash
# Use the Scalekit provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.scalekit.ScalekitProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.scalekit.provider.ScalekitProvider
# Scalekit configuration
FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL=https://your-env.scalekit.com

View file

@ -32,7 +32,7 @@ Create your FastMCP server using the `SupabaseProvider`:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.supabase import SupabaseProvider
from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider
# Configure Supabase Auth
auth = SupabaseProvider(
@ -101,7 +101,7 @@ Setting this environment variable allows the Supabase provider to be used automa
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.supabase.SupabaseProvider` to use Supabase authentication.
Set to `fastmcp.server.plugins.auth.supabase.provider.SupabaseProvider` to use Supabase authentication.
</ParamField>
</Card>
@ -130,7 +130,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid e
Example `.env` file:
```bash
# Use the Supabase provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.supabase.SupabaseProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.supabase.provider.SupabaseProvider
# Supabase configuration
FASTMCP_SERVER_AUTH_SUPABASE_PROJECT_URL=https://abc123.supabase.co

View file

@ -61,7 +61,7 @@ Create your FastMCP server using the `WorkOSProvider`:
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import WorkOSProvider
from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider
# Configure WorkOS OAuth
auth = WorkOSProvider(
@ -135,7 +135,7 @@ For production deployments with persistent token management across server restar
```python server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import WorkOSProvider
from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet
@ -180,7 +180,7 @@ Setting this environment variable allows the WorkOS provider to be used automati
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.workos.WorkOSProvider` to use WorkOS authentication.
Set to `fastmcp.server.plugins.auth.workos.provider.WorkOSProvider` to use WorkOS authentication.
</ParamField>
</Card>
@ -232,7 +232,7 @@ FASTMCP_SERVER_AUTH_WORKOS_BASE_URL=https://your-server.com
FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES=["openid","profile","email"]
# Optional: Automatically provision WorkOS auth for all servers
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.workos.provider.WorkOSProvider
```
With environment variables set, you can either:
@ -240,14 +240,14 @@ With environment variables set, you can either:
**Option 1: Manual instantiation (env vars provide defaults)**
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import WorkOSProvider
from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider
# Env vars provide default values for WorkOSProvider()
auth = WorkOSProvider() # Uses env var defaults
mcp = FastMCP(name="WorkOS Protected Server", auth=auth)
```
**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider)**
**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.workos.provider.WorkOSProvider)**
```python server.py
from fastmcp import FastMCP

View file

@ -106,7 +106,7 @@ For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully sup
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
auth = AuthKitProvider(
authkit_domain="https://your-project.authkit.app",
@ -136,7 +136,7 @@ For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with Git
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
auth = GitHubProvider(
client_id="Ov23li...", # Your GitHub OAuth App ID
@ -202,11 +202,11 @@ Authentication providers are configured by specifying the full module path to th
<ParamField path="FASTMCP_SERVER_AUTH" type="string">
The full module path to the authentication provider class. Examples:
- `fastmcp.server.auth.providers.github.GitHubProvider` - GitHub OAuth
- `fastmcp.server.auth.providers.google.GoogleProvider` - Google OAuth
- `fastmcp.server.plugins.auth.github.provider.GitHubProvider` - GitHub OAuth
- `fastmcp.server.plugins.auth.google.provider.GoogleProvider` - Google OAuth
- `fastmcp.server.auth.providers.jwt.JWTVerifier` - JWT token verification
- `fastmcp.server.auth.providers.workos.WorkOSProvider` - WorkOS OAuth
- `fastmcp.server.auth.providers.workos.AuthKitProvider` - WorkOS AuthKit
- `fastmcp.server.plugins.auth.workos.provider.WorkOSProvider` - WorkOS OAuth
- `fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider` - WorkOS AuthKit
- `mycompany.auth.CustomProvider` - Your custom provider class
</ParamField>
@ -214,12 +214,12 @@ When using providers like GitHub or Google, you'll need to set provider-specific
```bash
# GitHub OAuth
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..."
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="github_pat_..."
# Google OAuth
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.google.provider.GoogleProvider
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="123456.apps.googleusercontent.com"
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="GOCSPX-..."
```

View file

@ -345,7 +345,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
FastMCP includes pre-configured providers for common services:
```python
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
auth = GitHubProvider(
client_id="your-github-app-id",
@ -590,7 +590,7 @@ For production deployments, configure the OAuth proxy through environment variab
```bash
# Specify the provider implementation
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider
# Provider-specific credentials
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..."

View file

@ -218,7 +218,7 @@ auth = OIDCProxy(
FastMCP includes pre-configured OIDC providers for common services:
```python
from fastmcp.server.auth.providers.auth0 import Auth0Provider
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
auth = Auth0Provider(
config_url="https://.../.well-known/openid-configuration",
@ -247,7 +247,7 @@ For production deployments, configure the OIDC proxy through environment variabl
```bash
# Specify the provider implementation
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.auth0.provider.Auth0Provider
# Provider-specific credentials
export FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration

View file

@ -183,7 +183,7 @@ WorkOS AuthKit provides an excellent example of remote OAuth integration. The `A
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
auth = AuthKitProvider(
authkit_domain="https://your-project.authkit.app",

View file

@ -57,7 +57,7 @@ middleware = ResponseCachingMiddleware(
Or with OAuth token storage:
```python
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from key_value.aio.stores.disk import DiskStore
auth = GitHubProvider(
@ -110,7 +110,7 @@ For OAuth token storage:
```python
import os
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from key_value.aio.stores.redis import RedisStore
auth = GitHubProvider(
@ -162,7 +162,7 @@ By default, FastMCP automatically manages keys and storage based on your platfor
No configuration needed:
```python
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
auth = GitHubProvider(
client_id="your-id",
@ -177,7 +177,7 @@ For production deployments, configure explicit keys and persistent network-acces
```python
import os
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from cryptography.fernet import Fernet

View file

@ -1,4 +1,4 @@
"""Example: CodeMode transform — search and execute tools via code.
"""Example: CodeMode plugin — search and execute tools via code.
CodeMode replaces the entire tool catalog with two meta-tools: `search`
(keyword-based tool discovery) and `execute` (run Python code that chains
@ -13,9 +13,9 @@ Run with:
"""
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("CodeMode Demo")
mcp = FastMCP("CodeMode Demo", plugins=[CodeMode()])
@mcp.tool
@ -74,10 +74,10 @@ def read_file(path: str) -> str:
return f.read()
# CodeMode collapses all 8 tools into just `search` + `execute`.
# The LLM discovers tools via keyword search, then writes Python
# scripts that chain multiple tool calls in a single round-trip.
mcp.add_transform(CodeMode())
# CodeMode (registered at construction above) collapses all 8 tools
# into just `search` + `execute`. The LLM discovers tools via keyword
# search, then writes Python scripts that chain multiple tool calls in
# a single round-trip.
if __name__ == "__main__":

View file

@ -1,4 +1,4 @@
"""Example: Expose prompts as tools using PromptsAsTools transform.
"""Example: Expose prompts as tools using the PromptsAsTools plugin.
This example shows how to use PromptsAsTools to make prompts accessible
to clients that only support tools (not the prompts protocol).
@ -8,9 +8,9 @@ Run with:
"""
from fastmcp import FastMCP
from fastmcp.server.transforms import PromptsAsTools
from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools
mcp = FastMCP("Prompt Tools Demo")
mcp = FastMCP("Prompt Tools Demo", plugins=[PromptsAsTools()])
# Simple prompt without arguments
@ -78,8 +78,8 @@ Please provide:
"""
# Add the transform - this creates list_prompts and get_prompt tools
mcp.add_transform(PromptsAsTools(mcp))
# PromptsAsTools (registered at construction above) adds list_prompts
# and get_prompt synthetic tools so tools-only clients can drive prompts.
if __name__ == "__main__":

View file

@ -1,4 +1,4 @@
"""Example: Expose resources as tools using ResourcesAsTools transform.
"""Example: Expose resources as tools using the ResourcesAsTools plugin.
This example shows how to use ResourcesAsTools to make resources accessible
to clients that only support tools (not the resources protocol).
@ -8,9 +8,9 @@ Run with:
"""
from fastmcp import FastMCP
from fastmcp.server.transforms import ResourcesAsTools
from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools
mcp = FastMCP("Resource Tools Demo")
mcp = FastMCP("Resource Tools Demo", plugins=[ResourcesAsTools()])
# Static resource - has a fixed URI
@ -57,8 +57,8 @@ def read_file(directory: str, filename: str) -> str:
return f"Contents of {directory}/{filename}"
# Add the transform - this creates list_resources and read_resource tools
mcp.add_transform(ResourcesAsTools(mcp))
# ResourcesAsTools (registered at construction above) adds list_resources
# and read_resource synthetic tools so tools-only clients can drive resources.
if __name__ == "__main__":

View file

@ -1,21 +0,0 @@
# Search Transforms
When a server exposes many tools, listing them all at once can overwhelm an LLM's context window. Search transforms collapse the full tool catalog behind a search interface — clients see only `search_tools` and `call_tool`, and discover the real tools on demand.
## Two search strategies
**Regex** (`RegexSearchTransform`) — clients search with regex patterns like `add|multiply` or `text.*`. Fast and precise when you know what you're looking for.
**BM25** (`BM25SearchTransform`) — clients search with natural language like `"work with numbers"`. Results are ranked by relevance using BM25 scoring. The index rebuilds automatically when tools change.
Both strategies respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply to search results.
## Run
```bash
# Regex
uv run python examples/search/client_regex.py
# BM25
uv run python examples/search/client_bm25.py
```

View file

@ -0,0 +1,28 @@
# ToolSearch plugin
When a server exposes many tools, listing them all at once can overwhelm an LLM's context window. The `ToolSearch` plugin collapses the full tool catalog behind a search interface — clients see only `search_tools` and `call_tool`, and discover the real tools on demand.
## Two search strategies
**Regex** (`strategy="regex"`) — clients search with regex patterns like `add|multiply` or `text.*`. Fast and precise when you know what you're looking for.
**BM25** (`strategy="bm25"`) — clients search with natural language like `"work with numbers"`. Results are ranked by relevance using BM25 scoring. The index rebuilds automatically when tools change.
Both strategies respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply to search results.
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
mcp = FastMCP("Server", plugins=[ToolSearch(ToolSearchConfig(strategy="regex"))])
```
## Run
```bash
# Regex
uv run python examples/tool_search/client_regex.py
# BM25
uv run python examples/tool_search/client_bm25.py
```

View file

@ -4,7 +4,7 @@ BM25 search accepts natural language queries instead of regex patterns.
This client shows how relevance ranking surfaces the best matches.
Run with:
uv run python examples/search/client_bm25.py
uv run python examples/tool_search/client_bm25.py
"""
import asyncio
@ -65,7 +65,7 @@ def _tool_table(
async def main():
async with Client("examples/search/server_bm25.py") as client:
async with Client("examples/tool_search/server_bm25.py") as client:
console.print()
console.rule("[bold]BM25 Search Transform[/bold]")
console.print()

View file

@ -4,7 +4,7 @@ Regex search lets clients find tools by matching patterns against tool names
and descriptions. Precise when you know what you're looking for.
Run with:
uv run python examples/search/client_regex.py
uv run python examples/tool_search/client_regex.py
"""
import asyncio
@ -65,7 +65,7 @@ def _tool_table(
async def main():
async with Client("examples/search/server_regex.py") as client:
async with Client("examples/tool_search/server_regex.py") as client:
console.print()
console.rule("[bold]Regex Search Transform[/bold]")
console.print()

View file

@ -9,15 +9,20 @@ The index is built lazily and rebuilt automatically when the tool catalog
changes (e.g. tools added or removed between requests).
Run with:
uv run python examples/search/server_bm25.py
uv run python examples/tool_search/server_bm25.py
"""
import os
from fastmcp import FastMCP
from fastmcp.server.transforms.search import BM25SearchTransform
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
mcp = FastMCP("BM25 Search Demo")
mcp = FastMCP(
"BM25 Search Demo",
plugins=[
ToolSearch(ToolSearchConfig(max_results=5, always_visible=["list_files"]))
],
)
@mcp.tool
@ -75,10 +80,9 @@ def read_file(path: str) -> str:
# BM25 search with a higher result limit for this larger catalog.
# The `always_visible` option keeps specific tools in list_tools output
# alongside the search/call tools — useful for tools the LLM should
# always know about.
mcp.add_transform(BM25SearchTransform(max_results=5, always_visible=["list_files"]))
# The ToolSearch plugin is configured at server construction above —
# `always_visible` keeps specific tools in list_tools alongside the
# synthetic search/call tools.
if __name__ == "__main__":

View file

@ -10,13 +10,16 @@ Clients use `search_tools` with a regex pattern to find relevant tools, then
`call_tool` to execute them by name.
Run with:
uv run python examples/search/server_regex.py
uv run python examples/tool_search/server_regex.py
"""
from fastmcp import FastMCP
from fastmcp.server.transforms.search import RegexSearchTransform
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
mcp = FastMCP("Regex Search Demo")
mcp = FastMCP(
"Regex Search Demo",
plugins=[ToolSearch(ToolSearchConfig(strategy="regex", max_results=3))],
)
# Register a variety of tools across different domains.
@ -65,9 +68,8 @@ def to_uppercase(text: str) -> str:
return text.upper()
# Apply the regex search transform.
# max_results limits how many tools a single search returns.
mcp.add_transform(RegexSearchTransform(max_results=3))
# The ToolSearch plugin is configured at server construction above —
# nothing else to wire here.
if __name__ == "__main__":

View file

@ -23,6 +23,7 @@ from fastmcp.cli.auth import auth_app
from fastmcp.cli.client import call_command, discover_command, list_command
from fastmcp.cli.generate import generate_cli_command
from fastmcp.cli.install import install_app
from fastmcp.cli.plugin import plugin_app
from fastmcp.cli.tasks import tasks_app
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
from fastmcp.utilities.inspect import (
@ -1102,6 +1103,9 @@ app.command(install_app)
# Add tasks subcommand group
app.command(tasks_app)
# Add plugin subcommand group
app.command(plugin_app)
# Add client query commands
app.command(list_command, name="list")
app.command(call_command, name="call")

102
src/fastmcp/cli/plugin.py Normal file
View file

@ -0,0 +1,102 @@
"""CLI commands for working with FastMCP plugins.
Currently exposes a single verb, `fastmcp plugin manifest`, which imports
a plugin class and emits its manifest (metadata + config schema + entry
point) as JSON. The manifest is the artifact downstream consumers
(Horizon, registries, CI tooling) ingest to discover and configure the
plugin without importing its module themselves.
"""
from __future__ import annotations
import importlib
import json
import sys
from pathlib import Path
from typing import Annotated
import cyclopts
from cyclopts import Parameter
from fastmcp.server.plugins import Plugin
from fastmcp.server.plugins.base import PluginError
from fastmcp.utilities.logging import get_logger
logger = get_logger("cli.plugin")
plugin_app = cyclopts.App(
name="plugin",
help="Work with FastMCP plugins.",
default_parameter=Parameter(negative=()),
)
def _resolve_plugin_class(entry_point: str) -> type[Plugin]:
"""Import a plugin class from a `module.path:ClassName` spec.
The class portion may be dotted (e.g. `module:Outer.MyPlugin`) to
resolve a nested class, matching the `entry_point` format
`Plugin.manifest()` emits via `__qualname__`.
"""
if ":" not in entry_point:
raise ValueError(
f"Invalid plugin reference {entry_point!r}: "
f"expected 'module.path:ClassName'"
)
module_path, class_name = entry_point.split(":", 1)
try:
module = importlib.import_module(module_path)
except ImportError as exc:
raise ImportError(f"Could not import module {module_path!r}: {exc}") from exc
cls: object = module
for part in class_name.split("."):
try:
cls = getattr(cls, part)
except AttributeError as exc:
raise AttributeError(
f"Module {module_path!r} has no attribute {class_name!r}"
) from exc
if not isinstance(cls, type) or not issubclass(cls, Plugin):
raise TypeError(f"{entry_point!r} does not refer to a fastmcp.Plugin subclass")
return cls
@plugin_app.command(name="manifest")
def manifest_command(
entry_point: Annotated[
str,
Parameter(help="Plugin reference in 'module.path:ClassName' form."),
],
output: Annotated[
Path | None,
Parameter(
name=["--output", "-o"],
help="Write manifest JSON to this path instead of stdout.",
),
] = None,
) -> None:
"""Emit a plugin's manifest as JSON.
Imports the referenced plugin class and prints its manifest to stdout,
or writes it to the path given by `-o/--output`.
"""
try:
cls = _resolve_plugin_class(entry_point)
except (ImportError, AttributeError, TypeError, ValueError) as exc:
logger.error(str(exc))
sys.exit(1)
try:
manifest = cls.manifest()
except (PluginError, TypeError) as exc:
logger.error(str(exc))
sys.exit(1)
if output is None:
print(json.dumps(manifest, indent=2, sort_keys=False))
return
output.write_text(json.dumps(manifest, indent=2, sort_keys=False))
print(f"Wrote manifest for {cls.meta.name} to {output}")

View file

@ -1,4 +1,4 @@
"""Deprecated: Import from fastmcp.server.providers.openapi instead."""
"""Deprecated: Import from fastmcp.server.plugins.openapi instead."""
import warnings
@ -7,23 +7,27 @@ from fastmcp.exceptions import FastMCPDeprecationWarning
# Deprecated in 2.14 when OpenAPI support was promoted out of experimental
warnings.warn(
"Importing from fastmcp.experimental.server.openapi is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
# Import from canonical location
from fastmcp.server.openapi.server import FastMCPOpenAPI as FastMCPOpenAPI # noqa: E402
from fastmcp.server.providers.openapi import ( # noqa: E402
ComponentFn as ComponentFn,
from fastmcp.server.plugins.openapi import ( # noqa: E402
MCPType as MCPType,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.components import ( # noqa: E402
OpenAPIResource as OpenAPIResource,
OpenAPIResourceTemplate as OpenAPIResourceTemplate,
OpenAPITool as OpenAPITool,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.routing import ( # noqa: E402
ComponentFn as ComponentFn,
RouteMapFn as RouteMapFn,
)
from fastmcp.server.providers.openapi.routing import ( # noqa: E402
from fastmcp.server.plugins.openapi.routing import ( # noqa: E402
DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS,
_determine_route_type as _determine_route_type,
)

View file

@ -1,568 +1,62 @@
import importlib
import json
from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol
"""Deprecation shim — code mode moved to `fastmcp.server.plugins.code_mode`.
if TYPE_CHECKING:
from pydantic_monty import ResourceLimits
The preferred API is now the `CodeMode` plugin:
from mcp.types import TextContent
from pydantic import Field
from fastmcp import FastMCP
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.exceptions import NotFoundError
from fastmcp.server.context import Context
from fastmcp.server.transforms import GetToolNext
from fastmcp.server.transforms.catalog import CatalogTransform
from fastmcp.server.transforms.search.base import (
serialize_tools_for_output_json,
serialize_tools_for_output_markdown,
)
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.async_utils import is_coroutine_function
from fastmcp.utilities.versions import VersionSpec
mcp = FastMCP("Server", plugins=[CodeMode()])
# ---------------------------------------------------------------------------
# Type aliases
# ---------------------------------------------------------------------------
For backcompat, this module keeps `CodeMode` bound to the **transform**
class (so existing `mcp.add_transform(CodeMode())` code keeps working).
The transform is also exported under its new canonical name,
`CodeModeTransform`. Sandbox providers, discovery-tool factories, and
related helpers re-export from the new location unchanged.
GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]]
"""Async callable that returns the auth-filtered tool catalog."""
SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]]
"""Async callable that searches a tool sequence by query string."""
DiscoveryToolFactory = Callable[[GetToolCatalog], Tool]
"""Factory that receives catalog access and returns a synthetic Tool."""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
if is_coroutine_function(fn):
return fn
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs)
return wrapper
def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str:
"""Convert a ToolResult for use in the sandbox.
- Output schema present structured_content dict (matches the schema)
- Otherwise concatenated text content as a string
"""
if result.structured_content is not None:
return result.structured_content
parts: list[str] = []
for content in result.content:
if isinstance(content, TextContent):
parts.append(content.text)
else:
parts.append(str(content))
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Sandbox providers
# ---------------------------------------------------------------------------
class SandboxProvider(Protocol):
"""Interface for executing LLM-generated Python code in a sandbox.
WARNING: The ``code`` parameter passed to ``run`` contains untrusted,
LLM-generated Python. Implementations MUST execute it in an isolated
sandbox never with plain ``exec()``. Use ``MontySandboxProvider``
(backed by ``pydantic-monty``) for production workloads.
"""
async def run(
self,
code: str,
*,
inputs: dict[str, Any] | None = None,
external_functions: dict[str, Callable[..., Any]] | None = None,
) -> Any: ...
class MontySandboxProvider:
"""Sandbox provider backed by `pydantic-monty`.
Args:
limits: Resource limits for sandbox execution. Supported keys:
``max_duration_secs`` (float), ``max_allocations`` (int),
``max_memory`` (int), ``max_recursion_depth`` (int),
``gc_interval`` (int). All are optional; omit a key to
leave that limit uncapped.
"""
def __init__(
self,
*,
limits: "ResourceLimits | None" = None,
) -> None:
self.limits = limits
async def run(
self,
code: str,
*,
inputs: dict[str, Any] | None = None,
external_functions: dict[str, Callable[..., Any]] | None = None,
) -> Any:
try:
pydantic_monty = importlib.import_module("pydantic_monty")
except ModuleNotFoundError as exc:
raise ImportError(
"CodeMode requires pydantic-monty for the Monty sandbox provider. "
"Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider."
) from exc
inputs = inputs or {}
async_functions = {
key: _ensure_async(value)
for key, value in (external_functions or {}).items()
}
monty = pydantic_monty.Monty(code, inputs=list(inputs))
return await monty.run_async(
inputs=inputs or None,
external_functions=async_functions or None,
limits=self.limits,
)
# ---------------------------------------------------------------------------
# Built-in discovery tools
# ---------------------------------------------------------------------------
ToolDetailLevel = Literal["brief", "detailed", "full"]
"""Detail level for discovery tool output.
- ``"brief"``: tool names and one-line descriptions
- ``"detailed"``: compact markdown with parameter names, types, and required markers
- ``"full"``: complete JSON schema
This path issues a `FastMCPDeprecationWarning` on import a
`DeprecationWarning` subclass that fastmcp enables by default (plain
`DeprecationWarning` is suppressed by CPython's default filter, so
users wouldn't see the notice).
"""
def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str:
"""Render tools at the requested detail level.
The same detail value produces the same output format regardless of
which discovery tool calls this, so ``detail="detailed"`` on Search
gives identical formatting to ``detail="detailed"`` on GetSchemas.
"""
if not tools:
if detail == "full":
return json.dumps([], indent=2)
return "No tools matched the query."
if detail == "full":
return json.dumps(serialize_tools_for_output_json(tools), indent=2)
if detail == "detailed":
return serialize_tools_for_output_markdown(tools)
# brief
lines: list[str] = []
for tool in tools:
desc = f": {tool.description}" if tool.description else ""
lines.append(f"- {tool.name}{desc}")
return "\n".join(lines)
class Search:
"""Discovery tool factory that searches the catalog by query.
Args:
search_fn: Async callable ``(tools, query) -> matching_tools``.
Defaults to BM25 ranking.
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level for search results.
``"brief"`` returns tool names and descriptions only.
``"detailed"`` returns compact markdown with parameter schemas.
``"full"`` returns complete JSON tool definitions.
default_limit: Maximum number of results to return.
The LLM can override this per call. ``None`` means no limit.
"""
def __init__(
self,
*,
search_fn: SearchFn | None = None,
name: str = "search",
default_detail: ToolDetailLevel | None = None,
default_limit: int | None = None,
) -> None:
if search_fn is None:
from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
_bm25 = BM25SearchTransform(max_results=default_limit or 50)
search_fn = _bm25._search
self._search_fn = search_fn
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "brief"
self._default_limit = default_limit
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
search_fn = self._search_fn
default_detail = self._default_detail
default_limit = self._default_limit
async def search(
query: Annotated[str, "Search query to find available tools"],
tags: Annotated[
list[str] | None,
"Filter to tools with any of these tags before searching",
] = None,
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
limit: Annotated[
int | None,
"Maximum number of results to return",
] = default_limit,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""Search for available tools by query.
Returns matching tools ranked by relevance.
"""
catalog = await get_catalog(ctx)
catalog_size = len(catalog)
tools: Sequence[Tool] = catalog
if tags:
tag_set = set(tags)
has_untagged = "untagged" in tag_set
real_tags = tag_set - {"untagged"}
tools = [
t
for t in tools
if (t.tags & real_tags) or (has_untagged and not t.tags)
]
results = await search_fn(tools, query)
if limit is not None:
results = results[:limit]
rendered = _render_tools(results, detail)
if len(results) < catalog_size and detail != "full":
n = len(results)
rendered = f"{n} of {catalog_size} tools:\n\n{rendered}"
return rendered
return Tool.from_function(fn=search, name=self._name)
class GetSchemas:
"""Discovery tool factory that returns schemas for tools by name.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level for schema results.
``"brief"`` returns tool names and descriptions only.
``"detailed"`` renders compact markdown with parameter names,
types, and required markers.
``"full"`` returns the complete JSON schema.
"""
def __init__(
self,
*,
name: str = "get_schema",
default_detail: ToolDetailLevel | None = None,
) -> None:
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "detailed"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def get_schema(
tools: Annotated[
list[str],
"List of tool names to get schemas for",
],
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""Get parameter schemas for specific tools.
Use after searching to get the detail needed to call a tool.
"""
catalog = await get_catalog(ctx)
catalog_by_name = {t.name: t for t in catalog}
matched = [catalog_by_name[n] for n in tools if n in catalog_by_name]
not_found = [n for n in tools if n not in catalog_by_name]
if not matched and not_found:
return f"Tools not found: {', '.join(not_found)}"
if detail == "full":
data = serialize_tools_for_output_json(matched)
if not_found:
data.append({"not_found": not_found})
return json.dumps(data, indent=2)
result = _render_tools(matched, detail)
if not_found:
result += f"\n\nTools not found: {', '.join(not_found)}"
return result
return Tool.from_function(fn=get_schema, name=self._name)
class GetTags:
"""Discovery tool factory that lists tool tags from the catalog.
Reads ``tool.tags`` from the catalog and groups tools by tag. Tools
without tags appear under ``"untagged"``.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level.
``"brief"`` returns tag names with tool counts.
``"full"`` lists all tools under each tag.
"""
def __init__(
self,
*,
name: str = "tags",
default_detail: Literal["brief", "full"] | None = None,
) -> None:
self._name = name
self._default_detail: Literal["brief", "full"] = default_detail or "brief"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def tags(
detail: Annotated[
Literal["brief", "full"],
"Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""List available tool tags.
Use to browse available tools by tag before searching.
"""
catalog = await get_catalog(ctx)
by_tag: dict[str, list[Tool]] = {}
for tool in catalog:
if tool.tags:
for tag in tool.tags:
by_tag.setdefault(tag, []).append(tool)
else:
by_tag.setdefault("untagged", []).append(tool)
if not by_tag:
return "No tools available."
if detail == "brief":
lines = [
f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})"
for tag, tools in sorted(by_tag.items())
]
return "\n".join(lines)
blocks: list[str] = []
for tag, tools in sorted(by_tag.items()):
lines = [f"### {tag}"]
for tool in tools:
desc = f": {tool.description}" if tool.description else ""
lines.append(f"- {tool.name}{desc}")
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
return Tool.from_function(fn=tags, name=self._name)
class ListTools:
"""Discovery tool factory that lists all tools in the catalog.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level.
``"brief"`` returns tool names and one-line descriptions.
``"detailed"`` returns compact markdown with parameter schemas.
``"full"`` returns the complete JSON schema.
"""
def __init__(
self,
*,
name: str = "list_tools",
default_detail: ToolDetailLevel | None = None,
) -> None:
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "brief"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def list_tools(
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""List all available tools.
Use to see the full catalog before searching or calling tools.
"""
catalog = await get_catalog(ctx)
return _render_tools(catalog, detail)
return Tool.from_function(fn=list_tools, name=self._name)
# ---------------------------------------------------------------------------
# CodeMode
# ---------------------------------------------------------------------------
def _default_discovery_tools() -> list[DiscoveryToolFactory]:
return [Search(), GetSchemas()]
class CodeMode(CatalogTransform):
"""Transform that collapses all tools into discovery + execute meta-tools.
Discovery tools are composable via the ``discovery_tools`` parameter.
Each is a callable that receives catalog access and returns a ``Tool``.
By default, ``Search`` and ``GetSchemas`` are included for
progressive disclosure: search finds candidates, get_schema retrieves
parameter details, and execute runs code.
The ``execute`` tool is always present and provides a sandboxed Python
environment with ``call_tool(name, params)`` in scope.
"""
def __init__(
self,
*,
sandbox_provider: SandboxProvider | None = None,
discovery_tools: list[DiscoveryToolFactory] | None = None,
execute_tool_name: str = "execute",
execute_description: str | None = None,
) -> None:
super().__init__()
self.execute_tool_name = execute_tool_name
self.execute_description = execute_description
self.sandbox_provider = sandbox_provider or MontySandboxProvider()
self._discovery_factories = (
discovery_tools
if discovery_tools is not None
else _default_discovery_tools()
)
self._built_discovery_tools: list[Tool] | None = None
self._cached_execute_tool: Tool | None = None
def _build_discovery_tools(self) -> list[Tool]:
if self._built_discovery_tools is None:
tools = [
factory(self.get_tool_catalog) for factory in self._discovery_factories
]
names = {t.name for t in tools}
if self.execute_tool_name in names:
raise ValueError(
f"Discovery tool name '{self.execute_tool_name}' "
f"collides with execute_tool_name."
)
if len(names) != len(tools):
raise ValueError("Discovery tools must have unique names.")
self._built_discovery_tools = tools
return self._built_discovery_tools
async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
return [*self._build_discovery_tools(), self._get_execute_tool()]
async def get_tool(
self,
name: str,
call_next: GetToolNext,
*,
version: VersionSpec | None = None,
) -> Tool | None:
for tool in self._build_discovery_tools():
if tool.name == name:
return tool
if name == self.execute_tool_name:
return self._get_execute_tool()
return await call_next(name, version=version)
def _build_execute_description(self) -> str:
if self.execute_description is not None:
return self.execute_description
return (
"Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n"
"Use `return` to produce output.\n"
"Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope."
)
@staticmethod
def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None:
"""Find a tool by name from a pre-fetched list."""
for tool in tools:
if tool.name == name:
return tool
return None
def _get_execute_tool(self) -> Tool:
if self._cached_execute_tool is None:
self._cached_execute_tool = self._make_execute_tool()
return self._cached_execute_tool
def _make_execute_tool(self) -> Tool:
transform = self
async def execute(
code: Annotated[
str,
Field(
description=(
"Python async code to execute tool calls via call_tool(name, arguments)"
)
),
],
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> Any:
"""Execute tool calls using Python code."""
async def call_tool(tool_name: str, params: dict[str, Any]) -> Any:
backend_tools = await transform.get_tool_catalog(ctx)
tool = transform._find_tool(tool_name, backend_tools)
if tool is None:
raise NotFoundError(f"Unknown tool: {tool_name}")
result = await ctx.fastmcp.call_tool(tool.name, params)
return _unwrap_tool_result(result)
return await transform.sandbox_provider.run(
code,
external_functions={"call_tool": call_tool},
)
return Tool.from_function(
fn=execute,
name=self.execute_tool_name,
description=self._build_execute_description(),
)
import warnings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.code_mode.discovery import (
DiscoveryToolFactory,
GetSchemas,
GetTags,
GetToolCatalog,
ListTools,
Search,
)
from fastmcp.server.plugins.code_mode.sandbox import (
MontySandboxProvider,
SandboxProvider,
)
from fastmcp.server.plugins.code_mode.transform import CodeModeTransform
# `CodeMode` at this old path stays bound to the transform class, so
# `mcp.add_transform(CodeMode(...))` keeps working. The new plugin class
# is at `fastmcp.server.plugins.code_mode.CodeMode`.
CodeMode = CodeModeTransform
warnings.warn(
"fastmcp.experimental.transforms.code_mode has moved to "
"fastmcp.server.plugins.code_mode. Prefer the CodeMode plugin: "
"`from fastmcp.server.plugins.code_mode import CodeMode` and pass "
"it via `plugins=[CodeMode(...)]`. At this old path, `CodeMode` "
"remains the transform class (also exported as `CodeModeTransform`) "
"for backcompat. The old import path will be removed in a future "
"release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
__all__ = [
"CodeMode",
"CodeModeTransform",
"DiscoveryToolFactory",
"GetSchemas",
"GetTags",
"GetToolCatalog",

View file

@ -1,135 +1,20 @@
"""Auth0 OAuth provider for FastMCP.
"""Backward compatibility shim for Auth0 auth provider."""
This module provides a complete Auth0 integration that's ready to use with
just the configuration URL, client ID, client secret, audience, and base URL.
from __future__ import annotations
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.auth0 import Auth0Provider
import warnings
# Simple Auth0 OAuth protection
auth = Auth0Provider(
config_url="https://auth0.config.url",
client_id="your-auth0-client-id",
client_secret="your-auth0-client-secret",
audience="your-auth0-api-audience",
base_url="http://localhost:8000",
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.auth0 is deprecated. "
"Import from fastmcp.server.plugins.auth.auth0.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
from typing import Literal
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp.server.auth.oidc_proxy import OIDCProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class Auth0Provider(OIDCProxy):
"""An Auth0 provider implementation for FastMCP.
This provider is a complete Auth0 integration that's ready to use with
just the configuration URL, client ID, client secret, audience, and base URL.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.auth0 import Auth0Provider
# Simple Auth0 OAuth protection
auth = Auth0Provider(
config_url="https://auth0.config.url",
client_id="your-auth0-client-id",
client_secret="your-auth0-client-secret",
audience="your-auth0-api-audience",
base_url="http://localhost:8000",
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
def __init__(
self,
*,
config_url: AnyHttpUrl | str,
client_id: str,
client_secret: str,
audience: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
redirect_path: str | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
) -> None:
"""Initialize Auth0 OAuth provider.
Args:
config_url: Auth0 config URL
client_id: Auth0 application client id
client_secret: Auth0 application client secret
audience: Auth0 API audience
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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.
required_scopes: Required Auth0 scopes (defaults to ["openid"])
redirect_path: Redirect path configured in Auth0 application
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 Auth0.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
"""
# Parse scopes if provided as string
auth0_required_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
)
super().__init__(
config_url=config_url,
client_id=client_id,
client_secret=client_secret,
audience=audience,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
required_scopes=auth0_required_scopes,
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
)
logger.debug(
"Initialized Auth0 OAuth provider for client %s with scopes: %s",
client_id,
auth0_required_scopes,
)
__all__ = ["Auth0Provider"]

View file

@ -1,229 +1,23 @@
"""AWS Cognito OAuth provider for FastMCP.
This module provides a complete AWS Cognito OAuth integration that's ready to use
with a user pool ID, domain prefix, client ID and client secret. It handles all
the complexity of AWS Cognito's OAuth flow, token validation, and user management.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider
# Simple AWS Cognito OAuth protection
auth = AWSCognitoProvider(
user_pool_id="your-user-pool-id",
aws_region="eu-central-1",
client_id="your-cognito-client-id",
client_secret="your-cognito-client-secret"
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
"""Backward compatibility shim for AWS Cognito auth provider."""
from __future__ import annotations
from typing import Literal
import warnings
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.oidc_proxy import OIDCProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.aws is deprecated. "
"Import from fastmcp.server.plugins.auth.aws.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.aws.provider import (
AWSCognitoProvider,
AWSCognitoTokenVerifier,
)
class AWSCognitoTokenVerifier(JWTVerifier):
"""Token verifier for Cognito access tokens.
Cognito access tokens use a ``client_id`` claim instead of the
standard ``aud`` claim. This subclass passes ``audience=None``
to the parent (skipping the ``aud`` check) and validates the
``client_id`` claim directly.
"""
def __init__(self, *, audience: str | list[str] | None = None, **kwargs):
self._expected_client_id = audience
super().__init__(audience=None, **kwargs)
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token and filter claims to Cognito-specific subset."""
access_token = await super().verify_token(token)
if not access_token:
return None
# Validate client_id claim (Cognito's equivalent of aud)
if self._expected_client_id:
token_client_id = access_token.claims.get("client_id")
if isinstance(self._expected_client_id, list):
valid = token_client_id in self._expected_client_id
else:
valid = token_client_id == self._expected_client_id
if not valid:
self.logger.debug(
"Token validation failed: client_id mismatch (expected %s, got %s)",
self._expected_client_id,
token_client_id,
)
return None
# Filter claims to Cognito-specific subset
cognito_claims = {
"sub": access_token.claims.get("sub"),
"username": access_token.claims.get("username"),
"cognito:groups": access_token.claims.get("cognito:groups", []),
}
return AccessToken(
token=access_token.token,
client_id=access_token.client_id,
scopes=access_token.scopes,
expires_at=access_token.expires_at,
claims=cognito_claims,
)
class AWSCognitoProvider(OIDCProxy):
"""Complete AWS Cognito OAuth provider for FastMCP.
This provider makes it trivial to add AWS Cognito OAuth protection to any
FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details,
client credentials, and a base URL, and you're ready to go.
Features:
- Automatic OIDC Discovery from AWS Cognito User Pool
- Automatic JWT token validation via Cognito's public keys
- Cognito-specific claim filtering (sub, username, cognito:groups)
- Support for Cognito User Pools
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider
auth = AWSCognitoProvider(
user_pool_id="eu-central-1_XXXXXXXXX",
aws_region="eu-central-1",
client_id="your-cognito-client-id",
client_secret="your-cognito-client-secret",
base_url="https://my-server.com",
redirect_path="/custom/callback",
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
user_pool_id: str,
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
aws_region: str = "eu-central-1",
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str = "/auth/callback",
required_scopes: list[str] | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
):
"""Initialize AWS Cognito OAuth provider.
Args:
user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX")
client_id: Cognito app client ID
client_secret: Cognito app client secret
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
aws_region: AWS region where your User Pool is located (defaults to "eu-central-1")
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 Cognito app (defaults to "/auth/callback")
required_scopes: Required Cognito scopes (defaults to ["openid"])
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 AWS Cognito.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
"""
# Parse scopes if provided as string
required_scopes_final = (
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
)
# Construct OIDC discovery URL
config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration"
# Store Cognito-specific info for claim filtering
self.user_pool_id = user_pool_id
self.aws_region = aws_region
self.client_id = client_id
# Initialize OIDC proxy with Cognito discovery
super().__init__(
config_url=config_url,
client_id=client_id,
client_secret=client_secret,
algorithm="RS256",
required_scopes=required_scopes_final,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
)
logger.debug(
"Initialized AWS Cognito OAuth provider for client %s with scopes: %s",
client_id,
required_scopes_final,
)
def get_token_verifier(
self,
*,
algorithm: str | None = None,
audience: str | None = None,
required_scopes: list[str] | None = None,
timeout_seconds: int | None = None,
) -> AWSCognitoTokenVerifier:
"""Creates a Cognito-specific token verifier with claim filtering.
Args:
algorithm: Optional token verifier algorithm
audience: Optional token verifier audience
required_scopes: Optional token verifier required_scopes
timeout_seconds: HTTP request timeout in seconds
"""
return AWSCognitoTokenVerifier(
issuer=str(self.oidc_config.issuer),
audience=audience or self.client_id,
algorithm=algorithm,
jwks_uri=str(self.oidc_config.jwks_uri),
required_scopes=required_scopes,
)
__all__ = ["AWSCognitoProvider", "AWSCognitoTokenVerifier"]

View file

@ -1,768 +1,24 @@
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
This provider implements Azure/Microsoft Entra ID OAuth authentication
using the OAuth Proxy pattern for non-DCR OAuth flows.
"""
"""Backward compatibility shim for Azure auth provider."""
from __future__ import annotations
import hashlib
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Literal, cast
import httpx
from key_value.aio.protocols import AsyncKeyValue
from fastmcp.dependencies import Dependency
from fastmcp.server.auth.auth import MultiAuth
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from azure.identity.aio import OnBehalfOfCredential
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyHttpUrl
from fastmcp.server.auth.auth import AuthProvider
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 AzureProvider(OAuthProxy):
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
This provider implements Azure/Microsoft Entra ID authentication using the
OAuth Proxy pattern. It supports both organizational accounts and personal
Microsoft accounts depending on the tenant configuration.
Scope Handling:
- required_scopes: Provide unprefixed scope names (e.g., ["read", "write"])
Automatically prefixed with identifier_uri during initialization
Validated on all tokens and advertised to MCP clients
- additional_authorize_scopes: Provide full format (e.g., ["User.Read"])
NOT prefixed, NOT validated, NOT advertised to clients
Used to request Microsoft Graph or other upstream API permissions
Features:
- OAuth proxy to Azure/Microsoft identity platform
- JWT validation using tenant issuer and JWKS
- Supports tenant configurations: specific tenant ID, "organizations", or "consumers"
- Custom API scopes and Microsoft Graph scopes in a single provider
Setup:
1. Create an App registration in Azure Portal
2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path)
3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id})
4. Add custom scopes (e.g., "read", "write") under "Expose an API"
5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2
6. Create a client secret
7. Get Application (client) ID, Directory (tenant) ID, and client secret
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider
# Standard Azure (Public Cloud)
auth = AzureProvider(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
required_scopes=["read", "write"], # Unprefixed scope names
additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes
base_url="http://localhost:8000",
# identifier_uri defaults to api://{client_id}
)
# Azure Government
auth_gov = AzureProvider(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
required_scopes=["read", "write"],
base_authority="login.microsoftonline.us", # Override for Azure Gov
base_url="http://localhost:8000",
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
client_id: str,
client_secret: str | None = None,
tenant_id: str,
required_scopes: list[str],
base_url: str,
resource_base_url: AnyHttpUrl | str | None = None,
identifier_uri: str | None = None,
issuer_url: str | None = None,
redirect_path: str | None = None,
additional_authorize_scopes: list[str] | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
base_authority: str = "login.microsoftonline.com",
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
) -> None:
"""Initialize Azure OAuth provider.
Args:
client_id: Azure application (client) ID from your App registration
client_secret: Azure client secret from your App registration. Optional when
using alternative credentials (e.g., managed identity with a custom
_create_upstream_oauth_client override). When omitted, jwt_signing_key
must be provided.
tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers")
identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}).
This URI is automatically prefixed to all required_scopes during initialization.
Example: identifier_uri="api://my-api" + required_scopes=["read"]
tokens validated for "api://my-api/read"
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 Azure App registration (defaults to "/auth/callback")
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
For Azure Government, use "login.microsoftonline.us".
required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]).
- Automatically prefixed with identifier_uri during initialization
- Validated on all tokens
- Advertised in Protected Resource Metadata
- Must match scope names defined in Azure Portal under "Expose an API"
Example: ["read", "write"] validates tokens containing ["api://xxx/read", "api://xxx/write"]
additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format.
- NOT prefixed with identifier_uri
- NOT validated on tokens
- NOT advertised to MCP clients
- Used to request additional permissions from Azure (e.g., Graph API access)
Example: ["User.Read", "Mail.Read"]
These scopes allow your FastMCP server to call Microsoft Graph APIs using the
upstream Azure token, but MCP clients are unaware of them.
Note: "offline_access" is automatically included to obtain refresh tokens.
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 Azure.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches.
When provided, the client is reused for JWT key fetches and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
parsed_required_scopes = parse_scopes(required_scopes)
parsed_additional_scopes: list[str] = (
parse_scopes(additional_authorize_scopes) or []
if additional_authorize_scopes
else []
)
# Always include offline_access to get refresh tokens from Azure
if "offline_access" not in parsed_additional_scopes:
parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"]
# Store Azure-specific config for OBO credential creation
self._tenant_id = tenant_id
self._base_authority = base_authority
# Cache of OBO credentials keyed by hash of user assertion token.
# Reusing credentials allows the Azure SDK's internal token cache
# to avoid redundant OBO exchanges for the same user + scopes.
self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict()
self._obo_max_credentials: int = 128
# Apply defaults
self.identifier_uri = identifier_uri or f"api://{client_id}"
self.additional_authorize_scopes: list[str] = parsed_additional_scopes
# Always validate tokens against the app's API client ID using JWT
issuer = f"https://{base_authority}/{tenant_id}/v2.0"
jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys"
# 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 = [
s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES
]
if not validation_scopes:
raise ValueError(
"AzureProvider requires at least one non-OIDC scope in "
"required_scopes (e.g., 'read', 'write'). OIDC scopes like "
"'openid', 'profile', 'email', and 'offline_access' are not "
"included in Azure access token claims and cannot be used for "
"scope enforcement."
)
token_verifier = JWTVerifier(
jwks_uri=jwks_uri,
issuer=issuer,
audience=[client_id, self.identifier_uri],
algorithm="RS256",
required_scopes=validation_scopes, # Only validate non-OIDC scopes
http_client=http_client,
)
# Build Azure OAuth endpoints with tenant
authorization_endpoint = (
f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize"
)
token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token"
# Initialize OAuth proxy with Azure endpoints
# Remember there's hooks called, such as _prepare_scopes_for_token_exchange
# and _prepare_scopes_for_upstream_refresh
super().__init__(
upstream_authorization_endpoint=authorization_endpoint,
upstream_token_endpoint=token_endpoint,
upstream_client_id=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
valid_scopes=parsed_required_scopes,
enable_cimd=enable_cimd,
)
authority_info = ""
if base_authority != "login.microsoftonline.com":
authority_info = f" using authority {base_authority}"
logger.info(
"Initialized Azure OAuth provider for client %s with tenant %s%s%s",
client_id,
tenant_id,
f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
authority_info,
)
async def authorize(
self,
client: OAuthClientInformationFull,
params: AuthorizationParams,
) -> str:
"""Start OAuth transaction and redirect to Azure AD.
Override parent's authorize method to filter out the 'resource' parameter
which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use
scopes to determine the resource/audience instead of a separate parameter.
Args:
client: OAuth client information
params: Authorization parameters from the client
Returns:
Authorization URL to redirect the user to Azure AD
"""
# Clear the resource parameter that Azure AD v2.0 doesn't support
# This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators)
# but Azure AD v2.0 uses scopes instead to determine the audience
params_to_use = params
if hasattr(params, "resource"):
original_resource = getattr(params, "resource", None)
if original_resource is not None:
params_to_use = params.model_copy(update={"resource": None})
if original_resource:
logger.debug(
"Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
original_resource,
)
# Don't modify the scopes in params - they stay unprefixed for MCP clients
# We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url)
auth_url = await super().authorize(client, params_to_use)
separator = "&" if "?" in auth_url else "?"
return f"{auth_url}{separator}prompt=select_account"
def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
"""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
Returns:
List of scopes with identifier_uri prefix applied where needed
"""
prefixed = []
for scope in scopes:
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 custom API scope - prefix with identifier_uri
prefixed.append(f"{self.identifier_uri}/{scope}")
return prefixed
def _build_upstream_authorize_url(
self, txn_id: str, transaction: dict[str, Any]
) -> str:
"""Build Azure authorization URL with prefixed scopes.
Overrides parent to prefix scopes with identifier_uri before sending to Azure,
while keeping unprefixed scopes in the transaction for MCP clients.
"""
# Get unprefixed scopes from transaction
unprefixed_scopes = transaction.get("scopes") or self.required_scopes or []
# Prefix scopes for Azure authorization request
prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes)
# Add Microsoft Graph scopes (not validated, not prefixed)
if self.additional_authorize_scopes:
prefixed_scopes.extend(self.additional_authorize_scopes)
# Temporarily modify transaction dict for parent's URL building
modified_transaction = transaction.copy()
modified_transaction["scopes"] = prefixed_scopes
# Let parent build the URL with prefixed scopes
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]:
"""Prepare scopes for Azure authorization code exchange.
Azure requires scopes during token exchange (AADSTS28003 error if missing).
Azure only allows ONE resource per token request (AADSTS28000), so we only
include scopes for this API plus OIDC scopes.
Args:
scopes: Scopes from the authorization request (unprefixed)
Returns:
List of scopes for Azure token endpoint
"""
# Prefix scopes for this API
prefixed_scopes = self._prefix_scopes_for_azure(scopes or [])
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
if self.additional_authorize_scopes:
prefixed_scopes.extend(
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
)
deduplicated = list(dict.fromkeys(prefixed_scopes))
logger.debug("Token exchange scopes: %s", deduplicated)
return deduplicated
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
"""Prepare scopes for Azure token refresh.
Azure requires fully-qualified scopes and only allows ONE resource per
token request (AADSTS28000). We include scopes for this API plus OIDC scopes.
Args:
scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
Returns:
Deduplicated list of scopes formatted for Azure token endpoint
"""
logger.debug("Base scopes from storage: %s", scopes)
# Filter out any additional_authorize_scopes that may have been stored
additional_scopes_set = set(self.additional_authorize_scopes or [])
base_scopes = [s for s in scopes if s not in additional_scopes_set]
# Prefix base scopes with identifier_uri for Azure
prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
if self.additional_authorize_scopes:
prefixed_scopes.extend(
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
)
deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
return deduplicated_scopes
async def _extract_upstream_claims(
self, idp_tokens: dict[str, Any]
) -> dict[str, Any] | None:
"""Extract claims from Azure token response to embed in FastMCP JWT.
Decodes the Azure access token (which is a JWT) to extract user identity
claims. This allows gateways to inspect upstream identity information by
decoding the FastMCP JWT without needing server-side storage lookups.
Azure access tokens contain claims like:
- sub: Subject identifier (unique per user per application)
- oid: Object ID (unique user identifier across Azure AD)
- tid: Tenant ID
- azp: Authorized party (client ID that requested the token)
- name: Display name
- given_name: First name
- family_name: Last name
- preferred_username: User principal name (email format)
- upn: User Principal Name
- email: Email address (if available)
- roles: Application roles assigned to the user
- groups: Group memberships (if configured)
Args:
idp_tokens: Full token response from Azure, containing access_token
and potentially id_token.
Returns:
Dict of extracted claims, or None if extraction fails.
"""
access_token = idp_tokens.get("access_token")
if not access_token:
return None
try:
# Azure access tokens are JWTs - decode without verification
# (already validated by token_verifier during token exchange)
payload = decode_jwt_payload(access_token)
# Extract useful identity claims
claims: dict[str, Any] = {}
claim_keys = [
"sub",
"oid",
"tid",
"azp",
"name",
"given_name",
"family_name",
"preferred_username",
"upn",
"email",
"roles",
"groups",
]
for claim in claim_keys:
if claim in payload:
claims[claim] = payload[claim]
if claims:
logger.debug(
"Extracted %d Azure claims for embedding in FastMCP JWT",
len(claims),
)
return claims
return None
except Exception as e:
logger.debug("Failed to extract Azure claims: %s", e)
return None
async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential:
"""Get a cached or new OnBehalfOfCredential for OBO token exchange.
Credentials are cached by user assertion so the Azure SDK's internal
token cache can avoid redundant OBO exchanges when the same user
calls multiple tools with the same scopes.
Args:
user_assertion: The user's access token to exchange via OBO.
Returns:
A configured OnBehalfOfCredential ready for get_token() calls.
Raises:
ImportError: If azure-identity is not installed (requires fastmcp[azure]).
"""
_require_azure_identity("OBO token exchange")
from azure.identity.aio import OnBehalfOfCredential
key = hashlib.sha256(user_assertion.encode()).hexdigest()
if key in self._obo_credentials:
self._obo_credentials.move_to_end(key)
return self._obo_credentials[key]
obo_kwargs: dict[str, Any] = {
"tenant_id": self._tenant_id,
"client_id": self._upstream_client_id,
"user_assertion": user_assertion,
"authority": f"https://{self._base_authority}",
}
if self._upstream_client_secret is not None:
obo_kwargs["client_secret"] = (
self._upstream_client_secret.get_secret_value()
)
else:
raise ValueError(
"OBO token exchange requires either a client_secret or a subclass "
"that overrides get_obo_credential() to provide alternative credentials "
"(e.g., client_assertion_func for managed identity)."
)
credential = OnBehalfOfCredential(**obo_kwargs)
self._obo_credentials[key] = credential
# Evict oldest if over capacity
while len(self._obo_credentials) > self._obo_max_credentials:
_, evicted = self._obo_credentials.popitem(last=False)
await evicted.close()
return credential
async def close_obo_credentials(self) -> None:
"""Close all cached OBO credentials."""
credentials = list(self._obo_credentials.values())
self._obo_credentials.clear()
for credential in credentials:
try:
await credential.close()
except Exception:
logger.debug("Error closing OBO credential", exc_info=True)
class AzureJWTVerifier(JWTVerifier):
"""JWT verifier pre-configured for Azure AD / Microsoft Entra ID.
Auto-configures JWKS URI, issuer, audience, and scope handling from your
Azure app registration details. Designed for Managed Identity and other
token-verification-only scenarios where AzureProvider's full OAuth proxy
isn't needed.
Handles Azure's scope format automatically:
- Validates tokens using short-form scopes (what Azure puts in ``scp`` claims)
- Advertises full-URI scopes in OAuth metadata (what clients need to request)
Example::
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.azure import AzureJWTVerifier
from pydantic import AnyHttpUrl
verifier = AzureJWTVerifier(
client_id="your-client-id",
tenant_id="your-tenant-id",
required_scopes=["access_as_user"],
)
auth = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[
AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0")
],
base_url="https://my-server.com",
)
"""
def __init__(
self,
*,
client_id: str,
tenant_id: str,
required_scopes: list[str] | None = None,
identifier_uri: str | None = None,
base_authority: str = "login.microsoftonline.com",
):
"""Initialize Azure JWT verifier.
Args:
client_id: Azure application (client) ID from your App registration
tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers").
For multi-tenant apps ("organizations" or "consumers"), issuer validation
is skipped since Azure tokens carry the actual tenant GUID as issuer.
required_scopes: Scope names as they appear in Azure Portal under "Expose an API"
(e.g., ["access_as_user", "read"]). These are validated against
the short-form scopes in token ``scp`` claims, and automatically
prefixed with identifier_uri for OAuth metadata.
identifier_uri: Application ID URI (defaults to ``api://{client_id}``).
Used to prefix scopes in OAuth metadata so clients know the full
scope URIs to request from Azure.
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
For Azure Government, use "login.microsoftonline.us".
"""
self._identifier_uri = identifier_uri or f"api://{client_id}"
# For multi-tenant apps, Azure tokens carry the actual tenant GUID as
# issuer, not the literal "organizations" or "consumers" string. Skip
# issuer validation for these — audience still protects against wrong-app tokens.
multi_tenant_values = {"organizations", "consumers", "common"}
issuer: str | None = (
None
if tenant_id in multi_tenant_values
else f"https://{base_authority}/{tenant_id}/v2.0"
)
super().__init__(
jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys",
issuer=issuer,
audience=[client_id, self._identifier_uri],
algorithm="RS256",
required_scopes=required_scopes,
)
@property
def scopes_supported(self) -> list[str]:
"""Return scopes with Azure URI prefix for OAuth metadata.
Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp``
claim, but clients must request full URI scopes (e.g.,
``api://client-id/read``) from the Azure authorization endpoint. This
property returns the full-URI form for OAuth metadata while
``required_scopes`` retains the short form for token validation.
"""
if not self.required_scopes:
return []
prefixed = []
for scope in self.required_scopes:
if scope in OIDC_SCOPES or "://" in scope or "/" in scope:
prefixed.append(scope)
else:
prefixed.append(f"{self._identifier_uri}/{scope}")
return prefixed
# --- Dependency injection support ---
# These require fastmcp[azure] extra for azure-identity
def _require_azure_identity(feature: str) -> None:
"""Raise ImportError with install instructions if azure-identity is not available."""
try:
import azure.identity # noqa: F401
except ImportError as e:
raise ImportError(
f"{feature} requires the `azure` extra. "
"Install with: pip install 'fastmcp[azure]'"
) from e
def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None:
"""Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed."""
if isinstance(auth, AzureProvider):
return auth
if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider):
return auth.server
return None
class _EntraOBOToken(Dependency[str]):
"""Dependency that performs OBO token exchange for Microsoft Entra.
Uses azure.identity's OnBehalfOfCredential for async-native OBO,
with automatic token caching and refresh. Credentials are cached on
the AzureProvider so repeated tool calls reuse existing credentials
and benefit from the Azure SDK's internal token cache.
"""
def __init__(self, scopes: list[str]):
self.scopes = scopes
async def __aenter__(self) -> str:
_require_azure_identity("EntraOBOToken")
from fastmcp.server.dependencies import get_access_token, get_server
access_token = get_access_token()
if access_token is None:
raise RuntimeError(
"No access token available. Cannot perform OBO exchange."
)
server = get_server()
azure_provider = _find_azure_provider(server.auth)
if azure_provider is None:
raise RuntimeError(
"EntraOBOToken requires an AzureProvider as the auth provider. "
f"Current provider: {type(server.auth).__name__}"
)
credential = await azure_provider.get_obo_credential(
user_assertion=access_token.token,
)
result = await credential.get_token(*self.scopes)
return result.token
def EntraOBOToken(scopes: list[str]) -> str:
"""Exchange the user's Entra token for a downstream API token via OBO.
This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange,
allowing your MCP server to call downstream APIs (like Microsoft Graph) on
behalf of the authenticated user.
Args:
scopes: The scopes to request for the downstream API. For Microsoft Graph,
use scopes like ["https://graph.microsoft.com/Mail.Read"] or
["https://graph.microsoft.com/.default"].
Returns:
A dependency that resolves to the downstream API access token string
Raises:
ImportError: If fastmcp[azure] is not installed
RuntimeError: If no access token is available, provider is not Azure,
or OBO exchange fails
Example:
```python
from fastmcp.server.auth.providers.azure import EntraOBOToken
import httpx
@mcp.tool()
async def get_my_emails(
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"])
):
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://graph.microsoft.com/v1.0/me/messages",
headers={"Authorization": f"Bearer {graph_token}"}
)
return resp.json()
```
Note:
For OBO to work, ensure the scopes are included in the AzureProvider's
`additional_authorize_scopes` parameter, and that admin consent has been
granted for those scopes in your Entra app registration.
"""
return cast(str, _EntraOBOToken(scopes))
import warnings
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.azure is deprecated. "
"Import from fastmcp.server.plugins.auth.azure.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
from fastmcp.server.plugins.auth.azure.provider import (
AzureJWTVerifier,
AzureProvider,
EntraOBOToken,
)
__all__ = ["AzureJWTVerifier", "AzureProvider", "EntraOBOToken"]

View file

@ -1,388 +1,23 @@
"""Clerk OAuth provider for FastMCP.
This module provides a complete Clerk OAuth integration that's ready to use
with a Clerk domain, client ID, and client secret. It handles all the complexity
of Clerk's OAuth/OIDC flow, token validation, and user management.
Clerk uses standard OIDC endpoints derived from the instance domain
(e.g., ``https://<instance>.clerk.accounts.dev``). Token verification is
performed via the introspection endpoint (RFC 7662) for security-critical
checks (active status, audience, scopes), followed by the userinfo endpoint
for profile enrichment. Userinfo failure is non-fatal.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.clerk import ClerkProvider
auth = ClerkProvider(
domain="saving-primate-16.clerk.accounts.dev",
client_id="your-clerk-client-id",
client_secret="your-clerk-client-secret",
base_url="https://my-server.com",
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
"""Backward compatibility shim for Clerk auth provider."""
from __future__ import annotations
import contextlib
from typing import Literal
import warnings
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import TokenVerifier
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.clerk is deprecated. "
"Import from fastmcp.server.plugins.auth.clerk.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.clerk.provider import (
ClerkProvider,
ClerkTokenVerifier,
)
class ClerkTokenVerifier(TokenVerifier):
"""Token verifier for Clerk OAuth tokens.
Clerk issues standard OIDC tokens. Verification uses the introspection
endpoint (RFC 7662) as the primary security gate it confirms the token
is active and provides metadata (scopes, expiry, audience). The userinfo
endpoint is called second for profile enrichment (name, email, picture)
and its failure is non-fatal.
When a ``client_id`` is configured, the audience from introspection is
validated against it. When ``required_scopes`` are configured,
introspection must return the token's scopes — the verifier will not
assume scopes when introspection is unavailable.
"""
def __init__(
self,
*,
domain: str,
client_id: str | None = None,
client_secret: str | None = None,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize the Clerk token verifier.
Args:
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev")
client_id: Clerk OAuth client ID, used for introspection endpoint authentication
client_secret: Clerk OAuth client secret, used for introspection endpoint authentication
required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"])
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.domain = domain.rstrip("/")
self._client_id = client_id
self._client_secret = client_secret
self.timeout_seconds = timeout_seconds
self._http_client = http_client
self._userinfo_url = f"https://{self.domain}/oauth/userinfo"
self._introspection_url = f"https://{self.domain}/oauth/token_info"
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a Clerk OAuth token via introspection and userinfo.
Calls the introspection endpoint first to validate the token and
retrieve auth metadata (active status, scopes, expiry, audience).
If the token passes security checks, the userinfo endpoint is called
for profile enrichment. Userinfo failure is non-fatal.
When a ``client_id`` is configured, the token's audience must match it.
When ``required_scopes`` are configured, introspection must confirm
them; tokens are rejected if scope information is unavailable.
"""
try:
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Step 1: Validate token via introspection (RFC 7662).
# Security-critical checks (active, audience, scopes) come first.
introspect_data_payload: dict = {"token": token}
introspect_kwargs: dict = {
"data": introspect_data_payload,
"headers": {"User-Agent": "FastMCP-Clerk-OAuth"},
}
if self._client_id and self._client_secret:
introspect_kwargs["auth"] = (
self._client_id,
self._client_secret,
)
elif self._client_id:
introspect_data_payload["client_id"] = self._client_id
introspect_response = await client.post(
self._introspection_url,
**introspect_kwargs,
)
if introspect_response.status_code != 200:
logger.debug(
"Clerk introspection failed: %d",
introspect_response.status_code,
)
return None
introspect_data = introspect_response.json()
# RFC 7662 requires the 'active' field in the response.
# A missing field indicates a malformed response — reject.
if "active" not in introspect_data or not introspect_data["active"]:
logger.debug(
"Clerk introspection: token inactive or missing 'active' field"
)
return None
scope_str = introspect_data.get("scope", "")
token_scopes = scope_str.split() if scope_str else []
aud = introspect_data.get("aud") or introspect_data.get("client_id")
expires_at: int | None = None
exp = introspect_data.get("exp")
if exp is not None:
with contextlib.suppress(ValueError, TypeError):
expires_at = int(exp)
if self._client_id and aud != self._client_id:
logger.debug(
"Clerk token audience mismatch: got %s, expected %s",
aud,
self._client_id,
)
return None
if self.required_scopes:
if not token_scopes:
logger.debug(
"Clerk token missing scope information; "
"cannot verify required scopes %s",
self.required_scopes,
)
return None
token_scopes_set = set(token_scopes)
required_scopes_set = set(self.required_scopes)
if not required_scopes_set.issubset(token_scopes_set):
logger.debug(
"Clerk token missing required scopes. Has %s, needs %s",
token_scopes_set,
required_scopes_set,
)
return None
# Step 2: Fetch user profile via userinfo.
# Enriches the token with profile data (name, email, picture).
sub = introspect_data.get("sub")
user_data: dict = {}
try:
userinfo_response = await client.get(
self._userinfo_url,
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "FastMCP-Clerk-OAuth",
},
)
if userinfo_response.status_code == 200:
user_data = userinfo_response.json()
if not sub:
sub = user_data.get("sub")
except Exception as e:
logger.debug("Clerk userinfo call failed: %s", e)
if not sub:
logger.debug("Clerk token missing 'sub' claim")
return None
access_token = AccessToken(
token=token,
client_id=aud or sub,
scopes=token_scopes,
expires_at=expires_at,
claims={
"sub": sub,
"aud": aud,
"email": user_data.get("email"),
"email_verified": user_data.get("email_verified"),
"name": user_data.get("name"),
"picture": user_data.get("picture"),
"given_name": user_data.get("given_name"),
"family_name": user_data.get("family_name"),
"preferred_username": user_data.get("preferred_username"),
"iss": user_data.get("iss"),
"clerk_user_data": user_data or None,
},
)
logger.debug("Clerk token verified successfully for sub=%s", sub)
return access_token
except httpx.RequestError as e:
logger.debug("Failed to verify Clerk token: %s", e)
return None
except Exception as e:
logger.debug("Clerk token verification error: %s", e)
return None
class ClerkProvider(OAuthProxy):
"""Complete Clerk OAuth provider for FastMCP.
This provider makes it trivial to add Clerk OAuth protection to any
FastMCP server. Provide your Clerk instance domain, OAuth app credentials,
and a base URL, and you're ready to go.
Clerk uses standard OIDC endpoints derived from the instance domain.
All endpoint URLs are constructed automatically from the domain parameter.
Features:
- Transparent OAuth proxy to Clerk
- Automatic token validation via Clerk's userinfo & introspection APIs
- User information extraction from Clerk's OIDC claims
- PKCE support (S256)
- Minimal configuration required
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.clerk import ClerkProvider
auth = ClerkProvider(
domain="saving-primate-16.clerk.accounts.dev",
client_id="your-clerk-client-id",
client_secret="your-clerk-client-secret",
base_url="https://my-server.com",
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
domain: str,
client_id: str,
client_secret: str | None = None,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
valid_scopes: list[str] | None = None,
timeout_seconds: int = 10,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Clerk OAuth provider.
Args:
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev").
This is used to derive all OAuth/OIDC endpoint URLs.
client_id: Clerk OAuth application client ID
client_secret: Clerk OAuth application client secret.
Optional for PKCE public clients. When omitted, jwt_signing_key must be provided.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 Clerk OAuth app (defaults to "/auth/callback")
required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]).
Clerk supports: "openid", "email", "profile", "public_metadata",
"private_metadata", "offline_access".
valid_scopes: All scopes that clients are allowed to request, advertised through
well-known endpoints. Defaults to required_scopes if not provided.
timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10)
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, an encrypted file store will be created in the data directory
(derived from ``platformdirs``).
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 "external", the built-in consent screen is skipped
but no warning is logged, indicating that consent is handled externally by Clerk.
consent_csp_policy: Custom CSP policy for the consent page.
extra_authorize_params: Additional parameters to forward to Clerk's authorization
endpoint. Example: {"prompt": "login"} to force re-authentication.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created
per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
domain = domain.rstrip("/")
required_scopes_final = (
parse_scopes(required_scopes)
if required_scopes is not None
else ["openid", "email", "profile"]
)
parsed_valid_scopes = (
parse_scopes(valid_scopes) if valid_scopes is not None else None
)
token_verifier = ClerkTokenVerifier(
domain=domain,
client_id=client_id,
client_secret=client_secret,
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
)
extra_authorize_params_final = (
dict(extra_authorize_params) if extra_authorize_params else {}
)
super().__init__(
upstream_authorization_endpoint=f"https://{domain}/oauth/authorize",
upstream_token_endpoint=f"https://{domain}/oauth/token",
upstream_client_id=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url,
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
extra_authorize_params=extra_authorize_params_final or None,
valid_scopes=parsed_valid_scopes,
enable_cimd=enable_cimd,
)
logger.debug(
"Initialized Clerk OAuth provider for domain %s with scopes: %s",
domain,
required_scopes_final,
)
__all__ = ["ClerkProvider", "ClerkTokenVerifier"]

View file

@ -1,209 +1,20 @@
"""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.
"""
"""Backward compatibility shim for Descope auth provider."""
from __future__ import annotations
from urllib.parse import urlparse
import warnings
import httpx
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.descope is deprecated. "
"Import from fastmcp.server.plugins.auth.descope.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.descope.provider import DescopeProvider
class DescopeProvider(RemoteAuthProvider):
"""Descope metadata provider for DCR (Dynamic Client Registration).
This provider implements Descope integration using metadata forwarding.
This is the recommended approach for Descope DCR
as it allows Descope to handle the OAuth flow directly while FastMCP acts
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
- Note your Well-Known URL
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``
For detailed setup instructions, see:
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
Example:
```python
from fastmcp.server.auth.providers.descope import DescopeProvider
# Create Descope metadata provider (JWT verifier created automatically)
descope_auth = DescopeProvider(
config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration",
base_url="https://your-fastmcp-server.com",
)
# Use with FastMCP
mcp = FastMCP("My App", auth=descope_auth)
```
"""
def __init__(
self,
*,
base_url: AnyHttpUrl | str,
config_url: AnyHttpUrl | str | None = None,
project_id: str | None = None,
descope_base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
token_verifier: TokenVerifier | None = None,
):
"""Initialize Descope metadata provider.
Args:
base_url: Public URL of this FastMCP server
config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration")
This is the new recommended way. If provided, project_id and descope_base_url are ignored.
project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility.
descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility.
required_scopes: Optional list of scopes that must be present in validated tokens.
These scopes will be included in the protected resource metadata.
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
token_verifier: Optional token verifier. If None, creates JWT verifier for Descope
"""
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
# Parse scopes if provided as string
parsed_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else None
)
# Determine which API is being used
if config_url is not None:
# New API: use config_url
# Strip /.well-known/openid-configuration from config_url if present
issuer_url = str(config_url)
if issuer_url.endswith("/.well-known/openid-configuration"):
issuer_url = issuer_url[: -len("/.well-known/openid-configuration")]
# Parse the issuer URL to extract descope_base_url and project_id for other uses
parsed_url = urlparse(issuer_url)
path_parts = parsed_url.path.strip("/").split("/")
# Extract project_id from path (format: /v1/apps/agentic/P.../M...)
if "agentic" in path_parts:
agentic_index = path_parts.index("agentic")
if agentic_index + 1 < len(path_parts):
self.project_id = path_parts[agentic_index + 1]
else:
raise ValueError(
f"Could not extract project_id from config_url: {issuer_url}"
)
else:
raise ValueError(
f"Could not find 'agentic' in config_url path: {issuer_url}"
)
# Extract descope_base_url (scheme + netloc)
self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip(
"/"
)
elif project_id is not None and descope_base_url is not None:
# Old API: use project_id and descope_base_url
self.project_id = project_id
descope_base_url_str = str(descope_base_url).rstrip("/")
# Ensure descope_base_url has a scheme
if not descope_base_url_str.startswith(("http://", "https://")):
descope_base_url_str = f"https://{descope_base_url_str}"
self.descope_base_url = descope_base_url_str
# Old issuer format
issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}"
else:
raise ValueError(
"Either config_url (new API) or both project_id and descope_base_url (old API) must be provided"
)
# Create default JWT verifier if none provided
if token_verifier is None:
token_verifier = JWTVerifier(
jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json",
issuer=issuer_url,
algorithm="RS256",
audience=self.project_id,
required_scopes=parsed_scopes,
)
# Initialize RemoteAuthProvider with Descope as the authorization server
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(issuer_url)],
base_url=self.base_url,
scopes_supported=scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get OAuth routes including Descope authorization server metadata forwarding.
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.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward Descope OAuth authorization server metadata with FastMCP customizations."""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server"
)
response.raise_for_status()
metadata = response.json()
return JSONResponse(metadata)
except Exception as e:
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to fetch Descope metadata: {e}",
},
status_code=500,
)
# Add Descope authorization server metadata forwarding
routes.append(
Route(
"/.well-known/oauth-authorization-server",
endpoint=oauth_authorization_server_metadata,
methods=["GET"],
)
)
return routes
__all__ = ["DescopeProvider"]

View file

@ -1,288 +1,23 @@
"""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)
```
"""
"""Backward compatibility shim for Discord auth provider."""
from __future__ import annotations
import contextlib
import time
from datetime import datetime
from typing import Literal
import warnings
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import TokenVerifier
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.discord is deprecated. "
"Import from fastmcp.server.plugins.auth.discord.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.discord.provider import (
DiscordProvider,
DiscordTokenVerifier,
)
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,
*,
expected_client_id: str,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize the Discord token verifier.
Args:
expected_client_id: Expected Discord OAuth client ID for audience binding
required_scopes: Required OAuth scopes (e.g., ['email'])
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.expected_client_id = expected_client_id
self.timeout_seconds = timeout_seconds
self._http_client = http_client
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify Discord OAuth token by calling Discord's tokeninfo API."""
try:
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else 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"))
if client_id != self.expected_client_id:
logger.debug(
"Discord token app ID mismatch: expected %s, got %s",
self.expected_client_id,
client_id,
)
return None
# 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,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_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 = 10,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
http_client: httpx.AsyncClient | None = None,
enable_cimd: 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)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 (defaults to 10)
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
required_scopes_final = (
parse_scopes(required_scopes)
if required_scopes is not None
else ["identify"]
)
# Create Discord token verifier
token_verifier = DiscordTokenVerifier(
expected_client_id=client_id,
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
)
# 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=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
enable_cimd=enable_cimd,
)
logger.debug(
"Initialized Discord OAuth provider for client %s with scopes: %s",
client_id,
required_scopes_final,
)
__all__ = ["DiscordProvider", "DiscordTokenVerifier"]

View file

@ -1,303 +1,23 @@
"""GitHub OAuth provider for FastMCP.
This module provides a complete GitHub OAuth integration that's ready to use
with just a client ID and client secret. It handles all the complexity of
GitHub's OAuth flow, token validation, and user management.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
# Simple GitHub OAuth protection
auth = GitHubProvider(
client_id="your-github-client-id",
client_secret="your-github-client-secret"
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
"""Backward compatibility shim for GitHub auth provider."""
from __future__ import annotations
import contextlib
from typing import Literal
import warnings
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import TokenVerifier
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.token_cache import TokenCache
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.github is deprecated. "
"Import from fastmcp.server.plugins.auth.github.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.github.provider import (
GitHubProvider,
GitHubTokenVerifier,
)
class GitHubTokenVerifier(TokenVerifier):
"""Token verifier for GitHub OAuth tokens.
GitHub OAuth tokens are opaque (not JWTs), so we verify them
by calling GitHub's API to check if they're valid and get user info.
Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive
integer to cache successful verification results and avoid repeated
GitHub API calls for the same token.
"""
def __init__(
self,
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
cache_ttl_seconds: int | None = None,
max_cache_size: int | None = None,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize the GitHub token verifier.
Args:
required_scopes: Required OAuth scopes (e.g., ['user:email'])
timeout_seconds: HTTP request timeout
cache_ttl_seconds: How long to cache verification results in seconds.
Caching is disabled by default (None). Set to a positive integer
to enable (e.g., 300 for 5 minutes).
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.timeout_seconds = timeout_seconds
self._http_client = http_client
self._cache = TokenCache(
ttl_seconds=cache_ttl_seconds,
max_size=max_cache_size,
)
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify GitHub OAuth token by calling GitHub API."""
is_cached, cached_result = self._cache.get(token)
if is_cached:
logger.debug("GitHub token cache hit")
return cached_result
try:
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Get token info from GitHub API
response = await client.get(
"https://api.github.com/user",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "FastMCP-GitHub-OAuth",
},
)
if response.status_code != 200:
logger.debug(
"GitHub token verification failed: %d - %s",
response.status_code,
response.text[:200],
)
return None
user_data = response.json()
# Get token scopes from GitHub API
# GitHub includes scopes in the X-OAuth-Scopes header
scopes_response = await client.get(
"https://api.github.com/user/repos", # Any authenticated endpoint
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "FastMCP-GitHub-OAuth",
},
)
# Extract scopes from X-OAuth-Scopes header if available
scopes_verified = scopes_response.status_code == 200
oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "")
token_scopes = [
scope.strip()
for scope in oauth_scopes_header.split(",")
if scope.strip()
]
# If no scopes in header, assume basic scopes based on successful user API call
if not token_scopes:
token_scopes = ["user"] # Basic scope if we can access user info
# 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(
"GitHub token missing required scopes. Has %d, needs %d",
len(token_scopes_set),
len(required_scopes_set),
)
return None
# Create AccessToken with GitHub user info
result = AccessToken(
token=token,
client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID
scopes=token_scopes,
expires_at=None, # GitHub tokens don't typically expire
claims={
"sub": str(user_data["id"]),
"login": user_data.get("login"),
"name": user_data.get("name"),
"email": user_data.get("email"),
"avatar_url": user_data.get("avatar_url"),
"github_user_data": user_data,
},
)
if scopes_verified:
self._cache.set(token, result)
return result
except httpx.RequestError as e:
logger.debug("Failed to verify GitHub token: %s", e)
return None
except Exception as e:
logger.debug("GitHub token verification error: %s", e)
return None
class GitHubProvider(OAuthProxy):
"""Complete GitHub OAuth provider for FastMCP.
This provider makes it trivial to add GitHub OAuth protection to any
FastMCP server. Just provide your GitHub OAuth app credentials and
a base URL, and you're ready to go.
Features:
- Transparent OAuth proxy to GitHub
- Automatic token validation via GitHub API
- User information extraction
- Minimal configuration required
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
auth = GitHubProvider(
client_id="Ov23li...",
client_secret="abc123...",
base_url="https://my-server.com"
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_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 = 10,
cache_ttl_seconds: int | None = None,
max_cache_size: int | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize GitHub OAuth provider.
Args:
client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
client_secret: GitHub OAuth app client secret
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 GitHub OAuth app (defaults to "/auth/callback")
required_scopes: Required GitHub scopes (defaults to ["user"])
timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10)
cache_ttl_seconds: How long to cache token verification results in seconds.
Caching is disabled by default (None). Set to a positive integer to
enable (e.g., 300 for 5 minutes).
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 GitHub.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
required_scopes_final = (
parse_scopes(required_scopes) if required_scopes is not None else ["user"]
)
# Create GitHub token verifier
token_verifier = GitHubTokenVerifier(
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
cache_ttl_seconds=cache_ttl_seconds,
max_cache_size=max_cache_size,
http_client=http_client,
)
# Initialize OAuth proxy with GitHub endpoints
super().__init__(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
enable_cimd=enable_cimd,
)
logger.debug(
"Initialized GitHub OAuth provider for client %s with scopes: %s",
client_id,
required_scopes_final,
)
__all__ = ["GitHubProvider", "GitHubTokenVerifier"]

View file

@ -1,365 +1,23 @@
"""Google OAuth provider for FastMCP.
This module provides a complete Google OAuth integration that's ready to use
with just a client ID and client secret. It handles all the complexity of
Google's OAuth flow, token validation, and user management.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.google import GoogleProvider
# Simple Google OAuth protection
auth = GoogleProvider(
client_id="your-google-client-id.apps.googleusercontent.com",
client_secret="your-google-client-secret"
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
"""Backward compatibility shim for Google auth provider."""
from __future__ import annotations
import contextlib
import time
from typing import Literal
import warnings
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import TokenVerifier
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.google is deprecated. "
"Import from fastmcp.server.plugins.auth.google.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.google.provider import (
GoogleProvider,
GoogleTokenVerifier,
)
GOOGLE_SCOPE_ALIASES: dict[str, str] = {
"email": "https://www.googleapis.com/auth/userinfo.email",
"profile": "https://www.googleapis.com/auth/userinfo.profile",
}
def _normalize_google_scope(scope: str) -> str:
"""Normalize a Google scope shorthand to its canonical full URI.
Google accepts shorthand scopes like "email" and "profile" in authorization
requests, but returns the full URI form in token responses. This normalizes
to the full URI so comparisons work regardless of which form was used.
"""
return GOOGLE_SCOPE_ALIASES.get(scope, scope)
class GoogleTokenVerifier(TokenVerifier):
"""Token verifier for Google OAuth tokens.
Google OAuth tokens are opaque (not JWTs), so we verify them by calling
Google's tokeninfo endpoint with the access token as a query parameter.
This returns the OAuth app ID (``aud``), granted scopes, and expiry time.
User profile data (name, picture, etc.) is fetched separately from the
v2 userinfo endpoint when the token is valid.
"""
def __init__(
self,
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize the Google token verifier.
Args:
required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
normalized = (
[_normalize_google_scope(s) for s in required_scopes]
if required_scopes
else required_scopes
)
super().__init__(required_scopes=normalized)
self.timeout_seconds = timeout_seconds
self._http_client = http_client
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a Google OAuth token using the tokeninfo endpoint.
Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN``
to validate the token and retrieve the OAuth app ID (``aud``), granted
scopes, and expiry time. On success, fetches user profile data from
the v2 userinfo endpoint to populate name, picture, and locale claims.
"""
try:
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Step 1: Verify token via tokeninfo endpoint.
# Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email.
response = await client.get(
"https://oauth2.googleapis.com/tokeninfo",
params={"access_token": token},
headers={"User-Agent": "FastMCP-Google-OAuth"},
)
if response.status_code != 200:
logger.debug(
"Google token verification failed: %d",
response.status_code,
)
return None
token_data = response.json()
# aud is the OAuth app ID (client_id / audience)
aud = token_data.get("aud")
if not aud:
logger.debug("Google tokeninfo missing 'aud' claim")
return None
# sub is required (unique Google user ID)
sub = token_data.get("sub")
if not sub:
logger.debug("Google tokeninfo missing 'sub' claim")
return None
# Parse scopes directly from the tokeninfo response (space-separated)
scope_str = token_data.get("scope", "")
token_scopes = scope_str.split() if scope_str else []
# 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(
"Google token missing required scopes. Has %d, needs %d",
len(token_scopes_set),
len(required_scopes_set),
)
return None
# Compute expiry from expires_in (seconds until expiry)
expires_at: int | None = None
expires_in = token_data.get("expires_in")
if expires_in is not None:
with contextlib.suppress(ValueError, TypeError):
expires_at = int(time.time()) + int(expires_in)
# Step 2: Fetch user profile from v2 userinfo endpoint.
# tokeninfo provides auth data; userinfo provides name, picture, locale.
user_data: dict = {}
try:
userinfo_response = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "FastMCP-Google-OAuth",
},
)
if userinfo_response.status_code == 200:
user_data = userinfo_response.json()
except Exception as e:
logger.debug("Failed to fetch Google user profile: %s", e)
access_token = AccessToken(
token=token,
client_id=sub,
scopes=token_scopes,
expires_at=expires_at,
claims={
"sub": sub,
"aud": aud,
"email": token_data.get("email") or user_data.get("email"),
"email_verified": token_data.get("email_verified")
or user_data.get("verified_email"),
"name": user_data.get("name"),
"picture": user_data.get("picture"),
"given_name": user_data.get("given_name"),
"family_name": user_data.get("family_name"),
"locale": user_data.get("locale"),
"google_user_data": user_data or None,
},
)
logger.debug("Google token verified successfully")
return access_token
except httpx.RequestError as e:
logger.debug("Failed to verify Google token: %s", e)
return None
except Exception as e:
logger.debug("Google token verification error: %s", e)
return None
class GoogleProvider(OAuthProxy):
"""Complete Google OAuth provider for FastMCP.
This provider makes it trivial to add Google OAuth protection to any
FastMCP server. Just provide your Google OAuth app credentials and
a base URL, and you're ready to go.
Features:
- Transparent OAuth proxy to Google
- Automatic token validation via Google's tokeninfo API
- User information extraction from Google APIs
- Minimal configuration required
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.google import GoogleProvider
auth = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-abc123...",
base_url="https://my-server.com"
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
client_id: str,
client_secret: str | None = None,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
valid_scopes: list[str] | None = None,
timeout_seconds: int = 10,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Google OAuth provider.
Args:
client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...").
Optional for PKCE public clients (e.g., native apps). When omitted,
jwt_signing_key must be provided.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 Google OAuth app (defaults to "/auth/callback")
required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include:
- "openid" for OpenID Connect (default)
- "https://www.googleapis.com/auth/userinfo.email" for email access
- "https://www.googleapis.com/auth/userinfo.profile" for profile info
Google scope shorthands like "email" and "profile" are automatically
normalized to their full URI forms for token verification.
valid_scopes: All scopes that clients are allowed to request, advertised through
well-known endpoints. Defaults to required_scopes if not provided. Use this
when you want clients to be able to request additional scopes beyond the
required minimum. Shorthands are normalized to full URI forms.
timeout_seconds: HTTP request timeout for Google API calls (defaults to 10)
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 Google.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by Google's own consent).
SECURITY WARNING: Only set to False for local development or testing environments.
extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
refresh tokens are returned. You can override these defaults or add additional parameters.
Example: {"prompt": "select_account"} to let users choose their Google account.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
# Google requires at least one scope - openid is the minimal OIDC scope
required_scopes_final = (
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
)
# Normalize valid_scopes if provided
parsed_valid_scopes = (
parse_scopes(valid_scopes) if valid_scopes is not None else None
)
valid_scopes_final = (
[_normalize_google_scope(s) for s in parsed_valid_scopes]
if parsed_valid_scopes is not None
else None
)
# Create Google token verifier
# Normalization of shorthand scopes (e.g. "email" -> full URI) happens
# inside GoogleTokenVerifier so required_scopes match what Google returns.
token_verifier = GoogleTokenVerifier(
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
)
# Set Google-specific defaults for extra authorize params
# access_type=offline ensures refresh tokens are returned
# prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise)
google_defaults = {
"access_type": "offline",
"prompt": "consent",
}
# User-provided params override defaults
if extra_authorize_params:
google_defaults.update(extra_authorize_params)
extra_authorize_params_final = google_defaults
# Initialize OAuth proxy with Google endpoints
super().__init__(
upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
upstream_token_endpoint="https://oauth2.googleapis.com/token",
upstream_client_id=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
extra_authorize_params=extra_authorize_params_final,
valid_scopes=valid_scopes_final,
enable_cimd=enable_cimd,
)
logger.debug(
"Initialized Google OAuth provider for client %s with scopes: %s",
client_id,
required_scopes_final,
)
__all__ = ["GoogleProvider", "GoogleTokenVerifier"]

View file

@ -1,74 +1,22 @@
"""Keycloak authentication provider for FastMCP."""
"""Backward compatibility shim for Keycloak auth provider."""
from __future__ import annotations
from pydantic import AnyHttpUrl
import warnings
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
logger = get_logger(__name__)
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.keycloak is deprecated. "
"Import from fastmcp.server.plugins.auth.keycloak.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
from fastmcp.server.plugins.auth.keycloak.provider import (
KeycloakAuthProvider,
)
class KeycloakAuthProvider(RemoteAuthProvider):
"""Keycloak authentication provider using Dynamic Client Registration (DCR).
Requires Keycloak 26.6.0 or later, which includes the fix for DCR compatibility
with MCP clients (https://github.com/keycloak/keycloak/pull/45309).
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
auth = KeycloakAuthProvider(
realm_url="https://keycloak.example.com/realms/myrealm",
base_url="https://my-mcp-server.example.com",
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
realm_url: AnyHttpUrl | str,
base_url: AnyHttpUrl | str,
required_scopes: list[str] | str | None = None,
audience: str | list[str] | None = None,
token_verifier: TokenVerifier | None = None,
):
"""Initialize the Keycloak auth provider.
Args:
realm_url: Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm")
base_url: Public URL of this FastMCP server
required_scopes: Scopes to require on incoming tokens. Defaults to
["openid"], which ensures the `sub` claim (user identifier) is
present in the access token. Override to require additional scopes.
audience: Optional audience(s) for JWT validation. Recommended for production.
token_verifier: Optional custom token verifier. Defaults to a JWTVerifier
configured for Keycloak's JWKS endpoint and issuer.
"""
self.realm_url = str(realm_url).rstrip("/")
parsed_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
)
if token_verifier is None:
token_verifier = JWTVerifier(
jwks_uri=f"{self.realm_url}/protocol/openid-connect/certs",
issuer=self.realm_url,
algorithm="RS256",
required_scopes=parsed_scopes,
audience=audience,
)
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(self.realm_url)],
base_url=AnyHttpUrl(str(base_url).rstrip("/")),
)
__all__ = ["KeycloakAuthProvider"]

View file

@ -1,180 +1,20 @@
"""OCI OIDC provider for FastMCP.
"""Backward compatibility shim for OCI auth provider."""
The pull request for the provider is submitted to fastmcp.
from __future__ import annotations
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.
import warnings
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.
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
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
import oci
from oci.auth.signers import TokenExchangeSigner
logger = get_logger(__name__)
# Load configuration from environment
config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL
client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application
client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application
iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain
# Simple OCI OIDC protection
auth = OCIProvider(
config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL
client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application
client_secret=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",
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.oci is deprecated. "
"Import from fastmcp.server.plugins.auth.oci.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
# 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
from fastmcp.server.plugins.auth.oci.provider import OCIProvider
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=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain
client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application
client_secret=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)
```
"""
from typing import Literal
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp.server.auth.oidc_proxy import OIDCProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class OCIProvider(OIDCProxy):
"""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.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.oci import OCIProvider
import os
# Load configuration from environment
auth = OCIProvider(
config_url=os.environ.get("OCI_CONFIG_URL"), # OCI IAM Domain OIDC discovery URL
client_id=os.environ.get("OCI_CLIENT_ID"), # Client ID configured for the OCI IAM Domain Integrated Application
client_secret=os.environ.get("OCI_CLIENT_SECRET"), # Client secret configured for the OCI IAM Domain Integrated Application
base_url="http://localhost:8000",
required_scopes=["openid", "profile", "email"],
redirect_path="/auth/callback",
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
def __init__(
self,
*,
config_url: AnyHttpUrl | str,
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
audience: str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
redirect_path: str | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
) -> None:
"""Initialize OCI OIDC provider.
Args:
config_url: OCI OIDC Discovery URL
client_id: OCI IAM Domain Integrated Application client id
client_secret: OCI Integrated Application client secret
base_url: Public URL where OIDC endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
audience: OCI API audience (optional)
issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL.
required_scopes: Required OCI scopes (defaults to ["openid"])
redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback".
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
"""
# Parse scopes if provided as string
oci_required_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
)
super().__init__(
config_url=config_url,
client_id=client_id,
client_secret=client_secret,
audience=audience,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
required_scopes=oci_required_scopes,
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
)
logger.debug(
"Initialized OCI OAuth provider for client %s with scopes: %s",
client_id,
oci_required_scopes,
)
__all__ = ["OCIProvider"]

View file

@ -1,234 +1,23 @@
"""PropelAuth authentication provider for FastMCP.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
auth = PropelAuthProvider(
auth_url="https://auth.yourdomain.com",
introspection_client_id="your-client-id",
introspection_client_secret="your-client-secret",
base_url="https://your-fastmcp-server.com",
required_scopes=["read:user_data"],
)
mcp = FastMCP("My App", auth=auth)
```
"""
"""Backward compatibility shim for PropelAuth auth provider."""
from __future__ import annotations
from typing import TypedDict
import warnings
import httpx
from pydantic import AnyHttpUrl, SecretStr
from starlette.responses import JSONResponse
from starlette.routing import Route
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import AccessToken, RemoteAuthProvider
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.propelauth is deprecated. "
"Import from fastmcp.server.plugins.auth.propelauth.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.propelauth.provider import (
PropelAuthProvider,
PropelAuthTokenIntrospectionOverrides,
)
class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False):
timeout_seconds: int
cache_ttl_seconds: int | None
max_cache_size: int | None
http_client: httpx.AsyncClient | None
class PropelAuthProvider(RemoteAuthProvider):
"""PropelAuth resource server provider using OAuth 2.1 token introspection.
This provider validates access tokens via PropelAuth's introspection endpoint
and forwards authorization server metadata for OAuth discovery.
Setup:
1. Enable MCP authentication in the PropelAuth Dashboard
2. Configure scopes on the MCP page
3. Select which redirect URIs to enable by picking which clients you support
4. Generate introspection credentials (Client ID + Client Secret)
For detailed setup instructions, see:
https://docs.propelauth.com/mcp-authentication/overview
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
auth = PropelAuthProvider(
auth_url="https://auth.yourdomain.com",
introspection_client_id="your-client-id",
introspection_client_secret="your-client-secret",
base_url="https://your-fastmcp-server.com",
required_scopes=["read:user_data"],
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
auth_url: AnyHttpUrl | str,
introspection_client_id: str,
introspection_client_secret: str | SecretStr,
base_url: AnyHttpUrl | str,
required_scopes: list[str] | None = None,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
resource: AnyHttpUrl | str | None = None,
token_introspection_overrides: (
PropelAuthTokenIntrospectionOverrides | None
) = None,
):
"""Initialize PropelAuth provider.
Args:
auth_url: Your PropelAuth Auth URL (from the Backend Integration page)
introspection_client_id: Introspection Client ID from the PropelAuth Dashboard
introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard
base_url: Public URL of this FastMCP server
required_scopes: Optional list of scopes that must be present in tokens
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
resource: Optional resource URI (RFC 8707) identifying this MCP server.
Use this when multiple MCP servers share the same PropelAuth
authorization server (e.g. ``resource="https://api.example.com/mcp"``),
so only tokens intended for this MCP server are accepted.
token_introspection_overrides: Optional overrides for the underlying
IntrospectionTokenVerifier (timeout, caching, http_client)
"""
normalized_auth_url = str(auth_url).rstrip("/")
introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect"
authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1")
if resource is None:
self._resource = None
logger.debug(
"PropelAuthProvider: no resource configured, audience checking disabled"
)
else:
self._resource = str(resource)
token_verifier = self._create_token_verifier(
introspection_url=introspection_url,
client_id=introspection_client_id,
client_secret=introspection_client_secret,
required_scopes=required_scopes,
introspection_overrides=token_introspection_overrides,
)
self._normalized_auth_url = normalized_auth_url
super().__init__(
token_verifier=token_verifier,
authorization_servers=[authorization_server_url],
base_url=base_url,
scopes_supported=scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get routes for this provider.
Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)),
and creates an authorization server metadata route that forwards to PropelAuth's route
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
This is used to advertise the resource URL in metadata.
"""
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward PropelAuth OAuth authorization server metadata"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1"
)
response.raise_for_status()
metadata = response.json()
return JSONResponse(metadata)
except Exception as e:
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to fetch PropelAuth metadata: {e}",
},
status_code=500,
)
routes.append(
Route(
"/.well-known/oauth-authorization-server",
endpoint=oauth_authorization_server_metadata,
methods=["GET"],
)
)
return routes
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token and check the ``aud`` claim against the configured resource."""
result = await super().verify_token(token)
if result is None or self._resource is None:
return result
aud = result.claims.get("aud")
if aud != self._resource:
logger.debug(
"PropelAuthProvider: token audience %r does not match resource %s",
aud,
self._resource,
)
return None
return result
def _create_token_verifier(
self,
introspection_url: str,
client_id: str,
client_secret: str | SecretStr,
required_scopes: list[str] | None,
introspection_overrides: PropelAuthTokenIntrospectionOverrides | None,
) -> IntrospectionTokenVerifier:
# Being defensive here, check for only the fields we are expecting
safe_overrides: PropelAuthTokenIntrospectionOverrides = {}
if introspection_overrides is not None:
if "timeout_seconds" in introspection_overrides:
safe_overrides["timeout_seconds"] = introspection_overrides[
"timeout_seconds"
]
if "cache_ttl_seconds" in introspection_overrides:
safe_overrides["cache_ttl_seconds"] = introspection_overrides[
"cache_ttl_seconds"
]
if "max_cache_size" in introspection_overrides:
safe_overrides["max_cache_size"] = introspection_overrides[
"max_cache_size"
]
if "http_client" in introspection_overrides:
safe_overrides["http_client"] = introspection_overrides["http_client"]
return IntrospectionTokenVerifier(
introspection_url=introspection_url,
client_id=client_id,
client_secret=client_secret,
required_scopes=required_scopes,
**safe_overrides,
)
__all__ = ["PropelAuthProvider", "PropelAuthTokenIntrospectionOverrides"]

View file

@ -1,212 +1,20 @@
"""Scalekit authentication provider for FastMCP.
This module provides ScalekitProvider - a complete authentication solution that integrates
with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server
authentication for seamless MCP client authentication.
"""
"""Backward compatibility shim for Scalekit auth provider."""
from __future__ import annotations
import httpx
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
import warnings
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
logger = get_logger(__name__)
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.scalekit is deprecated. "
"Import from fastmcp.server.plugins.auth.scalekit.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider
class ScalekitProvider(RemoteAuthProvider):
"""Scalekit resource server provider for OAuth 2.1 authentication.
This provider implements Scalekit integration using resource server pattern.
FastMCP acts as a protected resource server that validates access tokens issued
by Scalekit's authorization server.
IMPORTANT SETUP REQUIREMENTS:
1. Create an MCP Server in Scalekit Dashboard:
- Go to your [Scalekit Dashboard](https://app.scalekit.com/)
- Navigate to MCP Servers section
- Register a new MCP Server with appropriate scopes
- Ensure the Resource Identifier matches exactly what you configure as MCP URL
- Note the Resource ID
2. Environment Configuration:
- 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
For detailed setup instructions, see:
https://docs.scalekit.com/mcp/overview/
Example:
```python
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
# Create Scalekit resource server provider
scalekit_auth = ScalekitProvider(
environment_url="https://your-env.scalekit.com",
resource_id="sk_resource_...",
base_url="https://your-fastmcp-server.com",
)
# Use with FastMCP
mcp = FastMCP("My App", auth=scalekit_auth)
```
"""
def __init__(
self,
*,
environment_url: AnyHttpUrl | str,
resource_id: str,
base_url: AnyHttpUrl | str | None = None,
mcp_url: AnyHttpUrl | str | None = None,
client_id: str | None = None,
required_scopes: list[str] | None = None,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
token_verifier: TokenVerifier | None = None,
):
"""Initialize Scalekit resource server provider.
Args:
environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com")
resource_id: Your Scalekit resource ID
base_url: Public URL of this FastMCP server (or use mcp_url for backwards compatibility)
mcp_url: Deprecated alias for base_url. Will be removed in a future release.
client_id: Deprecated parameter, no longer required. Will be removed in a future release.
required_scopes: Optional list of scopes that must be present in tokens
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit
"""
# Resolve base_url from mcp_url if needed (backwards compatibility)
resolved_base_url = base_url or mcp_url
if not resolved_base_url:
raise ValueError("Either base_url or mcp_url must be provided")
if mcp_url is not None:
logger.warning(
"ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. "
"Rename it to 'base_url'."
)
if client_id is not None:
logger.warning(
"ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward "
"compatibility and will be removed in a future release."
)
self.environment_url = str(environment_url).rstrip("/")
self.resource_id = resource_id
parsed_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else []
)
self.required_scopes = parsed_scopes
base_url_value = str(resolved_base_url)
logger.debug(
"Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s",
self.environment_url,
self.resource_id,
base_url_value,
self.required_scopes,
)
# Create default JWT verifier if none provided
if token_verifier is None:
logger.debug(
"Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s",
f"{self.environment_url}/keys",
self.environment_url,
self.required_scopes,
)
token_verifier = JWTVerifier(
jwks_uri=f"{self.environment_url}/keys",
issuer=self.environment_url,
algorithm="RS256",
audience=self.resource_id,
required_scopes=self.required_scopes or None,
)
else:
logger.debug("Using custom token verifier for ScalekitProvider")
# Initialize RemoteAuthProvider with Scalekit as the authorization server
super().__init__(
token_verifier=token_verifier,
authorization_servers=[
AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}")
],
base_url=base_url_value,
scopes_supported=scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get OAuth routes including Scalekit authorization server metadata forwarding.
This returns the standard protected resource routes plus an authorization server
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.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path)
logger.debug(
"Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s",
mcp_path,
self.resource_id,
)
async def oauth_authorization_server_metadata(request):
"""Forward Scalekit OAuth authorization server metadata with FastMCP customizations."""
try:
metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}"
logger.debug(
"Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url
)
async with httpx.AsyncClient() as client:
response = await client.get(metadata_url)
response.raise_for_status()
metadata = response.json()
logger.debug(
"Scalekit metadata fetched successfully: metadata_keys=%s",
list(metadata.keys()),
)
return JSONResponse(metadata)
except Exception as e:
logger.error(f"Failed to fetch Scalekit metadata: {e}")
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to fetch Scalekit metadata: {e}",
},
status_code=500,
)
# Add Scalekit authorization server metadata forwarding
routes.append(
Route(
"/.well-known/oauth-authorization-server",
endpoint=oauth_authorization_server_metadata,
methods=["GET"],
)
)
return routes
__all__ = ["ScalekitProvider"]

View file

@ -1,181 +1,20 @@
"""Supabase authentication provider for FastMCP.
This module provides SupabaseProvider - a complete authentication solution that integrates
with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR)
for seamless MCP client authentication.
"""
"""Backward compatibility shim for Supabase auth provider."""
from __future__ import annotations
from typing import Literal
import warnings
import httpx
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.supabase is deprecated. "
"Import from fastmcp.server.plugins.auth.supabase.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider
class SupabaseProvider(RemoteAuthProvider):
"""Supabase metadata provider for DCR (Dynamic Client Registration).
This provider implements Supabase Auth integration using metadata forwarding.
This approach allows Supabase to handle the OAuth flow directly while FastMCP acts
as a resource server, verifying JWTs issued by Supabase Auth.
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")
- Configure your JWT algorithm in Supabase Auth settings (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_route}/.well-known/jwks.json
- JWTs are issued by {project_url}{auth_route}
- Default auth_route is "/auth/v1" (can be customized for self-hosted setups)
- 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
Example:
```python
from fastmcp.server.auth.providers.supabase import SupabaseProvider
# Create Supabase metadata provider (JWT verifier created automatically)
supabase_auth = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="https://your-fastmcp-server.com",
algorithm="ES256", # Match your Supabase Auth configuration
)
# Use with FastMCP
mcp = FastMCP("My App", auth=supabase_auth)
```
"""
def __init__(
self,
*,
project_url: AnyHttpUrl | str,
base_url: AnyHttpUrl | str,
auth_route: str = "/auth/v1",
algorithm: Literal["RS256", "ES256"] = "ES256",
required_scopes: list[str] | None = None,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
token_verifier: TokenVerifier | None = None,
):
"""Initialize Supabase metadata provider.
Args:
project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
base_url: Public URL of this FastMCP server
auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized
for self-hosted Supabase Auth setups using custom routes.
algorithm: JWT signing algorithm (RS256 or ES256). Must match your
Supabase Auth configuration. Defaults to ES256.
required_scopes: Optional list of scopes to require for all requests.
Note: Supabase currently uses RLS policies for authorization. OAuth-level
scopes are an upcoming feature.
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase
"""
self.project_url = str(project_url).rstrip("/")
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
self.auth_route = auth_route.strip("/")
# Parse scopes if provided as string
parsed_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else None
)
# Create default JWT verifier if none provided
if token_verifier is None:
logger.warning(
"SupabaseProvider cannot validate token audience for the specific resource "
"because Supabase Auth does not support RFC 8707 resource indicators. "
"This may leave the server vulnerable to cross-server token replay."
)
token_verifier = JWTVerifier(
jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json",
issuer=f"{self.project_url}/{self.auth_route}",
algorithm=algorithm,
audience="authenticated",
required_scopes=parsed_scopes,
)
# Initialize RemoteAuthProvider with Supabase as the authorization server
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")],
base_url=self.base_url,
scopes_supported=scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get OAuth routes including Supabase authorization server metadata forwarding.
This returns the standard protected resource routes plus an authorization server
metadata endpoint that forwards Supabase'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.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward Supabase OAuth authorization server metadata with FastMCP customizations."""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server"
)
response.raise_for_status()
metadata = response.json()
return JSONResponse(metadata)
except Exception as e:
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to fetch Supabase metadata: {e}",
},
status_code=500,
)
# Add Supabase authorization server metadata forwarding
routes.append(
Route(
"/.well-known/oauth-authorization-server",
endpoint=oauth_authorization_server_metadata,
methods=["GET"],
)
)
return routes
__all__ = ["SupabaseProvider"]

View file

@ -1,428 +1,25 @@
"""WorkOS authentication providers for FastMCP.
This module provides two WorkOS authentication strategies:
1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR)
2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit
Choose based on your WorkOS setup and authentication requirements.
"""
"""Backward compatibility shim for WorkOS auth providers."""
from __future__ import annotations
import contextlib
from typing import Literal
import warnings
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
from fastmcp import settings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
if settings.deprecation_warnings:
warnings.warn(
"fastmcp.server.auth.providers.workos is deprecated. "
"Import from fastmcp.server.plugins.auth.workos.provider or "
"fastmcp.server.plugins.auth.authkit.provider instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
from fastmcp.server.plugins.auth.workos.provider import (
WorkOSProvider,
WorkOSTokenVerifier,
)
class WorkOSTokenVerifier(TokenVerifier):
"""Token verifier for WorkOS OAuth tokens.
WorkOS AuthKit tokens are opaque, so we verify them by calling
the /oauth2/userinfo endpoint to check validity and get user info.
"""
def __init__(
self,
*,
authkit_domain: str,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize the WorkOS token verifier.
Args:
authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
required_scopes: Required OAuth scopes
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.authkit_domain = authkit_domain.rstrip("/")
self.timeout_seconds = timeout_seconds
self._http_client = http_client
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify WorkOS OAuth token by calling userinfo endpoint."""
try:
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Use WorkOS AuthKit userinfo endpoint to validate token
response = await client.get(
f"{self.authkit_domain}/oauth2/userinfo",
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "FastMCP-WorkOS-OAuth",
},
)
if response.status_code != 200:
logger.debug(
"WorkOS token verification failed: %d - %s",
response.status_code,
response.text[:200],
)
return None
user_data = response.json()
token_scopes = (
parse_scopes(user_data.get("scope") or user_data.get("scopes"))
or []
)
if self.required_scopes and not all(
scope in token_scopes for scope in self.required_scopes
):
logger.debug(
"WorkOS token missing required scopes. required=%s actual=%s",
self.required_scopes,
token_scopes,
)
return None
# Create AccessToken with WorkOS user info
return AccessToken(
token=token,
client_id=str(user_data.get("sub", "unknown")),
scopes=token_scopes,
expires_at=None, # Will be set from token introspection if needed
claims={
"sub": user_data.get("sub"),
"email": user_data.get("email"),
"email_verified": user_data.get("email_verified"),
"name": user_data.get("name"),
"given_name": user_data.get("given_name"),
"family_name": user_data.get("family_name"),
},
)
except httpx.RequestError as e:
logger.debug("Failed to verify WorkOS token: %s", e)
return None
except Exception as e:
logger.debug("WorkOS token verification error: %s", e)
return None
class WorkOSProvider(OAuthProxy):
"""Complete WorkOS OAuth provider for FastMCP.
This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern.
It provides OAuth2 authentication for users through WorkOS Connect applications.
Features:
- Transparent OAuth proxy to WorkOS AuthKit
- Automatic token validation via userinfo endpoint
- User information extraction from ID tokens
- Support for standard OAuth scopes (openid, profile, email)
Setup Requirements:
1. Create a WorkOS Connect application in your dashboard
2. Note your AuthKit domain (e.g., "https://your-app.authkit.app")
3. Configure redirect URI as: http://localhost:8000/auth/callback
4. Note your Client ID and Client Secret
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import WorkOSProvider
auth = WorkOSProvider(
client_id="client_123",
client_secret="sk_test_456",
authkit_domain="https://your-app.authkit.app",
base_url="http://localhost:8000"
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
client_id: str,
client_secret: str,
authkit_domain: str,
base_url: AnyHttpUrl | str,
resource_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 = 10,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize WorkOS OAuth provider.
Args:
client_id: WorkOS client ID
client_secret: WorkOS client secret
authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 WorkOS (defaults to "/auth/callback")
required_scopes: Required OAuth scopes (no default)
timeout_seconds: HTTP request timeout for WorkOS API calls (defaults to 10)
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 WorkOS.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
# Apply defaults and ensure authkit_domain is a full URL
authkit_domain_str = authkit_domain
if not authkit_domain_str.startswith(("http://", "https://")):
authkit_domain_str = f"https://{authkit_domain_str}"
authkit_domain_final = authkit_domain_str.rstrip("/")
scopes_final = (
parse_scopes(required_scopes) if required_scopes is not None else []
)
# Create WorkOS token verifier
token_verifier = WorkOSTokenVerifier(
authkit_domain=authkit_domain_final,
required_scopes=scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
)
# Initialize OAuth proxy with WorkOS AuthKit endpoints
super().__init__(
upstream_authorization_endpoint=f"{authkit_domain_final}/oauth2/authorize",
upstream_token_endpoint=f"{authkit_domain_final}/oauth2/token",
upstream_client_id=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
enable_cimd=enable_cimd,
)
logger.debug(
"Initialized WorkOS OAuth provider for client %s with AuthKit domain %s",
client_id,
authkit_domain_final,
)
class AuthKitProvider(RemoteAuthProvider):
"""AuthKit metadata provider for DCR (Dynamic Client Registration).
This provider implements AuthKit integration using metadata forwarding
instead of OAuth proxying. This is the recommended approach for WorkOS DCR
as it allows WorkOS to handle the OAuth flow directly while FastMCP acts
as a resource server.
IMPORTANT SETUP REQUIREMENTS:
1. Enable Dynamic Client Registration in WorkOS Dashboard:
- Go to Applications Configuration
- Toggle "Dynamic Client Registration" to enabled
2. Configure your FastMCP server URL as a callback:
- Add your server URL to the Redirects tab in WorkOS dashboard
- Example: https://your-fastmcp-server.com/oauth2/callback
For detailed setup instructions, see:
https://workos.com/docs/authkit/mcp/integrating/token-verification
Token audience is bound to this server automatically: when the MCP
mount path becomes known (typically at ``http_app()`` construction),
``JWTVerifier.audience`` is set to the resource URL advertised in
``.well-known/oauth-protected-resource``. Enable Resource Indicators
(RFC 8707) in your WorkOS Dashboard and list that same URL AuthKit
will then mint tokens with the matching ``aud`` claim.
Example:
```python
from fastmcp.server.auth.providers.workos import AuthKitProvider
workos_auth = AuthKitProvider(
authkit_domain="https://your-workos-domain.authkit.app",
base_url="https://your-fastmcp-server.com",
)
mcp = FastMCP("My App", auth=workos_auth)
```
"""
def __init__(
self,
*,
authkit_domain: AnyHttpUrl | str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
token_verifier: TokenVerifier | None = None,
):
"""Initialize AuthKit metadata provider.
Args:
authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app")
base_url: Public URL of this FastMCP server
resource_base_url: Optional public base URL for the protected resource.
When provided, this URL is advertised in protected resource metadata
instead of ``base_url``. Useful when OAuth callbacks and the protected
MCP resource live under different public URLs.
required_scopes: Optional list of scopes to require for all requests
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
token_verifier: Optional token verifier. If provided, it is used as-is and
audience auto-wiring is skipped the caller is responsible for setting
an appropriate ``audience``. If None (default), a ``JWTVerifier`` is
created with audience bound to this server's resource URL.
"""
self.authkit_domain = str(authkit_domain).rstrip("/")
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
# Parse scopes if provided as string
parsed_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else None
)
# When no custom verifier is provided, we own the JWTVerifier and can
# bind its audience to our resource URL once set_mcp_path() is called.
self._auto_bind_audience = token_verifier is None
if token_verifier is None:
token_verifier = JWTVerifier(
jwks_uri=f"{self.authkit_domain}/oauth2/jwks",
issuer=self.authkit_domain,
algorithm="RS256",
required_scopes=parsed_scopes,
)
# Initialize RemoteAuthProvider with AuthKit as the authorization server
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(self.authkit_domain)],
base_url=self.base_url,
resource_base_url=resource_base_url,
scopes_supported=scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
def set_mcp_path(self, mcp_path: str | None) -> None:
"""Bind the default verifier's audience to this server's resource URL.
AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud``
claim equals the resource URL the client requested which is the URL
we advertise in ``.well-known/oauth-protected-resource``. Binding the
audience here keeps validation in lock-step with what clients are sent.
"""
super().set_mcp_path(mcp_path)
if (
self._auto_bind_audience
and self._resource_url is not None
and isinstance(self.token_verifier, JWTVerifier)
):
resource_url = str(self._resource_url)
self.token_verifier.audience = resource_url
logger.info(
"AuthKit tokens will be validated against aud=%s. "
"Configure this URL as a Resource Indicator in the WorkOS Dashboard.",
resource_url,
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get OAuth routes including AuthKit authorization server metadata forwarding.
This returns the standard protected resource routes plus an authorization server
metadata endpoint that forwards AuthKit'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.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.authkit_domain}/.well-known/oauth-authorization-server"
)
response.raise_for_status()
metadata = response.json()
return JSONResponse(metadata)
except Exception as e:
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to fetch AuthKit metadata: {e}",
},
status_code=500,
)
# Add AuthKit authorization server metadata forwarding
routes.append(
Route(
"/.well-known/oauth-authorization-server",
endpoint=oauth_authorization_server_metadata,
methods=["GET"],
)
)
return routes
__all__ = ["AuthKitProvider", "WorkOSProvider", "WorkOSTokenVerifier"]

View file

@ -220,6 +220,10 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
)
capabilities.extensions = {**existing_extensions, UI_EXTENSION_ID: {}}
# Plugin contributions apply last so plugins can override built-in
# defaults. See FastMCP._apply_plugin_capabilities for merge rules.
capabilities = self.fastmcp._apply_plugin_capabilities(capabilities)
return capabilities
async def run(

View file

@ -171,6 +171,16 @@ class LifespanMixin:
self._lifespan_result = user_lifespan_result
self._lifespan_result_set = True
# Plugin runtime pass: each registered plugin's `run()` async
# context manager wraps the server's lifespan. Contributions
# were already installed at add_plugin() time, so this only
# enters async runtime work before provider lifespans and
# `_started`. Partial-failure safety is automatic —
# AsyncExitStack only unwinds plugin contexts that were
# successfully entered, so a raising plugin doesn't tear down
# plugins that never entered.
await self._enter_plugin_contexts(stack)
# Start lifespans for all providers
for provider in self.providers:
await stack.enter_async_context(provider.lifespan())

View file

@ -333,7 +333,6 @@ class TransportMixin:
Returns:
A Starlette application configured with the specified transport
"""
if transport in ("streamable-http", "http"):
return create_streamable_http_app(
server=self,

View file

@ -1,12 +1,12 @@
"""OpenAPI server implementation for FastMCP.
.. deprecated::
This module is deprecated. Import from fastmcp.server.providers.openapi instead.
This module is deprecated. Import from fastmcp.server.plugins.openapi instead.
The recommended approach is to use OpenAPIProvider with FastMCP:
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")
@ -24,20 +24,26 @@ from fastmcp.exceptions import FastMCPDeprecationWarning
warnings.warn(
"fastmcp.server.openapi is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
# Re-export from new canonical location
from fastmcp.server.providers.openapi import ( # noqa: E402
ComponentFn as ComponentFn,
from fastmcp.server.plugins.openapi import ( # noqa: E402
MCPType as MCPType,
OpenAPIProvider as OpenAPIProvider,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.components import ( # noqa: E402
OpenAPIResource as OpenAPIResource,
OpenAPIResourceTemplate as OpenAPIResourceTemplate,
OpenAPITool as OpenAPITool,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.provider import ( # noqa: E402
OpenAPIProvider as OpenAPIProvider,
)
from fastmcp.server.plugins.openapi.routing import ( # noqa: E402
ComponentFn as ComponentFn,
RouteMapFn as RouteMapFn,
)

View file

@ -1,6 +1,6 @@
"""OpenAPI component implementations - backwards compatibility stub.
This module is deprecated. Import from fastmcp.server.providers.openapi instead.
This module is deprecated. Import from fastmcp.server.plugins.openapi instead.
"""
from __future__ import annotations
@ -11,12 +11,12 @@ from fastmcp.exceptions import FastMCPDeprecationWarning
warnings.warn(
"fastmcp.server.openapi.components is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
from fastmcp.server.providers.openapi import ( # noqa: E402
from fastmcp.server.plugins.openapi.components import ( # noqa: E402
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,

View file

@ -22,27 +22,27 @@ __all__ = [
warnings.warn(
"fastmcp.server.openapi.routing is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
# Re-export from new canonical location
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
ComponentFn as ComponentFn,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
MCPType as MCPType,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
RouteMap as RouteMap,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
RouteMapFn as RouteMapFn,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
_determine_route_type as _determine_route_type,
)

View file

@ -3,7 +3,7 @@
This class is deprecated. Use FastMCP with OpenAPIProvider instead:
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")
@ -19,12 +19,9 @@ from typing import Any
import httpx
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.providers.openapi import (
ComponentFn,
OpenAPIProvider,
RouteMap,
RouteMapFn,
)
from fastmcp.server.plugins.openapi import RouteMap
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
from fastmcp.server.plugins.openapi.routing import ComponentFn, RouteMapFn
from fastmcp.server.server import FastMCP
@ -49,7 +46,7 @@ class FastMCPOpenAPI(FastMCP):
New approach:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")

View file

@ -0,0 +1,15 @@
"""FastMCP plugin primitive.
Plugins are reusable, configurable units that contribute middleware,
transforms, providers, and custom HTTP routes to a FastMCP server. See
the design document for the full specification.
Only the two user-facing primitives are re-exported here: `Plugin`
(subclass to define a plugin) and `PluginMeta` (the metadata model
plugins instantiate). Error classes live in `fastmcp.server.plugins.base`
and can be imported from there if needed.
"""
from fastmcp.server.plugins.base import Plugin, PluginMeta
__all__ = ["Plugin", "PluginMeta"]

View file

@ -0,0 +1,3 @@
"""Auth plugin namespace for FastMCP."""
__all__: list[str] = []

View file

@ -0,0 +1,65 @@
"""Shared primitives for first-party auth plugins."""
from __future__ import annotations
from typing import Any, Generic, Literal, TypeVar
from pydantic import AnyHttpUrl, BaseModel, ConfigDict
from fastmcp.server.plugins.base import Plugin
ConsentMode = bool | Literal["remember", "external"]
Algorithm = Literal["RS256", "ES256"]
ConfigT = TypeVar("ConfigT", bound=BaseModel)
class AuthPlugin(Plugin[ConfigT], Generic[ConfigT]):
def _require(self, *fields: str) -> None:
missing = [field for field in fields if getattr(self.config, field) is None]
if missing:
names = ", ".join(f"`{field}`" for field in missing)
raise ValueError(f"{type(self).__name__} requires {names}.")
def _require_one(self, *fields: str) -> None:
if not any(getattr(self.config, field) is not None for field in fields):
names = " or ".join(f"`{field}`" for field in fields)
raise ValueError(f"{type(self).__name__} requires {names}.")
def _kwargs(self, *fields: str) -> dict[str, Any]:
return {
field: getattr(self.config, field)
for field in fields
if getattr(self.config, field) is not None
}
class PluginConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
class OAuthProxyConfig(PluginConfig):
base_url: AnyHttpUrl | str | None = None
resource_base_url: AnyHttpUrl | str | None = None
issuer_url: AnyHttpUrl | str | None = None
redirect_path: str | None = None
required_scopes: list[str] | None = None
allowed_client_redirect_uris: list[str] | None = None
jwt_signing_key: str | None = None
require_authorization_consent: ConsentMode = True
consent_csp_policy: str | None = None
forward_resource: bool = True
class OAuthProviderConfig(OAuthProxyConfig):
client_id: str | None = None
client_secret: str | None = None
timeout_seconds: int = 10
enable_cimd: bool = True
class RemoteAuthConfig(PluginConfig):
base_url: AnyHttpUrl | str | None = None
required_scopes: list[str] | None = None
scopes_supported: list[str] | None = None
resource_name: str | None = None
resource_documentation: AnyHttpUrl | None = None

View file

@ -0,0 +1,5 @@
"""Auth0 auth plugin."""
from fastmcp.server.plugins.auth.auth0.plugin import Auth0Auth
__all__ = ["Auth0Auth"]

View file

@ -0,0 +1,63 @@
"""Auth0 auth plugin."""
from __future__ import annotations
from typing import Any, ClassVar
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp.server.auth import AuthProvider
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
from fastmcp.server.plugins.base import PluginMeta
class Auth0AuthConfig(OAuthProxyConfig):
"""Config model for the Auth0 auth plugin."""
config_url: AnyHttpUrl | str | None = None
client_id: str | None = None
client_secret: str | None = None
audience: str | None = None
class Auth0Auth(AuthPlugin[Auth0AuthConfig]):
"""Contribute an `Auth0Provider` as the server's auth provider."""
Config: ClassVar[type[Auth0AuthConfig]] = Auth0AuthConfig
meta = PluginMeta(name="auth0-auth")
def __init__(
self,
config: Auth0AuthConfig | dict[str, Any] | None = None,
*,
client_storage: AsyncKeyValue | None = None,
) -> None:
super().__init__(config)
self._client_storage = client_storage
def auth(self) -> AuthProvider | None:
self._require(
"config_url", "client_id", "client_secret", "audience", "base_url"
)
return Auth0Provider(
**self._kwargs(
"config_url",
"client_id",
"client_secret",
"audience",
"base_url",
"resource_base_url",
"issuer_url",
"required_scopes",
"redirect_path",
"allowed_client_redirect_uris",
"jwt_signing_key",
"require_authorization_consent",
"consent_csp_policy",
"forward_resource",
),
client_storage=self._client_storage,
)

View file

@ -0,0 +1,135 @@
"""Auth0 OAuth provider for FastMCP.
This module provides a complete Auth0 integration that's ready to use with
just the configuration URL, client ID, client secret, audience, and base URL.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
# Simple Auth0 OAuth protection
auth = Auth0Provider(
config_url="https://auth0.config.url",
client_id="your-auth0-client-id",
client_secret="your-auth0-client-secret",
audience="your-auth0-api-audience",
base_url="http://localhost:8000",
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
from typing import Literal
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp.server.auth.oidc_proxy import OIDCProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class Auth0Provider(OIDCProxy):
"""An Auth0 provider implementation for FastMCP.
This provider is a complete Auth0 integration that's ready to use with
just the configuration URL, client ID, client secret, audience, and base URL.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
# Simple Auth0 OAuth protection
auth = Auth0Provider(
config_url="https://auth0.config.url",
client_id="your-auth0-client-id",
client_secret="your-auth0-client-secret",
audience="your-auth0-api-audience",
base_url="http://localhost:8000",
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
def __init__(
self,
*,
config_url: AnyHttpUrl | str,
client_id: str,
client_secret: str,
audience: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
redirect_path: str | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
) -> None:
"""Initialize Auth0 OAuth provider.
Args:
config_url: Auth0 config URL
client_id: Auth0 application client id
client_secret: Auth0 application client secret
audience: Auth0 API audience
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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.
required_scopes: Required Auth0 scopes (defaults to ["openid"])
redirect_path: Redirect path configured in Auth0 application
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 Auth0.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
"""
# Parse scopes if provided as string
auth0_required_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
)
super().__init__(
config_url=config_url,
client_id=client_id,
client_secret=client_secret,
audience=audience,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
required_scopes=auth0_required_scopes,
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
)
logger.debug(
"Initialized Auth0 OAuth provider for client %s with scopes: %s",
client_id,
auth0_required_scopes,
)

View file

@ -0,0 +1,5 @@
"""WorkOS AuthKit auth plugin."""
from fastmcp.server.plugins.auth.authkit.plugin import AuthKitAuth
__all__ = ["AuthKitAuth"]

View file

@ -0,0 +1,51 @@
"""WorkOS AuthKit auth plugin."""
from __future__ import annotations
from typing import Any, ClassVar
from pydantic import AnyHttpUrl
from fastmcp.server.auth import AuthProvider, TokenVerifier
from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
from fastmcp.server.plugins.base import PluginMeta
class AuthKitAuthConfig(RemoteAuthConfig):
"""Config model for the WorkOS AuthKit auth plugin."""
authkit_domain: AnyHttpUrl | str | None = None
resource_base_url: AnyHttpUrl | str | None = None
class AuthKitAuth(AuthPlugin[AuthKitAuthConfig]):
"""Contribute an `AuthKitProvider` as the server's auth provider."""
Config: ClassVar[type[AuthKitAuthConfig]] = AuthKitAuthConfig
meta = PluginMeta(name="authkit-auth")
def __init__(
self,
config: AuthKitAuthConfig | dict[str, Any] | None = None,
*,
token_verifier: TokenVerifier | None = None,
) -> None:
super().__init__(config)
self._token_verifier = token_verifier
def auth(self) -> AuthProvider | None:
self._require("authkit_domain", "base_url")
return AuthKitProvider(
**self._kwargs(
"authkit_domain",
"base_url",
"resource_base_url",
"required_scopes",
"scopes_supported",
"resource_name",
"resource_documentation",
),
token_verifier=self._token_verifier,
)

View file

@ -0,0 +1,186 @@
"""WorkOS AuthKit provider."""
from __future__ import annotations
import httpx
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class AuthKitProvider(RemoteAuthProvider):
"""AuthKit metadata provider for DCR (Dynamic Client Registration).
This provider implements AuthKit integration using metadata forwarding
instead of OAuth proxying. This is the recommended approach for WorkOS DCR
as it allows WorkOS to handle the OAuth flow directly while FastMCP acts
as a resource server.
IMPORTANT SETUP REQUIREMENTS:
1. Enable Dynamic Client Registration in WorkOS Dashboard:
- Go to Applications -> Configuration
- Toggle "Dynamic Client Registration" to enabled
2. Configure your FastMCP server URL as a callback:
- Add your server URL to the Redirects tab in WorkOS dashboard
- Example: https://your-fastmcp-server.com/oauth2/callback
For detailed setup instructions, see:
https://workos.com/docs/authkit/mcp/integrating/token-verification
Token audience is bound to this server automatically: when the MCP
mount path becomes known (typically at ``http_app()`` construction),
``JWTVerifier.audience`` is set to the resource URL advertised in
``.well-known/oauth-protected-resource``. Enable Resource Indicators
(RFC 8707) in your WorkOS Dashboard and list that same URL AuthKit
will then mint tokens with the matching ``aud`` claim.
Example:
```python
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
workos_auth = AuthKitProvider(
authkit_domain="https://your-workos-domain.authkit.app",
base_url="https://your-fastmcp-server.com",
)
mcp = FastMCP("My App", auth=workos_auth)
```
"""
def __init__(
self,
*,
authkit_domain: AnyHttpUrl | str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
token_verifier: TokenVerifier | None = None,
):
"""Initialize AuthKit metadata provider.
Args:
authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app")
base_url: Public URL of this FastMCP server
resource_base_url: Optional public base URL for the protected resource.
When provided, this URL is advertised in protected resource metadata
instead of ``base_url``. Useful when OAuth callbacks and the protected
MCP resource live under different public URLs.
required_scopes: Optional list of scopes to require for all requests
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
token_verifier: Optional token verifier. If provided, it is used as-is and
audience auto-wiring is skipped the caller is responsible for setting
an appropriate ``audience``. If None (default), a ``JWTVerifier`` is
created with audience bound to this server's resource URL.
"""
self.authkit_domain = str(authkit_domain).rstrip("/")
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
parsed_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else None
)
# When no custom verifier is provided, we own the JWTVerifier and can
# bind its audience to our resource URL once set_mcp_path() is called.
self._auto_bind_audience = token_verifier is None
if token_verifier is None:
token_verifier = JWTVerifier(
jwks_uri=f"{self.authkit_domain}/oauth2/jwks",
issuer=self.authkit_domain,
algorithm="RS256",
required_scopes=parsed_scopes,
)
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(self.authkit_domain)],
base_url=self.base_url,
resource_base_url=resource_base_url,
scopes_supported=scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
def set_mcp_path(self, mcp_path: str | None) -> None:
"""Bind the default verifier's audience to this server's resource URL.
AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud``
claim equals the resource URL the client requested which is the URL
we advertise in ``.well-known/oauth-protected-resource``. Binding the
audience here keeps validation in lock-step with what clients are sent.
"""
super().set_mcp_path(mcp_path)
if (
self._auto_bind_audience
and self._resource_url is not None
and isinstance(self.token_verifier, JWTVerifier)
):
resource_url = str(self._resource_url)
self.token_verifier.audience = resource_url
logger.info(
"AuthKit tokens will be validated against aud=%s. "
"Configure this URL as a Resource Indicator in the WorkOS Dashboard.",
resource_url,
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get OAuth routes including AuthKit authorization server metadata forwarding.
This returns the standard protected resource routes plus an authorization server
metadata endpoint that forwards AuthKit'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.
"""
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.authkit_domain}/.well-known/oauth-authorization-server"
)
response.raise_for_status()
metadata = response.json()
return JSONResponse(metadata)
except Exception as e:
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to fetch AuthKit metadata: {e}",
},
status_code=500,
)
routes.append(
Route(
"/.well-known/oauth-authorization-server",
endpoint=oauth_authorization_server_metadata,
methods=["GET"],
)
)
return routes
__all__ = ["AuthKitProvider"]

View file

@ -0,0 +1,5 @@
"""AWS Cognito auth plugin."""
from fastmcp.server.plugins.auth.aws.plugin import AWSCognitoAuth
__all__ = ["AWSCognitoAuth"]

View file

@ -0,0 +1,61 @@
"""AWS Cognito auth plugin."""
from __future__ import annotations
from typing import Any, ClassVar
from key_value.aio.protocols import AsyncKeyValue
from fastmcp.server.auth import AuthProvider
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
from fastmcp.server.plugins.base import PluginMeta
class AWSCognitoAuthConfig(OAuthProxyConfig):
"""Config model for the AWS Cognito auth plugin."""
user_pool_id: str | None = None
client_id: str | None = None
client_secret: str | None = None
aws_region: str = "eu-central-1"
redirect_path: str | None = "/auth/callback"
class AWSCognitoAuth(AuthPlugin[AWSCognitoAuthConfig]):
"""Contribute an `AWSCognitoProvider` as the server's auth provider."""
Config: ClassVar[type[AWSCognitoAuthConfig]] = AWSCognitoAuthConfig
meta = PluginMeta(name="aws-cognito-auth")
def __init__(
self,
config: AWSCognitoAuthConfig | dict[str, Any] | None = None,
*,
client_storage: AsyncKeyValue | None = None,
) -> None:
super().__init__(config)
self._client_storage = client_storage
def auth(self) -> AuthProvider | None:
self._require("user_pool_id", "client_id", "client_secret", "base_url")
return AWSCognitoProvider(
**self._kwargs(
"user_pool_id",
"client_id",
"client_secret",
"base_url",
"resource_base_url",
"aws_region",
"issuer_url",
"redirect_path",
"required_scopes",
"allowed_client_redirect_uris",
"jwt_signing_key",
"require_authorization_consent",
"consent_csp_policy",
"forward_resource",
),
client_storage=self._client_storage,
)

View file

@ -0,0 +1,229 @@
"""AWS Cognito OAuth provider for FastMCP.
This module provides a complete AWS Cognito OAuth integration that's ready to use
with a user pool ID, domain prefix, client ID and client secret. It handles all
the complexity of AWS Cognito's OAuth flow, token validation, and user management.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
# Simple AWS Cognito OAuth protection
auth = AWSCognitoProvider(
user_pool_id="your-user-pool-id",
aws_region="eu-central-1",
client_id="your-cognito-client-id",
client_secret="your-cognito-client-secret"
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
from __future__ import annotations
from typing import Literal
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.oidc_proxy import OIDCProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class AWSCognitoTokenVerifier(JWTVerifier):
"""Token verifier for Cognito access tokens.
Cognito access tokens use a ``client_id`` claim instead of the
standard ``aud`` claim. This subclass passes ``audience=None``
to the parent (skipping the ``aud`` check) and validates the
``client_id`` claim directly.
"""
def __init__(self, *, audience: str | list[str] | None = None, **kwargs):
self._expected_client_id = audience
super().__init__(audience=None, **kwargs)
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token and filter claims to Cognito-specific subset."""
access_token = await super().verify_token(token)
if not access_token:
return None
# Validate client_id claim (Cognito's equivalent of aud)
if self._expected_client_id:
token_client_id = access_token.claims.get("client_id")
if isinstance(self._expected_client_id, list):
valid = token_client_id in self._expected_client_id
else:
valid = token_client_id == self._expected_client_id
if not valid:
self.logger.debug(
"Token validation failed: client_id mismatch (expected %s, got %s)",
self._expected_client_id,
token_client_id,
)
return None
# Filter claims to Cognito-specific subset
cognito_claims = {
"sub": access_token.claims.get("sub"),
"username": access_token.claims.get("username"),
"cognito:groups": access_token.claims.get("cognito:groups", []),
}
return AccessToken(
token=access_token.token,
client_id=access_token.client_id,
scopes=access_token.scopes,
expires_at=access_token.expires_at,
claims=cognito_claims,
)
class AWSCognitoProvider(OIDCProxy):
"""Complete AWS Cognito OAuth provider for FastMCP.
This provider makes it trivial to add AWS Cognito OAuth protection to any
FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details,
client credentials, and a base URL, and you're ready to go.
Features:
- Automatic OIDC Discovery from AWS Cognito User Pool
- Automatic JWT token validation via Cognito's public keys
- Cognito-specific claim filtering (sub, username, cognito:groups)
- Support for Cognito User Pools
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
auth = AWSCognitoProvider(
user_pool_id="eu-central-1_XXXXXXXXX",
aws_region="eu-central-1",
client_id="your-cognito-client-id",
client_secret="your-cognito-client-secret",
base_url="https://my-server.com",
redirect_path="/custom/callback",
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
user_pool_id: str,
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
aws_region: str = "eu-central-1",
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str = "/auth/callback",
required_scopes: list[str] | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
):
"""Initialize AWS Cognito OAuth provider.
Args:
user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX")
client_id: Cognito app client ID
client_secret: Cognito app client secret
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
aws_region: AWS region where your User Pool is located (defaults to "eu-central-1")
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 Cognito app (defaults to "/auth/callback")
required_scopes: Required Cognito scopes (defaults to ["openid"])
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 AWS Cognito.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
"""
# Parse scopes if provided as string
required_scopes_final = (
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
)
# Construct OIDC discovery URL
config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration"
# Store Cognito-specific info for claim filtering
self.user_pool_id = user_pool_id
self.aws_region = aws_region
self.client_id = client_id
# Initialize OIDC proxy with Cognito discovery
super().__init__(
config_url=config_url,
client_id=client_id,
client_secret=client_secret,
algorithm="RS256",
required_scopes=required_scopes_final,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
)
logger.debug(
"Initialized AWS Cognito OAuth provider for client %s with scopes: %s",
client_id,
required_scopes_final,
)
def get_token_verifier(
self,
*,
algorithm: str | None = None,
audience: str | None = None,
required_scopes: list[str] | None = None,
timeout_seconds: int | None = None,
) -> AWSCognitoTokenVerifier:
"""Creates a Cognito-specific token verifier with claim filtering.
Args:
algorithm: Optional token verifier algorithm
audience: Optional token verifier audience
required_scopes: Optional token verifier required_scopes
timeout_seconds: HTTP request timeout in seconds
"""
return AWSCognitoTokenVerifier(
issuer=str(self.oidc_config.issuer),
audience=audience or self.client_id,
algorithm=algorithm,
jwks_uri=str(self.oidc_config.jwks_uri),
required_scopes=required_scopes,
)

View file

@ -0,0 +1,5 @@
"""Azure auth plugin."""
from fastmcp.server.plugins.auth.azure.plugin import AzureAuth
__all__ = ["AzureAuth"]

View file

@ -0,0 +1,69 @@
"""Azure auth plugin."""
from __future__ import annotations
from typing import Any, ClassVar
import httpx
from key_value.aio.protocols import AsyncKeyValue
from fastmcp.server.auth import AuthProvider
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
from fastmcp.server.plugins.base import PluginMeta
class AzureAuthConfig(OAuthProviderConfig):
"""Config model for the Azure auth plugin."""
tenant_id: str | None = None
required_scopes: list[str] | None = None
identifier_uri: str | None = None
additional_authorize_scopes: list[str] | None = None
base_authority: str = "login.microsoftonline.com"
class AzureAuth(AuthPlugin[AzureAuthConfig]):
"""Contribute an `AzureProvider` as the server's auth provider."""
Config: ClassVar[type[AzureAuthConfig]] = AzureAuthConfig
meta = PluginMeta(name="azure-auth")
def __init__(
self,
config: AzureAuthConfig | dict[str, Any] | None = None,
*,
client_storage: AsyncKeyValue | None = None,
http_client: httpx.AsyncClient | None = None,
) -> None:
super().__init__(config)
self._client_storage = client_storage
self._http_client = http_client
def auth(self) -> AuthProvider | None:
self._require("client_id", "tenant_id", "required_scopes", "base_url")
self._require_one("client_secret", "jwt_signing_key")
return AzureProvider(
**self._kwargs(
"client_id",
"client_secret",
"tenant_id",
"required_scopes",
"base_url",
"resource_base_url",
"identifier_uri",
"issuer_url",
"redirect_path",
"additional_authorize_scopes",
"allowed_client_redirect_uris",
"jwt_signing_key",
"require_authorization_consent",
"consent_csp_policy",
"forward_resource",
"base_authority",
"enable_cimd",
),
client_storage=self._client_storage,
http_client=self._http_client,
)

View file

@ -0,0 +1,768 @@
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
This provider implements Azure/Microsoft Entra ID OAuth authentication
using the OAuth Proxy pattern for non-DCR OAuth flows.
"""
from __future__ import annotations
import hashlib
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Literal, cast
import httpx
from key_value.aio.protocols import AsyncKeyValue
from fastmcp.dependencies import Dependency
from fastmcp.server.auth.auth import MultiAuth
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from azure.identity.aio import OnBehalfOfCredential
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyHttpUrl
from fastmcp.server.auth.auth import AuthProvider
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 AzureProvider(OAuthProxy):
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
This provider implements Azure/Microsoft Entra ID authentication using the
OAuth Proxy pattern. It supports both organizational accounts and personal
Microsoft accounts depending on the tenant configuration.
Scope Handling:
- required_scopes: Provide unprefixed scope names (e.g., ["read", "write"])
Automatically prefixed with identifier_uri during initialization
Validated on all tokens and advertised to MCP clients
- additional_authorize_scopes: Provide full format (e.g., ["User.Read"])
NOT prefixed, NOT validated, NOT advertised to clients
Used to request Microsoft Graph or other upstream API permissions
Features:
- OAuth proxy to Azure/Microsoft identity platform
- JWT validation using tenant issuer and JWKS
- Supports tenant configurations: specific tenant ID, "organizations", or "consumers"
- Custom API scopes and Microsoft Graph scopes in a single provider
Setup:
1. Create an App registration in Azure Portal
2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path)
3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id})
4. Add custom scopes (e.g., "read", "write") under "Expose an API"
5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2
6. Create a client secret
7. Get Application (client) ID, Directory (tenant) ID, and client secret
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
# Standard Azure (Public Cloud)
auth = AzureProvider(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
required_scopes=["read", "write"], # Unprefixed scope names
additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes
base_url="http://localhost:8000",
# identifier_uri defaults to api://{client_id}
)
# Azure Government
auth_gov = AzureProvider(
client_id="your-client-id",
client_secret="your-client-secret",
tenant_id="your-tenant-id",
required_scopes=["read", "write"],
base_authority="login.microsoftonline.us", # Override for Azure Gov
base_url="http://localhost:8000",
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
client_id: str,
client_secret: str | None = None,
tenant_id: str,
required_scopes: list[str],
base_url: str,
resource_base_url: AnyHttpUrl | str | None = None,
identifier_uri: str | None = None,
issuer_url: str | None = None,
redirect_path: str | None = None,
additional_authorize_scopes: list[str] | None = None,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
base_authority: str = "login.microsoftonline.com",
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
) -> None:
"""Initialize Azure OAuth provider.
Args:
client_id: Azure application (client) ID from your App registration
client_secret: Azure client secret from your App registration. Optional when
using alternative credentials (e.g., managed identity with a custom
_create_upstream_oauth_client override). When omitted, jwt_signing_key
must be provided.
tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers")
identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}).
This URI is automatically prefixed to all required_scopes during initialization.
Example: identifier_uri="api://my-api" + required_scopes=["read"]
tokens validated for "api://my-api/read"
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 Azure App registration (defaults to "/auth/callback")
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
For Azure Government, use "login.microsoftonline.us".
required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]).
- Automatically prefixed with identifier_uri during initialization
- Validated on all tokens
- Advertised in Protected Resource Metadata
- Must match scope names defined in Azure Portal under "Expose an API"
Example: ["read", "write"] validates tokens containing ["api://xxx/read", "api://xxx/write"]
additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format.
- NOT prefixed with identifier_uri
- NOT validated on tokens
- NOT advertised to MCP clients
- Used to request additional permissions from Azure (e.g., Graph API access)
Example: ["User.Read", "Mail.Read"]
These scopes allow your FastMCP server to call Microsoft Graph APIs using the
upstream Azure token, but MCP clients are unaware of them.
Note: "offline_access" is automatically included to obtain refresh tokens.
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, an encrypted file store will be created in the data directory
(derived from `platformdirs`).
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 Azure.
When False, authorization proceeds directly without user confirmation.
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches.
When provided, the client is reused for JWT key fetches and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
parsed_required_scopes = parse_scopes(required_scopes)
parsed_additional_scopes: list[str] = (
parse_scopes(additional_authorize_scopes) or []
if additional_authorize_scopes
else []
)
# Always include offline_access to get refresh tokens from Azure
if "offline_access" not in parsed_additional_scopes:
parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"]
# Store Azure-specific config for OBO credential creation
self._tenant_id = tenant_id
self._base_authority = base_authority
# Cache of OBO credentials keyed by hash of user assertion token.
# Reusing credentials allows the Azure SDK's internal token cache
# to avoid redundant OBO exchanges for the same user + scopes.
self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict()
self._obo_max_credentials: int = 128
# Apply defaults
self.identifier_uri = identifier_uri or f"api://{client_id}"
self.additional_authorize_scopes: list[str] = parsed_additional_scopes
# Always validate tokens against the app's API client ID using JWT
issuer = f"https://{base_authority}/{tenant_id}/v2.0"
jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys"
# 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 = [
s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES
]
if not validation_scopes:
raise ValueError(
"AzureProvider requires at least one non-OIDC scope in "
"required_scopes (e.g., 'read', 'write'). OIDC scopes like "
"'openid', 'profile', 'email', and 'offline_access' are not "
"included in Azure access token claims and cannot be used for "
"scope enforcement."
)
token_verifier = JWTVerifier(
jwks_uri=jwks_uri,
issuer=issuer,
audience=[client_id, self.identifier_uri],
algorithm="RS256",
required_scopes=validation_scopes, # Only validate non-OIDC scopes
http_client=http_client,
)
# Build Azure OAuth endpoints with tenant
authorization_endpoint = (
f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize"
)
token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token"
# Initialize OAuth proxy with Azure endpoints
# Remember there's hooks called, such as _prepare_scopes_for_token_exchange
# and _prepare_scopes_for_upstream_refresh
super().__init__(
upstream_authorization_endpoint=authorization_endpoint,
upstream_token_endpoint=token_endpoint,
upstream_client_id=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
valid_scopes=parsed_required_scopes,
enable_cimd=enable_cimd,
)
authority_info = ""
if base_authority != "login.microsoftonline.com":
authority_info = f" using authority {base_authority}"
logger.info(
"Initialized Azure OAuth provider for client %s with tenant %s%s%s",
client_id,
tenant_id,
f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
authority_info,
)
async def authorize(
self,
client: OAuthClientInformationFull,
params: AuthorizationParams,
) -> str:
"""Start OAuth transaction and redirect to Azure AD.
Override parent's authorize method to filter out the 'resource' parameter
which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use
scopes to determine the resource/audience instead of a separate parameter.
Args:
client: OAuth client information
params: Authorization parameters from the client
Returns:
Authorization URL to redirect the user to Azure AD
"""
# Clear the resource parameter that Azure AD v2.0 doesn't support
# This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators)
# but Azure AD v2.0 uses scopes instead to determine the audience
params_to_use = params
if hasattr(params, "resource"):
original_resource = getattr(params, "resource", None)
if original_resource is not None:
params_to_use = params.model_copy(update={"resource": None})
if original_resource:
logger.debug(
"Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
original_resource,
)
# Don't modify the scopes in params - they stay unprefixed for MCP clients
# We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url)
auth_url = await super().authorize(client, params_to_use)
separator = "&" if "?" in auth_url else "?"
return f"{auth_url}{separator}prompt=select_account"
def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
"""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
Returns:
List of scopes with identifier_uri prefix applied where needed
"""
prefixed = []
for scope in scopes:
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 custom API scope - prefix with identifier_uri
prefixed.append(f"{self.identifier_uri}/{scope}")
return prefixed
def _build_upstream_authorize_url(
self, txn_id: str, transaction: dict[str, Any]
) -> str:
"""Build Azure authorization URL with prefixed scopes.
Overrides parent to prefix scopes with identifier_uri before sending to Azure,
while keeping unprefixed scopes in the transaction for MCP clients.
"""
# Get unprefixed scopes from transaction
unprefixed_scopes = transaction.get("scopes") or self.required_scopes or []
# Prefix scopes for Azure authorization request
prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes)
# Add Microsoft Graph scopes (not validated, not prefixed)
if self.additional_authorize_scopes:
prefixed_scopes.extend(self.additional_authorize_scopes)
# Temporarily modify transaction dict for parent's URL building
modified_transaction = transaction.copy()
modified_transaction["scopes"] = prefixed_scopes
# Let parent build the URL with prefixed scopes
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]:
"""Prepare scopes for Azure authorization code exchange.
Azure requires scopes during token exchange (AADSTS28003 error if missing).
Azure only allows ONE resource per token request (AADSTS28000), so we only
include scopes for this API plus OIDC scopes.
Args:
scopes: Scopes from the authorization request (unprefixed)
Returns:
List of scopes for Azure token endpoint
"""
# Prefix scopes for this API
prefixed_scopes = self._prefix_scopes_for_azure(scopes or [])
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
if self.additional_authorize_scopes:
prefixed_scopes.extend(
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
)
deduplicated = list(dict.fromkeys(prefixed_scopes))
logger.debug("Token exchange scopes: %s", deduplicated)
return deduplicated
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
"""Prepare scopes for Azure token refresh.
Azure requires fully-qualified scopes and only allows ONE resource per
token request (AADSTS28000). We include scopes for this API plus OIDC scopes.
Args:
scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
Returns:
Deduplicated list of scopes formatted for Azure token endpoint
"""
logger.debug("Base scopes from storage: %s", scopes)
# Filter out any additional_authorize_scopes that may have been stored
additional_scopes_set = set(self.additional_authorize_scopes or [])
base_scopes = [s for s in scopes if s not in additional_scopes_set]
# Prefix base scopes with identifier_uri for Azure
prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
if self.additional_authorize_scopes:
prefixed_scopes.extend(
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
)
deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
return deduplicated_scopes
async def _extract_upstream_claims(
self, idp_tokens: dict[str, Any]
) -> dict[str, Any] | None:
"""Extract claims from Azure token response to embed in FastMCP JWT.
Decodes the Azure access token (which is a JWT) to extract user identity
claims. This allows gateways to inspect upstream identity information by
decoding the FastMCP JWT without needing server-side storage lookups.
Azure access tokens contain claims like:
- sub: Subject identifier (unique per user per application)
- oid: Object ID (unique user identifier across Azure AD)
- tid: Tenant ID
- azp: Authorized party (client ID that requested the token)
- name: Display name
- given_name: First name
- family_name: Last name
- preferred_username: User principal name (email format)
- upn: User Principal Name
- email: Email address (if available)
- roles: Application roles assigned to the user
- groups: Group memberships (if configured)
Args:
idp_tokens: Full token response from Azure, containing access_token
and potentially id_token.
Returns:
Dict of extracted claims, or None if extraction fails.
"""
access_token = idp_tokens.get("access_token")
if not access_token:
return None
try:
# Azure access tokens are JWTs - decode without verification
# (already validated by token_verifier during token exchange)
payload = decode_jwt_payload(access_token)
# Extract useful identity claims
claims: dict[str, Any] = {}
claim_keys = [
"sub",
"oid",
"tid",
"azp",
"name",
"given_name",
"family_name",
"preferred_username",
"upn",
"email",
"roles",
"groups",
]
for claim in claim_keys:
if claim in payload:
claims[claim] = payload[claim]
if claims:
logger.debug(
"Extracted %d Azure claims for embedding in FastMCP JWT",
len(claims),
)
return claims
return None
except Exception as e:
logger.debug("Failed to extract Azure claims: %s", e)
return None
async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential:
"""Get a cached or new OnBehalfOfCredential for OBO token exchange.
Credentials are cached by user assertion so the Azure SDK's internal
token cache can avoid redundant OBO exchanges when the same user
calls multiple tools with the same scopes.
Args:
user_assertion: The user's access token to exchange via OBO.
Returns:
A configured OnBehalfOfCredential ready for get_token() calls.
Raises:
ImportError: If azure-identity is not installed (requires fastmcp[azure]).
"""
_require_azure_identity("OBO token exchange")
from azure.identity.aio import OnBehalfOfCredential
key = hashlib.sha256(user_assertion.encode()).hexdigest()
if key in self._obo_credentials:
self._obo_credentials.move_to_end(key)
return self._obo_credentials[key]
obo_kwargs: dict[str, Any] = {
"tenant_id": self._tenant_id,
"client_id": self._upstream_client_id,
"user_assertion": user_assertion,
"authority": f"https://{self._base_authority}",
}
if self._upstream_client_secret is not None:
obo_kwargs["client_secret"] = (
self._upstream_client_secret.get_secret_value()
)
else:
raise ValueError(
"OBO token exchange requires either a client_secret or a subclass "
"that overrides get_obo_credential() to provide alternative credentials "
"(e.g., client_assertion_func for managed identity)."
)
credential = OnBehalfOfCredential(**obo_kwargs)
self._obo_credentials[key] = credential
# Evict oldest if over capacity
while len(self._obo_credentials) > self._obo_max_credentials:
_, evicted = self._obo_credentials.popitem(last=False)
await evicted.close()
return credential
async def close_obo_credentials(self) -> None:
"""Close all cached OBO credentials."""
credentials = list(self._obo_credentials.values())
self._obo_credentials.clear()
for credential in credentials:
try:
await credential.close()
except Exception:
logger.debug("Error closing OBO credential", exc_info=True)
class AzureJWTVerifier(JWTVerifier):
"""JWT verifier pre-configured for Azure AD / Microsoft Entra ID.
Auto-configures JWKS URI, issuer, audience, and scope handling from your
Azure app registration details. Designed for Managed Identity and other
token-verification-only scenarios where AzureProvider's full OAuth proxy
isn't needed.
Handles Azure's scope format automatically:
- Validates tokens using short-form scopes (what Azure puts in ``scp`` claims)
- Advertises full-URI scopes in OAuth metadata (what clients need to request)
Example::
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.plugins.auth.azure.provider import AzureJWTVerifier
from pydantic import AnyHttpUrl
verifier = AzureJWTVerifier(
client_id="your-client-id",
tenant_id="your-tenant-id",
required_scopes=["access_as_user"],
)
auth = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[
AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0")
],
base_url="https://my-server.com",
)
"""
def __init__(
self,
*,
client_id: str,
tenant_id: str,
required_scopes: list[str] | None = None,
identifier_uri: str | None = None,
base_authority: str = "login.microsoftonline.com",
):
"""Initialize Azure JWT verifier.
Args:
client_id: Azure application (client) ID from your App registration
tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers").
For multi-tenant apps ("organizations" or "consumers"), issuer validation
is skipped since Azure tokens carry the actual tenant GUID as issuer.
required_scopes: Scope names as they appear in Azure Portal under "Expose an API"
(e.g., ["access_as_user", "read"]). These are validated against
the short-form scopes in token ``scp`` claims, and automatically
prefixed with identifier_uri for OAuth metadata.
identifier_uri: Application ID URI (defaults to ``api://{client_id}``).
Used to prefix scopes in OAuth metadata so clients know the full
scope URIs to request from Azure.
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
For Azure Government, use "login.microsoftonline.us".
"""
self._identifier_uri = identifier_uri or f"api://{client_id}"
# For multi-tenant apps, Azure tokens carry the actual tenant GUID as
# issuer, not the literal "organizations" or "consumers" string. Skip
# issuer validation for these — audience still protects against wrong-app tokens.
multi_tenant_values = {"organizations", "consumers", "common"}
issuer: str | None = (
None
if tenant_id in multi_tenant_values
else f"https://{base_authority}/{tenant_id}/v2.0"
)
super().__init__(
jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys",
issuer=issuer,
audience=[client_id, self._identifier_uri],
algorithm="RS256",
required_scopes=required_scopes,
)
@property
def scopes_supported(self) -> list[str]:
"""Return scopes with Azure URI prefix for OAuth metadata.
Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp``
claim, but clients must request full URI scopes (e.g.,
``api://client-id/read``) from the Azure authorization endpoint. This
property returns the full-URI form for OAuth metadata while
``required_scopes`` retains the short form for token validation.
"""
if not self.required_scopes:
return []
prefixed = []
for scope in self.required_scopes:
if scope in OIDC_SCOPES or "://" in scope or "/" in scope:
prefixed.append(scope)
else:
prefixed.append(f"{self._identifier_uri}/{scope}")
return prefixed
# --- Dependency injection support ---
# These require fastmcp[azure] extra for azure-identity
def _require_azure_identity(feature: str) -> None:
"""Raise ImportError with install instructions if azure-identity is not available."""
try:
import azure.identity # noqa: F401
except ImportError as e:
raise ImportError(
f"{feature} requires the `azure` extra. "
"Install with: pip install 'fastmcp[azure]'"
) from e
def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None:
"""Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed."""
if isinstance(auth, AzureProvider):
return auth
if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider):
return auth.server
return None
class _EntraOBOToken(Dependency[str]):
"""Dependency that performs OBO token exchange for Microsoft Entra.
Uses azure.identity's OnBehalfOfCredential for async-native OBO,
with automatic token caching and refresh. Credentials are cached on
the AzureProvider so repeated tool calls reuse existing credentials
and benefit from the Azure SDK's internal token cache.
"""
def __init__(self, scopes: list[str]):
self.scopes = scopes
async def __aenter__(self) -> str:
_require_azure_identity("EntraOBOToken")
from fastmcp.server.dependencies import get_access_token, get_server
access_token = get_access_token()
if access_token is None:
raise RuntimeError(
"No access token available. Cannot perform OBO exchange."
)
server = get_server()
azure_provider = _find_azure_provider(server.auth)
if azure_provider is None:
raise RuntimeError(
"EntraOBOToken requires an AzureProvider as the auth provider. "
f"Current provider: {type(server.auth).__name__}"
)
credential = await azure_provider.get_obo_credential(
user_assertion=access_token.token,
)
result = await credential.get_token(*self.scopes)
return result.token
def EntraOBOToken(scopes: list[str]) -> str:
"""Exchange the user's Entra token for a downstream API token via OBO.
This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange,
allowing your MCP server to call downstream APIs (like Microsoft Graph) on
behalf of the authenticated user.
Args:
scopes: The scopes to request for the downstream API. For Microsoft Graph,
use scopes like ["https://graph.microsoft.com/Mail.Read"] or
["https://graph.microsoft.com/.default"].
Returns:
A dependency that resolves to the downstream API access token string
Raises:
ImportError: If fastmcp[azure] is not installed
RuntimeError: If no access token is available, provider is not Azure,
or OBO exchange fails
Example:
```python
from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken
import httpx
@mcp.tool()
async def get_my_emails(
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"])
):
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://graph.microsoft.com/v1.0/me/messages",
headers={"Authorization": f"Bearer {graph_token}"}
)
return resp.json()
```
Note:
For OBO to work, ensure the scopes are included in the AzureProvider's
`additional_authorize_scopes` parameter, and that admin consent has been
granted for those scopes in your Entra app registration.
"""
return cast(str, _EntraOBOToken(scopes))

View file

@ -0,0 +1,5 @@
"""Clerk auth plugin."""
from fastmcp.server.plugins.auth.clerk.plugin import ClerkAuth
__all__ = ["ClerkAuth"]

View file

@ -0,0 +1,67 @@
"""Clerk auth plugin."""
from __future__ import annotations
from typing import Any, ClassVar
import httpx
from key_value.aio.protocols import AsyncKeyValue
from fastmcp.server.auth import AuthProvider
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig
from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider
from fastmcp.server.plugins.base import PluginMeta
class ClerkAuthConfig(OAuthProviderConfig):
"""Config model for the Clerk auth plugin."""
domain: str | None = None
valid_scopes: list[str] | None = None
extra_authorize_params: dict[str, str] | None = None
class ClerkAuth(AuthPlugin[ClerkAuthConfig]):
"""Contribute a `ClerkProvider` as the server's auth provider."""
Config: ClassVar[type[ClerkAuthConfig]] = ClerkAuthConfig
meta = PluginMeta(name="clerk-auth")
def __init__(
self,
config: ClerkAuthConfig | dict[str, Any] | None = None,
*,
client_storage: AsyncKeyValue | None = None,
http_client: httpx.AsyncClient | None = None,
) -> None:
super().__init__(config)
self._client_storage = client_storage
self._http_client = http_client
def auth(self) -> AuthProvider | None:
self._require("domain", "client_id", "base_url")
self._require_one("client_secret", "jwt_signing_key")
return ClerkProvider(
**self._kwargs(
"domain",
"client_id",
"client_secret",
"base_url",
"resource_base_url",
"issuer_url",
"redirect_path",
"required_scopes",
"valid_scopes",
"timeout_seconds",
"allowed_client_redirect_uris",
"jwt_signing_key",
"require_authorization_consent",
"consent_csp_policy",
"forward_resource",
"extra_authorize_params",
"enable_cimd",
),
client_storage=self._client_storage,
http_client=self._http_client,
)

View file

@ -0,0 +1,388 @@
"""Clerk OAuth provider for FastMCP.
This module provides a complete Clerk OAuth integration that's ready to use
with a Clerk domain, client ID, and client secret. It handles all the complexity
of Clerk's OAuth/OIDC flow, token validation, and user management.
Clerk uses standard OIDC endpoints derived from the instance domain
(e.g., ``https://<instance>.clerk.accounts.dev``). Token verification is
performed via the introspection endpoint (RFC 7662) for security-critical
checks (active status, audience, scopes), followed by the userinfo endpoint
for profile enrichment. Userinfo failure is non-fatal.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider
auth = ClerkProvider(
domain="saving-primate-16.clerk.accounts.dev",
client_id="your-clerk-client-id",
client_secret="your-clerk-client-secret",
base_url="https://my-server.com",
)
mcp = FastMCP("My Protected Server", auth=auth)
```
"""
from __future__ import annotations
import contextlib
from typing import Literal
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from fastmcp.server.auth import TokenVerifier
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class ClerkTokenVerifier(TokenVerifier):
"""Token verifier for Clerk OAuth tokens.
Clerk issues standard OIDC tokens. Verification uses the introspection
endpoint (RFC 7662) as the primary security gate it confirms the token
is active and provides metadata (scopes, expiry, audience). The userinfo
endpoint is called second for profile enrichment (name, email, picture)
and its failure is non-fatal.
When a ``client_id`` is configured, the audience from introspection is
validated against it. When ``required_scopes`` are configured,
introspection must return the token's scopes — the verifier will not
assume scopes when introspection is unavailable.
"""
def __init__(
self,
*,
domain: str,
client_id: str | None = None,
client_secret: str | None = None,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize the Clerk token verifier.
Args:
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev")
client_id: Clerk OAuth client ID, used for introspection endpoint authentication
client_secret: Clerk OAuth client secret, used for introspection endpoint authentication
required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"])
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.domain = domain.rstrip("/")
self._client_id = client_id
self._client_secret = client_secret
self.timeout_seconds = timeout_seconds
self._http_client = http_client
self._userinfo_url = f"https://{self.domain}/oauth/userinfo"
self._introspection_url = f"https://{self.domain}/oauth/token_info"
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a Clerk OAuth token via introspection and userinfo.
Calls the introspection endpoint first to validate the token and
retrieve auth metadata (active status, scopes, expiry, audience).
If the token passes security checks, the userinfo endpoint is called
for profile enrichment. Userinfo failure is non-fatal.
When a ``client_id`` is configured, the token's audience must match it.
When ``required_scopes`` are configured, introspection must confirm
them; tokens are rejected if scope information is unavailable.
"""
try:
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Step 1: Validate token via introspection (RFC 7662).
# Security-critical checks (active, audience, scopes) come first.
introspect_data_payload: dict = {"token": token}
introspect_kwargs: dict = {
"data": introspect_data_payload,
"headers": {"User-Agent": "FastMCP-Clerk-OAuth"},
}
if self._client_id and self._client_secret:
introspect_kwargs["auth"] = (
self._client_id,
self._client_secret,
)
elif self._client_id:
introspect_data_payload["client_id"] = self._client_id
introspect_response = await client.post(
self._introspection_url,
**introspect_kwargs,
)
if introspect_response.status_code != 200:
logger.debug(
"Clerk introspection failed: %d",
introspect_response.status_code,
)
return None
introspect_data = introspect_response.json()
# RFC 7662 requires the 'active' field in the response.
# A missing field indicates a malformed response — reject.
if "active" not in introspect_data or not introspect_data["active"]:
logger.debug(
"Clerk introspection: token inactive or missing 'active' field"
)
return None
scope_str = introspect_data.get("scope", "")
token_scopes = scope_str.split() if scope_str else []
aud = introspect_data.get("aud") or introspect_data.get("client_id")
expires_at: int | None = None
exp = introspect_data.get("exp")
if exp is not None:
with contextlib.suppress(ValueError, TypeError):
expires_at = int(exp)
if self._client_id and aud != self._client_id:
logger.debug(
"Clerk token audience mismatch: got %s, expected %s",
aud,
self._client_id,
)
return None
if self.required_scopes:
if not token_scopes:
logger.debug(
"Clerk token missing scope information; "
"cannot verify required scopes %s",
self.required_scopes,
)
return None
token_scopes_set = set(token_scopes)
required_scopes_set = set(self.required_scopes)
if not required_scopes_set.issubset(token_scopes_set):
logger.debug(
"Clerk token missing required scopes. Has %s, needs %s",
token_scopes_set,
required_scopes_set,
)
return None
# Step 2: Fetch user profile via userinfo.
# Enriches the token with profile data (name, email, picture).
sub = introspect_data.get("sub")
user_data: dict = {}
try:
userinfo_response = await client.get(
self._userinfo_url,
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "FastMCP-Clerk-OAuth",
},
)
if userinfo_response.status_code == 200:
user_data = userinfo_response.json()
if not sub:
sub = user_data.get("sub")
except Exception as e:
logger.debug("Clerk userinfo call failed: %s", e)
if not sub:
logger.debug("Clerk token missing 'sub' claim")
return None
access_token = AccessToken(
token=token,
client_id=aud or sub,
scopes=token_scopes,
expires_at=expires_at,
claims={
"sub": sub,
"aud": aud,
"email": user_data.get("email"),
"email_verified": user_data.get("email_verified"),
"name": user_data.get("name"),
"picture": user_data.get("picture"),
"given_name": user_data.get("given_name"),
"family_name": user_data.get("family_name"),
"preferred_username": user_data.get("preferred_username"),
"iss": user_data.get("iss"),
"clerk_user_data": user_data or None,
},
)
logger.debug("Clerk token verified successfully for sub=%s", sub)
return access_token
except httpx.RequestError as e:
logger.debug("Failed to verify Clerk token: %s", e)
return None
except Exception as e:
logger.debug("Clerk token verification error: %s", e)
return None
class ClerkProvider(OAuthProxy):
"""Complete Clerk OAuth provider for FastMCP.
This provider makes it trivial to add Clerk OAuth protection to any
FastMCP server. Provide your Clerk instance domain, OAuth app credentials,
and a base URL, and you're ready to go.
Clerk uses standard OIDC endpoints derived from the instance domain.
All endpoint URLs are constructed automatically from the domain parameter.
Features:
- Transparent OAuth proxy to Clerk
- Automatic token validation via Clerk's userinfo & introspection APIs
- User information extraction from Clerk's OIDC claims
- PKCE support (S256)
- Minimal configuration required
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider
auth = ClerkProvider(
domain="saving-primate-16.clerk.accounts.dev",
client_id="your-clerk-client-id",
client_secret="your-clerk-client-secret",
base_url="https://my-server.com",
)
mcp = FastMCP("My App", auth=auth)
```
"""
def __init__(
self,
*,
domain: str,
client_id: str,
client_secret: str | None = None,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
valid_scopes: list[str] | None = None,
timeout_seconds: int = 10,
allowed_client_redirect_uris: list[str] | None = None,
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Clerk OAuth provider.
Args:
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev").
This is used to derive all OAuth/OIDC endpoint URLs.
client_id: Clerk OAuth application client ID
client_secret: Clerk OAuth application client secret.
Optional for PKCE public clients. When omitted, jwt_signing_key must be provided.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
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 Clerk OAuth app (defaults to "/auth/callback")
required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]).
Clerk supports: "openid", "email", "profile", "public_metadata",
"private_metadata", "offline_access".
valid_scopes: All scopes that clients are allowed to request, advertised through
well-known endpoints. Defaults to required_scopes if not provided.
timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10)
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, an encrypted file store will be created in the data directory
(derived from ``platformdirs``).
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 "external", the built-in consent screen is skipped
but no warning is logged, indicating that consent is handled externally by Clerk.
consent_csp_policy: Custom CSP policy for the consent page.
extra_authorize_params: Additional parameters to forward to Clerk's authorization
endpoint. Example: {"prompt": "login"} to force re-authentication.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created
per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs (default True). Set to False to disable.
"""
domain = domain.rstrip("/")
required_scopes_final = (
parse_scopes(required_scopes)
if required_scopes is not None
else ["openid", "email", "profile"]
)
parsed_valid_scopes = (
parse_scopes(valid_scopes) if valid_scopes is not None else None
)
token_verifier = ClerkTokenVerifier(
domain=domain,
client_id=client_id,
client_secret=client_secret,
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
)
extra_authorize_params_final = (
dict(extra_authorize_params) if extra_authorize_params else {}
)
super().__init__(
upstream_authorization_endpoint=f"https://{domain}/oauth/authorize",
upstream_token_endpoint=f"https://{domain}/oauth/token",
upstream_client_id=client_id,
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url,
allowed_client_redirect_uris=allowed_client_redirect_uris,
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
extra_authorize_params=extra_authorize_params_final or None,
valid_scopes=parsed_valid_scopes,
enable_cimd=enable_cimd,
)
logger.debug(
"Initialized Clerk OAuth provider for domain %s with scopes: %s",
domain,
required_scopes_final,
)

View file

@ -0,0 +1,5 @@
"""Descope auth plugin."""
from fastmcp.server.plugins.auth.descope.plugin import DescopeAuth
__all__ = ["DescopeAuth"]

Some files were not shown because too many files have changed in this diff Show more