mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Compare commits
17 commits
main
...
codex/auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835ba07bcd |
||
|
|
428427220f |
||
|
|
c945f3307b |
||
|
|
5dc7baa5a8 |
||
|
|
18ddf28b79 |
||
|
|
a82979e433 |
||
|
|
03f4a90e60 |
||
|
|
19fa2fc33e |
||
|
|
3c0d248526 |
||
|
|
34cb2218dc |
||
|
|
67f226d453 |
||
|
|
cc290b3a2e |
||
|
|
ff0ae10d88 |
||
|
|
e2e49f77d2 |
||
|
|
769e998017 |
||
|
|
54e83367a3 |
||
|
|
823ea4c5fc |
224 changed files with 15143 additions and 7700 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
70
docs/servers/auth/plugins.mdx
Normal file
70
docs/servers/auth/plugins.mdx
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())],
|
||||
)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.....
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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-..."
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
28
examples/tool_search/README.md
Normal file
28
examples/tool_search/README.md
Normal 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
|
||||
```
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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__":
|
||||
|
|
@ -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__":
|
||||
|
|
@ -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
102
src/fastmcp/cli/plugin.py
Normal 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}")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
15
src/fastmcp/server/plugins/__init__.py
Normal file
15
src/fastmcp/server/plugins/__init__.py
Normal 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"]
|
||||
3
src/fastmcp/server/plugins/auth/__init__.py
Normal file
3
src/fastmcp/server/plugins/auth/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Auth plugin namespace for FastMCP."""
|
||||
|
||||
__all__: list[str] = []
|
||||
65
src/fastmcp/server/plugins/auth/_base.py
Normal file
65
src/fastmcp/server/plugins/auth/_base.py
Normal 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
|
||||
5
src/fastmcp/server/plugins/auth/auth0/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/auth0/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Auth0 auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.auth0.plugin import Auth0Auth
|
||||
|
||||
__all__ = ["Auth0Auth"]
|
||||
63
src/fastmcp/server/plugins/auth/auth0/plugin.py
Normal file
63
src/fastmcp/server/plugins/auth/auth0/plugin.py
Normal 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,
|
||||
)
|
||||
135
src/fastmcp/server/plugins/auth/auth0/provider.py
Normal file
135
src/fastmcp/server/plugins/auth/auth0/provider.py
Normal 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,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/authkit/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/authkit/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""WorkOS AuthKit auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.authkit.plugin import AuthKitAuth
|
||||
|
||||
__all__ = ["AuthKitAuth"]
|
||||
51
src/fastmcp/server/plugins/auth/authkit/plugin.py
Normal file
51
src/fastmcp/server/plugins/auth/authkit/plugin.py
Normal 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,
|
||||
)
|
||||
186
src/fastmcp/server/plugins/auth/authkit/provider.py
Normal file
186
src/fastmcp/server/plugins/auth/authkit/provider.py
Normal 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"]
|
||||
5
src/fastmcp/server/plugins/auth/aws/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/aws/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""AWS Cognito auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.aws.plugin import AWSCognitoAuth
|
||||
|
||||
__all__ = ["AWSCognitoAuth"]
|
||||
61
src/fastmcp/server/plugins/auth/aws/plugin.py
Normal file
61
src/fastmcp/server/plugins/auth/aws/plugin.py
Normal 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,
|
||||
)
|
||||
229
src/fastmcp/server/plugins/auth/aws/provider.py
Normal file
229
src/fastmcp/server/plugins/auth/aws/provider.py
Normal 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,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/azure/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/azure/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Azure auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.azure.plugin import AzureAuth
|
||||
|
||||
__all__ = ["AzureAuth"]
|
||||
69
src/fastmcp/server/plugins/auth/azure/plugin.py
Normal file
69
src/fastmcp/server/plugins/auth/azure/plugin.py
Normal 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,
|
||||
)
|
||||
768
src/fastmcp/server/plugins/auth/azure/provider.py
Normal file
768
src/fastmcp/server/plugins/auth/azure/provider.py
Normal 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))
|
||||
5
src/fastmcp/server/plugins/auth/clerk/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/clerk/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Clerk auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.clerk.plugin import ClerkAuth
|
||||
|
||||
__all__ = ["ClerkAuth"]
|
||||
67
src/fastmcp/server/plugins/auth/clerk/plugin.py
Normal file
67
src/fastmcp/server/plugins/auth/clerk/plugin.py
Normal 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,
|
||||
)
|
||||
388
src/fastmcp/server/plugins/auth/clerk/provider.py
Normal file
388
src/fastmcp/server/plugins/auth/clerk/provider.py
Normal 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,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/descope/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/descope/__init__.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue