diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index c2ee3dfb3..d358a3a1e 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token diff --git a/.github/workflows/auto-close-needs-mre.yml b/.github/workflows/auto-close-needs-mre.yml index 98d26ccff..4338c58d8 100644 --- a/.github/workflows/auto-close-needs-mre.yml +++ b/.github/workflows/auto-close-needs-mre.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token diff --git a/.github/workflows/martian-issue-triage.yml b/.github/workflows/martian-issue-triage.yml index 8669eefdd..d9c58220c 100644 --- a/.github/workflows/martian-issue-triage.yml +++ b/.github/workflows/martian-issue-triage.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout base repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} diff --git a/.github/workflows/martian-test-failure.yml b/.github/workflows/martian-test-failure.yml index bd12de7e3..bd0df80a2 100644 --- a/.github/workflows/martian-test-failure.yml +++ b/.github/workflows/martian-test-failure.yml @@ -23,7 +23,7 @@ jobs: actions: read # Required for Claude to read CI results steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index 0f8b5c315..d95460ecd 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml index 8c13ef64a..e04cb8137 100644 --- a/.github/workflows/marvin-label-triage.yml +++ b/.github/workflows/marvin-label-triage.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout base repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} diff --git a/.github/workflows/marvin.yml b/.github/workflows/marvin.yml index 51674e615..2693e42f4 100644 --- a/.github/workflows/marvin.yml +++ b/.github/workflows/marvin.yml @@ -31,7 +31,7 @@ jobs: (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'marvin') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 # Install UV package manager - name: Install UV diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2a7f3adc9..5f2fe8d53 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: id-token: write # For PyPI's trusted publishing steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index 2551c49b1..b8e230902 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 with: diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 75ba3365e..5c6d4d892 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -60,7 +60,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -85,7 +85,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index 1ebe92ec1..9a7900cb8 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index 8d1996f7a..aa5a44bd9 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token diff --git a/README.md b/README.md index ca00ef3d0..bad8b2626 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,7 @@ FastMCP provides comprehensive authentication support that sets it apart from ba - **Auth0** - **WorkOS** - **Descope** +- **Discord** - **JWT/Custom** - **API Keys** diff --git a/docs/docs.json b/docs/docs.json index ecb10a6fd..7e306b642 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -197,6 +197,7 @@ "integrations/aws-cognito", "integrations/azure", "integrations/descope", + "integrations/discord", "integrations/github", "integrations/scalekit", "integrations/google", @@ -357,11 +358,13 @@ "python-sdk/fastmcp-server-auth-providers-azure", "python-sdk/fastmcp-server-auth-providers-debug", "python-sdk/fastmcp-server-auth-providers-descope", + "python-sdk/fastmcp-server-auth-providers-discord", "python-sdk/fastmcp-server-auth-providers-github", "python-sdk/fastmcp-server-auth-providers-google", "python-sdk/fastmcp-server-auth-providers-in_memory", "python-sdk/fastmcp-server-auth-providers-introspection", "python-sdk/fastmcp-server-auth-providers-jwt", + "python-sdk/fastmcp-server-auth-providers-oci", "python-sdk/fastmcp-server-auth-providers-scalekit", "python-sdk/fastmcp-server-auth-providers-supabase", "python-sdk/fastmcp-server-auth-providers-workos" diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 90dcfd1bc..e18843abf 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -164,6 +164,34 @@ Using your specific tenant ID is recommended for better security and control. **Important**: The `required_scopes` parameter is **REQUIRED** and must include at least one scope. Azure's OAuth API requires the `scope` parameter in all authorization requests - you cannot authenticate without specifying at least one scope. Use the unprefixed scope names from your Azure App registration (e.g., `["read", "write"]`). These scopes must be created under **Expose an API** in your App registration. +### Scope Handling + +FastMCP automatically prefixes `required_scopes` with your `identifier_uri` (e.g., `api://your-client-id`) since these are your custom API scopes. Scopes in `additional_authorize_scopes` are sent as-is since they target external resources like Microsoft Graph. + +**`required_scopes`** — Your custom API scopes, defined in Azure "Expose an API": + +| You write | Sent to Azure | Validated on tokens | +|-----------|---------------|---------------------| +| `mcp-read` | `api://xxx/mcp-read` | ✓ | +| `my.scope` | `api://xxx/my.scope` | ✓ | +| `openid` | `openid` | ✗ (OIDC scope) | +| `api://xxx/read` | `api://xxx/read` | ✓ | + +**`additional_authorize_scopes`** — External scopes (e.g., Microsoft Graph) for server-side use: + +| You write | Sent to Azure | Validated on tokens | +|-----------|---------------|---------------------| +| `User.Read` | `User.Read` | ✗ | +| `Mail.Send` | `Mail.Send` | ✗ | + + +**Why aren't `additional_authorize_scopes` validated?** Azure issues separate tokens per resource. The access token FastMCP receives is for *your API*—Graph scopes aren't in its `scp` claim. To call Graph APIs, your server uses the upstream Azure token in an on-behalf-of (OBO) flow. + + + +OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are never prefixed and excluded from validation because Azure doesn't include them in access token `scp` claims. + + ## Testing ### Running the Server @@ -304,6 +332,8 @@ Redirect path configured in your Azure App registration Comma-, space-, or JSON-separated list of required scopes for your API (at least one scope required). These are validated on tokens and used as defaults if the client does not request specific scopes. Use unprefixed scope names from your Azure App registration (e.g., `read,write`). +You can include standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) in `required_scopes`. FastMCP automatically handles them correctly: they're sent to Azure unprefixed and excluded from token validation (since Azure doesn't include OIDC scopes in access token `scp` claims). + Azure's OAuth API requires the `scope` parameter - you must provide at least one scope. diff --git a/docs/integrations/discord.mdx b/docs/integrations/discord.mdx new file mode 100644 index 000000000..4e3670358 --- /dev/null +++ b/docs/integrations/discord.mdx @@ -0,0 +1,255 @@ +--- +title: Discord OAuth 🤝 FastMCP +sidebarTitle: Discord +description: Secure your FastMCP server with Discord OAuth +icon: discord +tag: NEW +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +This guide shows you how to secure your FastMCP server using **Discord OAuth**. Since Discord doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Discord's traditional OAuth with MCP's authentication requirements. + +## Configuration + +### Prerequisites + +Before you begin, you will need: +1. A **[Discord Account](https://discord.com/)** with access to create applications +2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) + +### Step 1: Create a Discord Application + +Create an application in the Discord Developer Portal to get the credentials needed for authentication: + + + + Go to the [Discord Developer Portal](https://discord.com/developers/applications). + + Click **"New Application"** and give it a name users will recognize (e.g., "My FastMCP Server"). + + + + In the left sidebar, click **"OAuth2"**. + + In the **Redirects** section, click **"Add Redirect"** and enter your callback URL: + - For development: `http://localhost:8000/auth/callback` + - For production: `https://your-domain.com/auth/callback` + + + The redirect URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. Discord allows `http://localhost` URLs for development. For production, use HTTPS. + + + + + On the same OAuth2 page, you'll find: + + - **Client ID**: A numeric string like `12345` + - **Client Secret**: Click "Reset Secret" to generate one + + + Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. + + + + +### Step 2: FastMCP Configuration + +Create your FastMCP server using the `DiscordProvider`, which handles Discord's OAuth flow automatically: + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth.providers.discord import DiscordProvider + +auth_provider = DiscordProvider( + client_id="12345", # Your Discord Application Client ID + client_secret="your-client-secret", # Your Discord OAuth Client Secret + base_url="http://localhost:8000", # Must match your OAuth configuration +) + +mcp = FastMCP(name="Discord Secured App", auth=auth_provider) + +@mcp.tool +async def get_user_info() -> dict: + """Returns information about the authenticated Discord user.""" + from fastmcp.server.dependencies import get_access_token + + token = get_access_token() + return { + "discord_id": token.claims.get("sub"), + "username": token.claims.get("username"), + "avatar": token.claims.get("avatar"), + } +``` + +## Testing + +### Running the Server + +Start your FastMCP server with HTTP transport to enable OAuth flows: + +```bash +fastmcp run server.py --transport http --port 8000 +``` + +Your server is now running and protected by Discord OAuth authentication. + +### Testing with a Client + +Create a test client that authenticates with your Discord-protected server: + +```python test_client.py +from fastmcp import Client +import asyncio + +async def main(): + async with Client("http://localhost:8000/mcp", auth="oauth") as client: + print("✓ Authenticated with Discord!") + + result = await client.call_tool("get_user_info") + print(f"Discord user: {result['username']}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +When you run the client for the first time: +1. Your browser will open to Discord's authorization page +2. Sign in with your Discord account and authorize the app +3. After authorization, you'll be redirected back +4. The client receives the token and can make authenticated requests + + +The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. + + +## Discord Scopes + +Discord OAuth supports several scopes for accessing different types of user data: + +| Scope | Description | +|-------|-------------| +| `identify` | Access username, avatar, and discriminator (default) | +| `email` | Access the user's email address | +| `guilds` | Access the user's list of servers | +| `guilds.join` | Ability to add the user to a server | + +To request additional scopes: + +```python +auth_provider = DiscordProvider( + client_id="...", + client_secret="...", + base_url="http://localhost:8000", + required_scopes=["identify", "email"], +) +``` + +## Production Configuration + +For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: + +```python server.py +import os +from fastmcp import FastMCP +from fastmcp.server.auth.providers.discord import DiscordProvider +from key_value.aio.stores.redis import RedisStore +from key_value.aio.wrappers.encryption import FernetEncryptionWrapper +from cryptography.fernet import Fernet + +auth_provider = DiscordProvider( + client_id="12345", + client_secret=os.environ["DISCORD_CLIENT_SECRET"], + base_url="https://your-production-domain.com", + + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + client_storage=FernetEncryptionWrapper( + key_value=RedisStore( + host=os.environ["REDIS_HOST"], + port=int(os.environ["REDIS_PORT"]) + ), + fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) + ) +) + +mcp = FastMCP(name="Production Discord App", auth=auth_provider) +``` + + +Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. + +For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). + + +## Environment Variables + +For production deployments, use environment variables instead of hardcoding credentials. + +### Provider Selection + +Setting this environment variable allows the Discord provider to be used automatically without explicitly instantiating it in code. + + + +Set to `fastmcp.server.auth.providers.discord.DiscordProvider` to use Discord authentication. + + + +### Discord-Specific Configuration + +These environment variables provide default values for the Discord provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. + + + +Your Discord Application Client ID (e.g., `12345`) + + + +Your Discord OAuth Client Secret + + + +Public URL where OAuth endpoints will be accessible (includes any mount path) + + + +Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details. + + + +Redirect path configured in your Discord OAuth settings + + + +Comma-, space-, or JSON-separated list of required Discord scopes (e.g., `identify,email` or `["identify","email"]`) + + + +HTTP request timeout for Discord API calls + + + +Example `.env` file: +```bash +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.discord.DiscordProvider + +FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID=12345 +FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET=your-client-secret +FASTMCP_SERVER_AUTH_DISCORD_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_DISCORD_REQUIRED_SCOPES=identify,email +``` + +With environment variables set, your server code simplifies to: + +```python server.py +from fastmcp import FastMCP + +mcp = FastMCP(name="Discord Secured App") + +@mcp.tool +async def protected_tool(query: str) -> str: + """A tool that requires Discord authentication to access.""" + return f"Processing authenticated request: {query}" +``` diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 3b6f0bf3a..79cb794db 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx index 0463c3a05..40c0c5c6a 100644 --- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx +++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx @@ -27,7 +27,7 @@ Generate a Cursor deeplink for installing the MCP server. - Deeplink URL that can be clicked to install the server -### `open_deeplink` +### `open_deeplink` ```python open_deeplink(deeplink: str) -> bool @@ -43,7 +43,7 @@ Attempt to open a deeplink URL using the system's default handler. - True if the command succeeded, False otherwise -### `install_cursor_workspace` +### `install_cursor_workspace` ```python install_cursor_workspace(file: Path, server_object: str | None, name: str, workspace_path: Path) -> bool @@ -68,7 +68,7 @@ Install FastMCP server to workspace-specific Cursor configuration. - True if installation was successful, False otherwise -### `install_cursor` +### `install_cursor` ```python install_cursor(file: Path, server_object: str | None, name: str) -> bool @@ -93,7 +93,7 @@ Install FastMCP server in Cursor. - True if installation was successful, False otherwise -### `cursor_command` +### `cursor_command` ```python cursor_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index 6da25abbf..40d890ddf 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -7,7 +7,7 @@ sidebarTitle: oauth ## Functions -### `check_if_auth_required` +### `check_if_auth_required` ```python check_if_auth_required(mcp_url: str, httpx_kwargs: dict[str, Any] | None = None) -> bool @@ -22,47 +22,47 @@ Check if the MCP endpoint requires authentication by making a test request. ## Classes -### `ClientNotFoundError` +### `ClientNotFoundError` Raised when OAuth client credentials are not found on the server. -### `TokenStorageAdapter` +### `TokenStorageAdapter` **Methods:** -#### `clear` +#### `clear` ```python clear(self) -> None ``` -#### `get_tokens` +#### `get_tokens` ```python get_tokens(self) -> OAuthToken | None ``` -#### `set_tokens` +#### `set_tokens` ```python set_tokens(self, tokens: OAuthToken) -> None ``` -#### `get_client_info` +#### `get_client_info` ```python get_client_info(self) -> OAuthClientInformationFull | None ``` -#### `set_client_info` +#### `set_client_info` ```python set_client_info(self, client_info: OAuthClientInformationFull) -> None ``` -### `OAuth` +### `OAuth` OAuth client provider for MCP servers with browser-based authentication. @@ -73,7 +73,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -82,7 +82,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -91,7 +91,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx index 1b915ca6f..cbb1599f9 100644 --- a/docs/python-sdk/fastmcp-client-client.mdx +++ b/docs/python-sdk/fastmcp-client-client.mdx @@ -476,7 +476,7 @@ Retrieve a list of tools available on the server. #### `call_tool_mcp` ```python -call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.CallToolResult +call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, meta: dict[str, Any] | None = None) -> mcp.types.CallToolResult ``` Send a tools/call request and return the complete MCP protocol result. @@ -489,6 +489,10 @@ and other metadata. It does not raise an exception if the tool call results in a - `arguments`: Arguments to pass to the tool. - `timeout`: The timeout for the tool call. Defaults to None. - `progress_handler`: The progress handler to use for the tool call. Defaults to None. +- `meta`: Additional metadata to include with the request. +This is useful for passing contextual information (like user IDs, trace IDs, or preferences) +that shouldn't be tool arguments but may influence server-side processing. The server +can access this via `context.request_context.meta`. Defaults to None. **Returns:** - mcp.types.CallToolResult: The complete response object from the protocol, @@ -498,10 +502,10 @@ containing the tool result and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `call_tool` +#### `call_tool` ```python -call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True) -> CallToolResult +call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True, meta: dict[str, Any] | None = None) -> CallToolResult ``` Call a tool on the server. @@ -513,6 +517,11 @@ Unlike call_tool_mcp, this method raises a ToolError if the tool call results in - `arguments`: Arguments to pass to the tool. Defaults to None. - `timeout`: The timeout for the tool call. Defaults to None. - `progress_handler`: The progress handler to use for the tool call. Defaults to None. +- `raise_on_error`: Whether to raise a ToolError if the tool call results in an error. Defaults to True. +- `meta`: Additional metadata to include with the request. +This is useful for passing contextual information (like user IDs, trace IDs, or preferences) +that shouldn't be tool arguments but may influence server-side processing. The server +can access this via `context.request_context.meta`. Defaults to None. **Returns:** - @@ -528,10 +537,10 @@ raw result object. - `RuntimeError`: If called while the client is not connected. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` -### `CallToolResult` +### `CallToolResult` diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index 2e6fed379..b5002a805 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -7,13 +7,13 @@ sidebarTitle: auth ## Classes -### `AccessToken` +### `AccessToken` AccessToken that includes all JWT claims. -### `AuthProvider` +### `AuthProvider` Base class for all FastMCP authentication providers. @@ -26,7 +26,7 @@ custom authentication routes. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -43,7 +43,7 @@ All auth providers must implement token verification. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -67,7 +67,7 @@ provider does not create the actual MCP endpoint route. - List of all routes for this provider (excluding the MCP endpoint itself) -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] @@ -95,7 +95,7 @@ This is used to construct path-scoped well-known URLs. - List of well-known discovery routes (typically mounted at root level) -#### `get_middleware` +#### `get_middleware` ```python get_middleware(self) -> list @@ -107,7 +107,7 @@ Get HTTP application-level middleware for this auth provider. - List of Starlette Middleware instances to apply to the HTTP app -### `TokenVerifier` +### `TokenVerifier` Base class for token verifiers (Resource Servers). @@ -118,7 +118,7 @@ Token verifiers typically don't provide authentication routes by default. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -127,7 +127,7 @@ verify_token(self, token: str) -> AccessToken | None Verify a bearer token and return access info if valid. -### `RemoteAuthProvider` +### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. @@ -144,7 +144,7 @@ the authorization servers that issue valid tokens. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -153,7 +153,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token using the configured token verifier. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -164,7 +164,7 @@ Get routes for this provider. Creates protected resource metadata routes (RFC 9728). -### `OAuthProvider` +### `OAuthProvider` OAuth Authorization Server provider. @@ -175,7 +175,7 @@ authorization flows, token issuance, and token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -193,7 +193,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index cc676e63e..3aa245abb 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -26,17 +26,23 @@ production use with enterprise identity providers. ## Functions -### `create_consent_html` +### `create_consent_html` ```python -create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None) -> str +create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None) -> str ``` Create a styled HTML consent page for OAuth authorization requests. +**Args:** +- `csp_policy`: Content Security Policy override. +If None, uses the built-in CSP policy with appropriate directives. +If empty string "", disables CSP entirely (no meta tag is rendered). +If a non-empty string, uses that as the CSP policy value. -### `create_error_html` + +### `create_error_html` ```python create_error_html(error_title: str, error_message: str, error_details: dict[str, str] | None = None, server_name: str | None = None, server_icon_url: str | None = None) -> str @@ -58,7 +64,7 @@ Create a styled HTML error page for OAuth errors. ## Classes -### `OAuthTransaction` +### `OAuthTransaction` OAuth transaction state for consent flow. @@ -67,7 +73,7 @@ Stored server-side to track active authorization flows with client context. Includes CSRF tokens for consent protection per MCP security best practices. -### `ClientCode` +### `ClientCode` Client authorization code with PKCE and upstream tokens. @@ -76,7 +82,7 @@ Stored server-side after upstream IdP callback. Contains the upstream tokens bound to the client's PKCE challenge for secure token exchange. -### `UpstreamTokenSet` +### `UpstreamTokenSet` Stored upstream OAuth tokens from identity provider. @@ -86,7 +92,7 @@ and stored in plaintext within this model. Encryption is handled transparently at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. -### `JTIMapping` +### `JTIMapping` Maps FastMCP token JTI to upstream token ID. @@ -95,7 +101,16 @@ This allows stateless JWT validation while still being able to look up the corresponding upstream token when tools need to access upstream APIs. -### `ProxyDCRClient` +### `RefreshTokenMetadata` + + +Metadata for a refresh token, stored keyed by token hash. + +We store only metadata (not the token itself) for security - if storage +is compromised, attackers get hashes they can't reverse into usable tokens. + + +### `ProxyDCRClient` Client for DCR proxy with configurable redirect URI validation. @@ -125,7 +140,7 @@ arise from accepting arbitrary redirect URIs. **Methods:** -#### `validate_redirect_uri` +#### `validate_redirect_uri` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl @@ -139,7 +154,7 @@ This is essential for cached token scenarios where the client may reconnect with a different port. -### `TokenHandler` +### `TokenHandler` TokenHandler that returns OAuth 2.1 compliant error responses. @@ -162,7 +177,7 @@ Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." **Methods:** -#### `response` +#### `response` ```python response(self, obj: TokenSuccessResponse | TokenErrorResponse) @@ -171,7 +186,7 @@ response(self, obj: TokenSuccessResponse | TokenErrorResponse) Override response method to provide OAuth 2.1 compliant error handling. -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -251,14 +266,18 @@ OAuth Flow Implementation State Management --------------- -The proxy maintains minimal but crucial state: +The proxy maintains minimal but crucial state via pluggable storage (client_storage): - _oauth_transactions: Active authorization flows with client context - _client_codes: Authorization codes with PKCE challenges and upstream tokens -- _access_tokens, _refresh_tokens: Token storage for revocation -- Token relationship mappings for cleanup and rotation +- _jti_mapping_store: Maps FastMCP token JTIs to upstream token IDs +- _refresh_token_store: Refresh token metadata (keyed by token hash) + +All state is stored in the configured client_storage backend (Redis, disk, etc.) +enabling horizontal scaling across multiple instances. Security Considerations ---------------------- +- Refresh tokens stored by hash only (defense in depth if storage compromised) - PKCE enforced end-to-end (client to proxy, proxy to upstream) - Authorization codes are single-use with short expiry - Transaction IDs are cryptographically random @@ -281,7 +300,7 @@ Handles provider-specific requirements: **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -293,7 +312,7 @@ provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -307,7 +326,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -324,7 +343,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -336,7 +355,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -354,16 +373,19 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None ``` -Load refresh token from local storage. +Load refresh token metadata from distributed storage. + +Looks up by token hash and reconstructs the RefreshToken object. +Validates that the token belongs to the requesting client. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -380,7 +402,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -399,7 +421,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -407,11 +429,12 @@ revoke_token(self, token: AccessToken | RefreshToken) -> None Revoke token locally and with upstream server if supported. -Removes tokens from local storage and attempts to revoke them with -the upstream server if a revocation endpoint is configured. +For refresh tokens, removes from local storage by hash. +For all tokens, attempts upstream revocation if endpoint is configured. +Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index 2aa18f10a..184bc82af 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index b0474eb58..5fb265b32 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,13 +14,13 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Classes -### `AzureProviderSettings` +### `AzureProviderSettings` Settings for Azure OAuth provider. -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. @@ -55,7 +55,7 @@ Setup: **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str diff --git a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx index 8b282c398..e6a51c7c4 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx @@ -5,17 +5,20 @@ sidebarTitle: descope # `fastmcp.server.auth.providers.descope` + Descope authentication provider for FastMCP. This module provides DescopeProvider - a complete authentication solution that integrates with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) for seamless MCP client authentication. + ## Classes -### `DescopeProviderSettings` +### `DescopeProviderSettings` + +### `DescopeProvider` -### `DescopeProvider` Descope metadata provider for DCR (Dynamic Client Registration). @@ -27,7 +30,6 @@ as a resource server. IMPORTANT SETUP REQUIREMENTS: 1. Create an MCP Server in Descope Console: - - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console - Create a new MCP Server - Ensure that **Dynamic Client Registration (DCR)** is enabled @@ -35,14 +37,15 @@ IMPORTANT SETUP REQUIREMENTS: 2. Note your Well-Known URL: - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers) - - Format: `https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration` + - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`` For detailed setup instructions, see: https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr + **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -54,6 +57,6 @@ This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards Descope's OAuth metadata to clients. **Args:** - - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. +This is used to advertise the resource URL in metadata. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx b/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx new file mode 100644 index 000000000..031adaae9 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx @@ -0,0 +1,72 @@ +--- +title: discord +sidebarTitle: discord +--- + +# `fastmcp.server.auth.providers.discord` + + +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) + ``` + + +## Classes + +### `DiscordProviderSettings` + + +Settings for Discord OAuth provider. + + +### `DiscordTokenVerifier` + + +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. + + +**Methods:** + +#### `verify_token` + +```python +verify_token(self, token: str) -> AccessToken | None +``` + +Verify Discord OAuth token by calling Discord's tokeninfo API. + + +### `DiscordProvider` + + +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 + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx index cb97653c8..0071ebaa9 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx @@ -28,7 +28,7 @@ get_client(self, client_id: str) -> OAuthClientInformationFull | None register_client(self, client_info: OAuthClientInformationFull) -> None ``` -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -38,37 +38,37 @@ Simulates user authorization and generates an authorization code. Returns a redirect URI with the code and state. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None ``` -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken ``` -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None ``` -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken ``` -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None ``` -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -86,7 +86,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None diff --git a/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx b/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx new file mode 100644 index 000000000..dd42817b6 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx @@ -0,0 +1,103 @@ +--- +title: oci +sidebarTitle: oci +--- + +# `fastmcp.server.auth.providers.oci` + + +OCI OIDC provider for FastMCP. + +The pull request for the provider is submitted to fastmcp. + +This module provides OIDC Implementation to integrate MCP servers with OCI. +You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL. + +Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane. +You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs. +The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object. +You can use the signer object to create OCI service object. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.oci import OCIProvider + from fastmcp.server.dependencies import get_access_token + from fastmcp.utilities.logging import get_logger + + import os + + # Load configuration from environment + FASTMCP_SERVER_AUTH_OCI_CONFIG_URL = os.environ["FASTMCP_SERVER_AUTH_OCI_CONFIG_URL"] + FASTMCP_SERVER_AUTH_OCI_CLIENT_ID = os.environ["FASTMCP_SERVER_AUTH_OCI_CLIENT_ID"] + FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET = os.environ["FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET"] + FASTMCP_SERVER_AUTH_OCI_IAM_GUID = os.environ["FASTMCP_SERVER_AUTH_OCI_IAM_GUID"] + + import oci + from oci.auth.signers import TokenExchangeSigner + + logger = get_logger(__name__) + + # Simple OCI OIDC protection + auth = OCIProvider( + config_url=FASTMCP_SERVER_AUTH_OCI_CONFIG_URL, #config URL is the OCI IAM Domain OIDC discovery URL. + client_id=FASTMCP_SERVER_AUTH_OCI_CLIENT_ID, #This is same as the client ID configured for the OCI IAM Domain Integrated Application + client_secret=FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET, #This is same as the client secret configured for the OCI IAM Domain Integrated Application + required_scopes=["openid", "profile", "email"], + redirect_path="/auth/callback", + base_url="http://localhost:8000", + ) + + # NOTE: For production use, replace this with a thread-safe cache implementation + # such as threading.Lock-protected dict or a proper caching library + _global_token_cache = {} #In memory cache for OCI session token signer + + def get_oci_signer() -> TokenExchangeSigner: + + authntoken = get_access_token() + tokenID = authntoken.claims.get("jti") + token = authntoken.token + + #Check if the signer exists for the token ID in memory cache + cached_signer = _global_token_cache.get(tokenID) + logger.debug(f"Global cached signer: {cached_signer}") + if cached_signer: + logger.debug(f"Using globally cached signer for token ID: {tokenID}") + return cached_signer + + #If the signer is not yet created for the token then create new OCI signer object + logger.debug(f"Creating new signer for token ID: {tokenID}") + signer = TokenExchangeSigner( + jwt_or_func=token, + oci_domain_id=FASTMCP_SERVER_AUTH_OCI_IAM_GUID.split(".")[0], #This is same as IAM GUID configured for the OCI IAM Domain + client_id=FASTMCP_SERVER_AUTH_OCI_CLIENT_ID, #This is same as the client ID configured for the OCI IAM Domain Integrated Application + client_secret=FASTMCP_SERVER_AUTH_OCI_CLIENT_SECRET #This is same as the client secret configured for the OCI IAM Domain Integrated Application + ) + logger.debug(f"Signer {signer} created for token ID: {tokenID}") + + #Cache the signer object in memory cache + _global_token_cache[tokenID] = signer + logger.debug(f"Signer cached for token ID: {tokenID}") + + return signer + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + + +## Classes + +### `OCIProviderSettings` + + +Settings for OCI IAM domain OIDC provider. + + +### `OCIProvider` + + +An OCI IAM Domain provider implementation for FastMCP. + +This provider is a complete OCI integration that's ready to use with +just the configuration URL, client ID, client secret, and base URL. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx index 546b07b06..448ad7e9e 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx @@ -15,9 +15,9 @@ authentication for seamless MCP client authentication. ## Classes -### `ScalekitProviderSettings` +### `ScalekitProviderSettings` -### `ScalekitProvider` +### `ScalekitProvider` Scalekit resource server provider for OAuth 2.1 authentication. @@ -39,7 +39,6 @@ IMPORTANT SETUP REQUIREMENTS: - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) - Set SCALEKIT_RESOURCE_ID from your created resource - Set BASE_URL to your FastMCP server's public URL - - (Optional) Set SCALEKIT_REQUIRED_SCOPES to enforce token scopes For detailed setup instructions, see: https://docs.scalekit.com/mcp/overview/ @@ -47,7 +46,7 @@ https://docs.scalekit.com/mcp/overview/ **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -61,3 +60,4 @@ metadata endpoint that forwards Scalekit's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx index 603672d13..a5c35964e 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx @@ -15,9 +15,9 @@ for seamless MCP client authentication. ## Classes -### `SupabaseProviderSettings` +### `SupabaseProviderSettings` -### `SupabaseProvider` +### `SupabaseProvider` Supabase metadata provider for DCR (Dynamic Client Registration). @@ -31,13 +31,19 @@ IMPORTANT SETUP REQUIREMENTS: 1. Supabase Project Setup: - Create a Supabase project at https://supabase.com - Note your project URL (e.g., "https://abc123.supabase.co") - - For projects created after May 1st, 2025, asymmetric RS256 keys are used by default - - For older projects, consider migrating to asymmetric keys for better security + - Configure your JWT algorithm in Supabase Auth settings (HS256, RS256, or ES256) + - Asymmetric keys (RS256/ES256) are recommended for production 2. JWT Verification: - FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json - JWTs are issued by {project_url}/auth/v1 - Tokens are cached for up to 10 minutes by Supabase's edge servers + - Algorithm must match your Supabase Auth configuration + +3. Authorization: + - Supabase uses Row Level Security (RLS) policies for database authorization + - OAuth-level scopes are an upcoming feature in Supabase Auth + - Both approaches will be supported once scope handling is available For detailed setup instructions, see: https://supabase.com/docs/guides/auth/jwts @@ -45,7 +51,7 @@ https://supabase.com/docs/guides/auth/jwts **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index dd186188c..ee9772830 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -7,7 +7,7 @@ sidebarTitle: context ## Functions -### `set_context` +### `set_context` ```python set_context(context: Context) -> Generator[Context, None, None] @@ -15,7 +15,7 @@ set_context(context: Context) -> Generator[Context, None, None] ## Classes -### `LogData` +### `LogData` Data object for passing log arguments to client-side handlers. @@ -24,7 +24,7 @@ This provides an interface to match the Python standard library logging, for compatibility with structured logging. -### `Context` +### `Context` Context object providing access to MCP capabilities. @@ -72,7 +72,7 @@ The context is optional - tools that don't need it can omit the parameter. **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -81,18 +81,36 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `request_context` +#### `request_context` ```python -request_context(self) -> RequestContext[ServerSession, Any, Request] +request_context(self) -> RequestContext[ServerSession, Any, Request] | None ``` Access to the underlying request context. -If called outside of a request context, this will raise a ValueError. +Returns None when the MCP session has not been established yet. +Returns the full RequestContext once the MCP session is available. + +For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies, +which works whether or not the MCP session is available. + +Example in middleware: +```python +async def on_request(self, context, call_next): + ctx = context.fastmcp_context + if ctx.request_context: + # MCP session available - can access session_id, request_id, etc. + session_id = ctx.session_id + else: + # MCP session not available yet - use HTTP helpers + from fastmcp.server.dependencies import get_http_request + request = get_http_request() + return await call_next(context) +``` -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -105,7 +123,7 @@ Report progress for the current operation. - `total`: Optional total value e.g. 100 -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[MCPResource] @@ -117,7 +135,7 @@ List all available resources from the server. - List of Resource objects available on the server -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[MCPPrompt] @@ -129,7 +147,7 @@ List all available prompts from the server. - List of Prompt objects available on the server -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult @@ -145,7 +163,7 @@ Get a prompt by name with optional arguments. - The prompt result -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents] @@ -160,7 +178,7 @@ Read a resource by URI. - The resource content as either text or bytes -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -178,7 +196,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien - `extra`: Optional mapping for additional arguments -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -187,7 +205,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -195,8 +213,10 @@ request_id(self) -> str Get the unique ID for this request. +Raises RuntimeError if MCP request context is not available. -#### `session_id` + +#### `session_id` ```python session_id(self) -> str @@ -213,7 +233,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -221,8 +241,10 @@ session(self) -> ServerSession Access to the underlying session for advanced usage. +Raises RuntimeError if MCP request context is not available. -#### `debug` + +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -233,7 +255,7 @@ Send a `DEBUG`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -244,7 +266,7 @@ Send a `INFO`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -255,7 +277,7 @@ Send a `WARNING`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -266,7 +288,7 @@ Send a `ERROR`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -275,7 +297,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_tool_list_changed` +#### `send_tool_list_changed` ```python send_tool_list_changed(self) -> None @@ -284,7 +306,7 @@ send_tool_list_changed(self) -> None Send a tool list changed notification to the client. -#### `send_resource_list_changed` +#### `send_resource_list_changed` ```python send_resource_list_changed(self) -> None @@ -293,7 +315,7 @@ send_resource_list_changed(self) -> None Send a resource list changed notification to the client. -#### `send_prompt_list_changed` +#### `send_prompt_list_changed` ```python send_prompt_list_changed(self) -> None @@ -302,7 +324,7 @@ send_prompt_list_changed(self) -> None Send a prompt list changed notification to the client. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent | AudioContent @@ -315,25 +337,25 @@ completion from the client. The client must be appropriately configured, or the request will error. -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation @@ -362,16 +384,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `get_http_request` - -```python -get_http_request(self) -> Request -``` - -Get the active starlette request. - - -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -380,7 +393,7 @@ set_state(self, key: str, value: Any) -> None Set a value in the context state. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index 3c50f6421..9092633d9 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -7,19 +7,19 @@ sidebarTitle: dependencies ## Functions -### `get_context` +### `get_context` ```python get_context() -> Context ``` -### `get_http_request` +### `get_http_request` ```python get_http_request() -> Request ``` -### `get_http_headers` +### `get_http_headers` ```python get_http_headers(include_all: bool = False) -> dict[str, str] @@ -35,7 +35,7 @@ By default, strips problematic headers like `content-length` that cause issues i If `include_all` is True, all headers are returned. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -44,6 +44,11 @@ get_access_token() -> AccessToken | None Get the FastMCP access token from the current context. +This function first tries to get the token from the current HTTP request's scope, +which is more reliable for long-lived connections where the SDK's auth_context_var +may become stale after token refresh. Falls back to the SDK's context var if no +request is available. + **Returns:** - The access token if an authenticated user is available, None otherwise. diff --git a/docs/python-sdk/fastmcp-server-proxy.mdx b/docs/python-sdk/fastmcp-server-proxy.mdx index a64acca64..da1447345 100644 --- a/docs/python-sdk/fastmcp-server-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-proxy.mdx @@ -7,7 +7,7 @@ sidebarTitle: proxy ## Functions -### `default_proxy_roots_handler` +### `default_proxy_roots_handler` ```python default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanContextT]) -> RootsList @@ -19,13 +19,13 @@ A handler that forwards the list roots request from the remote server to the pro ## Classes -### `ProxyManagerMixin` +### `ProxyManagerMixin` A mixin for proxy managers to provide a unified client retrieval method. -### `ProxyToolManager` +### `ProxyToolManager` A ToolManager that sources its tools from a remote client in addition to local and mounted tools. @@ -33,7 +33,7 @@ A ToolManager that sources its tools from a remote client in addition to local a **Methods:** -#### `get_tools` +#### `get_tools` ```python get_tools(self) -> dict[str, Tool] @@ -42,7 +42,7 @@ get_tools(self) -> dict[str, Tool] Gets the unfiltered tool inventory including local, mounted, and proxy tools. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> list[Tool] @@ -51,7 +51,7 @@ list_tools(self) -> list[Tool] Gets the filtered list of tools including local, mounted, and proxy tools. -#### `call_tool` +#### `call_tool` ```python call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult @@ -60,7 +60,7 @@ call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult Calls a tool, trying local/mounted first, then proxy if not found. -### `ProxyResourceManager` +### `ProxyResourceManager` A ResourceManager that sources its resources from a remote client in addition to local and mounted resources. @@ -68,7 +68,7 @@ A ResourceManager that sources its resources from a remote client in addition to **Methods:** -#### `get_resources` +#### `get_resources` ```python get_resources(self) -> dict[str, Resource] @@ -77,7 +77,7 @@ get_resources(self) -> dict[str, Resource] Gets the unfiltered resource inventory including local, mounted, and proxy resources. -#### `get_resource_templates` +#### `get_resource_templates` ```python get_resource_templates(self) -> dict[str, ResourceTemplate] @@ -86,7 +86,7 @@ get_resource_templates(self) -> dict[str, ResourceTemplate] Gets the unfiltered template inventory including local, mounted, and proxy templates. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[Resource] @@ -95,7 +95,7 @@ list_resources(self) -> list[Resource] Gets the filtered list of resources including local, mounted, and proxy resources. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> list[ResourceTemplate] @@ -104,7 +104,7 @@ list_resource_templates(self) -> list[ResourceTemplate] Gets the filtered list of templates including local, mounted, and proxy templates. -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: AnyUrl | str) -> str | bytes @@ -113,7 +113,7 @@ read_resource(self, uri: AnyUrl | str) -> str | bytes Reads a resource, trying local/mounted first, then proxy if not found. -### `ProxyPromptManager` +### `ProxyPromptManager` A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts. @@ -121,7 +121,7 @@ A PromptManager that sources its prompts from a remote client in addition to loc **Methods:** -#### `get_prompts` +#### `get_prompts` ```python get_prompts(self) -> dict[str, Prompt] @@ -130,7 +130,7 @@ get_prompts(self) -> dict[str, Prompt] Gets the unfiltered prompt inventory including local, mounted, and proxy prompts. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[Prompt] @@ -139,7 +139,7 @@ list_prompts(self) -> list[Prompt] Gets the filtered list of prompts including local, mounted, and proxy prompts. -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult @@ -148,7 +148,7 @@ render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPr Renders a prompt, trying local/mounted first, then proxy if not found. -### `ProxyTool` +### `ProxyTool` A Tool that represents and executes a tool on a remote server. @@ -156,7 +156,7 @@ A Tool that represents and executes a tool on a remote server. **Methods:** -#### `from_mcp_tool` +#### `from_mcp_tool` ```python from_mcp_tool(cls, client: Client, mcp_tool: mcp.types.Tool) -> ProxyTool @@ -191,7 +191,7 @@ from_mcp_resource(cls, client: Client, mcp_resource: mcp.types.Resource) -> Prox Factory method to create a ProxyResource from a raw MCP resource schema. -#### `read` +#### `read` ```python read(self) -> str | bytes @@ -200,7 +200,7 @@ read(self) -> str | bytes Read the resource content from the remote server. -### `ProxyTemplate` +### `ProxyTemplate` A ResourceTemplate that represents and creates resources from a remote server template. @@ -208,7 +208,7 @@ A ResourceTemplate that represents and creates resources from a remote server te **Methods:** -#### `from_mcp_template` +#### `from_mcp_template` ```python from_mcp_template(cls, client: Client, mcp_template: mcp.types.ResourceTemplate) -> ProxyTemplate @@ -217,7 +217,7 @@ from_mcp_template(cls, client: Client, mcp_template: mcp.types.ResourceTemplate) Factory method to create a ProxyTemplate from a raw MCP template schema. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> ProxyResource @@ -226,7 +226,7 @@ create_resource(self, uri: str, params: dict[str, Any], context: Context | None Create a resource from the template by calling the remote server. -### `ProxyPrompt` +### `ProxyPrompt` A Prompt that represents and renders a prompt from a remote server. @@ -234,7 +234,7 @@ A Prompt that represents and renders a prompt from a remote server. **Methods:** -#### `from_mcp_prompt` +#### `from_mcp_prompt` ```python from_mcp_prompt(cls, client: Client, mcp_prompt: mcp.types.Prompt) -> ProxyPrompt @@ -243,7 +243,7 @@ from_mcp_prompt(cls, client: Client, mcp_prompt: mcp.types.Prompt) -> ProxyPromp Factory method to create a ProxyPrompt from a raw MCP prompt schema. -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any]) -> list[PromptMessage] @@ -252,14 +252,14 @@ render(self, arguments: dict[str, Any]) -> list[PromptMessage] Render the prompt by making a call through the client. -### `FastMCPProxy` +### `FastMCPProxy` A FastMCP server that acts as a proxy to a remote MCP-compliant server. It uses specialized managers that fulfill requests via a client factory. -### `ProxyClient` +### `ProxyClient` A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients. @@ -268,7 +268,7 @@ Supports forwarding roots, sampling, elicitation, logging, and progress. **Methods:** -#### `default_sampling_handler` +#### `default_sampling_handler` ```python default_sampling_handler(cls, messages: list[mcp.types.SamplingMessage], params: mcp.types.CreateMessageRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> mcp.types.CreateMessageResult @@ -277,7 +277,7 @@ default_sampling_handler(cls, messages: list[mcp.types.SamplingMessage], params: A handler that forwards the sampling request from the remote server to the proxy's connected clients and relays the response back to the remote server. -#### `default_elicitation_handler` +#### `default_elicitation_handler` ```python default_elicitation_handler(cls, message: str, response_type: type, params: mcp.types.ElicitRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> ElicitResult @@ -286,7 +286,7 @@ default_elicitation_handler(cls, message: str, response_type: type, params: mcp. A handler that forwards the elicitation request from the remote server to the proxy's connected clients and relays the response back to the remote server. -#### `default_log_handler` +#### `default_log_handler` ```python default_log_handler(cls, message: LogMessage) -> None @@ -295,7 +295,7 @@ default_log_handler(cls, message: LogMessage) -> None A handler that forwards the log notification from the remote server to the proxy's connected clients. -#### `default_progress_handler` +#### `default_progress_handler` ```python default_progress_handler(cls, progress: float, total: float | None, message: str | None) -> None @@ -304,7 +304,7 @@ default_progress_handler(cls, progress: float, total: float | None, message: str A handler that forwards the progress notification from the remote server to the proxy's connected clients. -### `StatefulProxyClient` +### `StatefulProxyClient` A proxy client that provides a stateful client factory for the proxy server. @@ -318,7 +318,7 @@ Note that it is essential to ensure that the proxy server itself is also statefu **Methods:** -#### `clear` +#### `clear` ```python clear(self) @@ -327,7 +327,7 @@ clear(self) Clear all cached clients and force disconnect them. -#### `new_stateful` +#### `new_stateful` ```python new_stateful(self) -> Client[ClientTransportT] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 1e12460b2..186377e35 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,14 +26,14 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `add_resource_prefix` +### `add_resource_prefix` ```python -add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str +add_resource_prefix(uri: str, prefix: str) -> str ``` -Add a prefix to a resource URI. +Add a prefix to a resource URI using path formatting (resource://prefix/path). **Args:** - `uri`: The original resource URI @@ -44,16 +44,10 @@ Add a prefix to a resource URI. **Examples:** -With new style: ```python add_resource_prefix("resource://path/to/resource", "prefix") "resource://prefix/path/to/resource" ``` -With legacy style: -```python -add_resource_prefix("resource://path/to/resource", "prefix") -"prefix+resource://path/to/resource" -``` With absolute path: ```python add_resource_prefix("resource:///absolute/path", "prefix") @@ -64,10 +58,10 @@ add_resource_prefix("resource:///absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `remove_resource_prefix` +### `remove_resource_prefix` ```python -remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str +remove_resource_prefix(uri: str, prefix: str) -> str ``` @@ -76,23 +70,16 @@ Remove a prefix from a resource URI. **Args:** - `uri`: The resource URI with a prefix - `prefix`: The prefix to remove -- `prefix_format`: The format of the prefix to remove -Returns: - The resource URI with the prefix removed +**Returns:** +- The resource URI with the prefix removed **Examples:** -With new style: ```python remove_resource_prefix("resource://prefix/path/to/resource", "prefix") "resource://path/to/resource" ``` -With legacy style: -```python -remove_resource_prefix("prefix+resource://path/to/resource", "prefix") -"resource://path/to/resource" -``` With absolute path: ```python remove_resource_prefix("resource://prefix//absolute/path", "prefix") @@ -103,10 +90,10 @@ remove_resource_prefix("resource://prefix//absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `has_resource_prefix` +### `has_resource_prefix` ```python -has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> bool +has_resource_prefix(uri: str, prefix: str) -> bool ``` @@ -121,16 +108,10 @@ Check if a resource URI has a specific prefix. **Examples:** -With new style: ```python has_resource_prefix("resource://prefix/path/to/resource", "prefix") True ``` -With legacy style: -```python -has_resource_prefix("prefix+resource://path/to/resource", "prefix") -True -``` With other path: ```python has_resource_prefix("resource://other/path/to/resource", "prefix") @@ -143,53 +124,53 @@ False ## Classes -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `run_async` +#### `run_async` ```python run_async(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -201,7 +182,7 @@ Run the FastMCP server asynchronously. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `run` +#### `run` ```python run(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -213,13 +194,13 @@ Run the FastMCP server. Note this is a synchronous function. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `get_tools` +#### `get_tools` ```python get_tools(self) -> dict[str, Tool] @@ -228,13 +209,13 @@ get_tools(self) -> dict[str, Tool] Get all tools (unfiltered), including mounted servers, indexed by key. -#### `get_tool` +#### `get_tool` ```python get_tool(self, key: str) -> Tool ``` -#### `get_resources` +#### `get_resources` ```python get_resources(self) -> dict[str, Resource] @@ -243,13 +224,13 @@ get_resources(self) -> dict[str, Resource] Get all resources (unfiltered), including mounted servers, indexed by key. -#### `get_resource` +#### `get_resource` ```python get_resource(self, key: str) -> Resource ``` -#### `get_resource_templates` +#### `get_resource_templates` ```python get_resource_templates(self) -> dict[str, ResourceTemplate] @@ -258,7 +239,7 @@ get_resource_templates(self) -> dict[str, ResourceTemplate] Get all resource templates (unfiltered), including mounted servers, indexed by key. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, key: str) -> ResourceTemplate @@ -267,7 +248,7 @@ get_resource_template(self, key: str) -> ResourceTemplate Get a registered resource template by key. -#### `get_prompts` +#### `get_prompts` ```python get_prompts(self) -> dict[str, Prompt] @@ -276,13 +257,13 @@ get_prompts(self) -> dict[str, Prompt] Get all prompts (unfiltered), including mounted servers, indexed by key. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, key: str) -> Prompt ``` -#### `custom_route` +#### `custom_route` ```python custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True) -> Callable[[Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]]] @@ -303,7 +284,7 @@ Starlette's reverse URL lookup feature) - `include_in_schema`: Whether to include in OpenAPI schema, defaults to True -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool) -> Tool @@ -321,7 +302,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str) -> None @@ -336,7 +317,7 @@ Remove a tool from the server. - `NotFoundError`: If the tool is not found -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -345,7 +326,7 @@ add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfi Add a tool transformation. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, tool_name: str) -> None @@ -354,19 +335,19 @@ remove_tool_transformation(self, tool_name: str) -> None Remove a tool transformation. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool @@ -392,7 +373,9 @@ This decorator supports multiple calling patterns: - `tags`: Optional set of tags for categorizing the tool - `output_schema`: Optional JSON schema for the tool's output - `annotations`: Optional annotations about the tool's behavior -- `exclude_args`: Optional list of argument names to exclude from the tool schema +- `exclude_args`: Optional list of argument names to exclude from the tool schema. +Note\: `exclude_args` will be deprecated in FastMCP 2.14 in favor of dependency +injection with `Depends()` for better lifecycle management. - `meta`: Optional meta information about the tool - `enabled`: Optional boolean to enable or disable the tool @@ -422,7 +405,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource) -> Resource @@ -437,7 +420,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -452,27 +435,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `add_resource_fn` - -```python -add_resource_fn(self, fn: AnyFunction, uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> None -``` - -Add a resource or template to the server from a function. - -If the URI contains parameters (e.g. "resource://{param}") or the function -has parameters, it will be registered as a template resource. - -**Args:** -- `fn`: The function to register as a resource -- `uri`: The URI for the resource -- `name`: Optional name for the resource -- `description`: Optional description of the resource -- `mime_type`: Optional MIME type for the resource -- `tags`: Optional set of tags for categorizing the resource - - -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate] @@ -532,7 +495,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt) -> Prompt @@ -547,19 +510,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt @@ -637,7 +600,7 @@ Decorator to register a prompt. ``` -#### `run_stdio_async` +#### `run_stdio_async` ```python run_stdio_async(self, show_banner: bool = True, log_level: str | None = None) -> None @@ -650,7 +613,7 @@ Run the server using stdio transport. - `log_level`: Log level for the server -#### `run_http_async` +#### `run_http_async` ```python run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None) -> None @@ -670,43 +633,7 @@ Run the server using HTTP transport. - `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http) -#### `run_sse_async` - -```python -run_sse_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None -``` - -Run the server using SSE transport. - - -#### `sse_app` - -```python -sse_app(self, path: str | None = None, message_path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan -``` - -Create a Starlette app for the SSE server. - -**Args:** -- `path`: The path to the SSE endpoint -- `message_path`: The path to the message endpoint -- `middleware`: A list of middleware to apply to the app - - -#### `streamable_http_app` - -```python -streamable_http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan -``` - -Create a Starlette app for the StreamableHTTP server. - -**Args:** -- `path`: The path to the StreamableHTTP endpoint -- `middleware`: A list of middleware to apply to the app - - -#### `http_app` +#### `http_app` ```python http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http') -> StarletteWithLifespan @@ -723,7 +650,7 @@ Create a Starlette app using the specified HTTP transport. - A Starlette application configured with the specified transport -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None @@ -777,7 +704,7 @@ automatically determined based on whether the server has a custom lifespan - `prompt_separator`: Deprecated. Separator character for prompt names. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None) -> None @@ -818,7 +745,7 @@ applied using the protocol\://prefix/path format - `prompt_separator`: Deprecated. Separator for prompt names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -827,7 +754,7 @@ from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route Create a FastMCP server from an OpenAPI specification. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -836,7 +763,7 @@ from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] Create a FastMCP server from a FastAPI application. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -850,19 +777,10 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `from_client` - -```python -from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPProxy -``` - -Create a FastMCP proxy server from a FastMCP client. - - -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` -### `MountedServer` +### `MountedServer` diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index 5f9451a0d..716534ea1 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -7,27 +7,9 @@ sidebarTitle: settings ## Classes -### `ExtendedEnvSettingsSource` +### `ExperimentalSettings` - -A special EnvSettingsSource that allows for multiple env var prefixes to be used. - -Raises a deprecation warning if the old `FASTMCP_SERVER_` prefix is used. - - -**Methods:** - -#### `get_field_value` - -```python -get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool] -``` - -### `ExtendedSettingsConfigDict` - -### `ExperimentalSettings` - -### `Settings` +### `Settings` FastMCP settings. @@ -35,7 +17,7 @@ FastMCP settings. **Methods:** -#### `get_setting` +#### `get_setting` ```python get_setting(self, attr: str) -> Any @@ -45,7 +27,7 @@ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `set_setting` +#### `set_setting` ```python set_setting(self, attr: str, value: Any) -> None @@ -55,13 +37,7 @@ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `settings_customise_sources` - -```python -settings_customise_sources(cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource) -> tuple[PydanticBaseSettingsSource, ...] -``` - -#### `settings` +#### `settings` ```python settings(self) -> Self @@ -71,13 +47,13 @@ This property is for backwards compatibility with FastMCP < 2.8.0, which accessed fastmcp.settings.settings -#### `normalize_log_level` +#### `normalize_log_level` ```python normalize_log_level(cls, v) ``` -#### `server_auth_class` +#### `server_auth_class` ```python server_auth_class(self) -> AuthProvider | None diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx index b55865525..fff2e4657 100644 --- a/docs/python-sdk/fastmcp-tools-tool.mdx +++ b/docs/python-sdk/fastmcp-tools-tool.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool ## Functions -### `default_serializer` +### `default_serializer` ```python default_serializer(data: Any) -> str @@ -15,17 +15,17 @@ default_serializer(data: Any) -> str ## Classes -### `ToolResult` +### `ToolResult` **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ``` -### `Tool` +### `Tool` Internal tool registration info. @@ -33,19 +33,19 @@ Internal tool registration info. **Methods:** -#### `enable` +#### `enable` ```python enable(self) -> None ``` -#### `disable` +#### `disable` ```python disable(self) -> None ``` -#### `to_mcp_tool` +#### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> MCPTool @@ -54,16 +54,16 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool Convert the FastMCP tool to an MCP tool. -#### `from_function` +#### `from_function` ```python -from_function(fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool +from_function(fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool ``` Create a Tool from a function. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -78,20 +78,20 @@ implemented by subclasses. (list of ContentBlocks, dict of structured output). -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool) -> TransformedTool ``` -### `FunctionTool` +### `FunctionTool` **Methods:** -#### `from_function` +#### `from_function` ```python -from_function(cls, fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool +from_function(cls, fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool ``` Create a Tool from a function. @@ -106,11 +106,11 @@ run(self, arguments: dict[str, Any]) -> ToolResult Run the tool with arguments. -### `ParsedFunction` +### `ParsedFunction` **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True) -> ParsedFunction diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index c9c416543..8586fb4fc 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool_transform ## Functions -### `forward` +### `forward` ```python forward(**kwargs: Any) -> ToolResult @@ -36,7 +36,7 @@ tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to - `TypeError`: If provided arguments don't match the transformed schema. -### `forward_raw` +### `forward_raw` ```python forward_raw(**kwargs: Any) -> ToolResult @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -75,7 +75,7 @@ are left unchanged. ## Classes -### `ArgTransform` +### `ArgTransform` Configuration for transforming a parent tool's argument. @@ -137,7 +137,7 @@ ArgTransform(name="new_name", description="New desc", default=None, type=int) ``` -### `ArgTransformConfig` +### `ArgTransformConfig` A model for requesting a single argument transform. @@ -145,7 +145,7 @@ A model for requesting a single argument transform. **Methods:** -#### `to_arg_transform` +#### `to_arg_transform` ```python to_arg_transform(self) -> ArgTransform @@ -154,7 +154,7 @@ to_arg_transform(self) -> ArgTransform Convert the argument transform to a FastMCP argument transform. -### `TransformedTool` +### `TransformedTool` A tool that is transformed from another tool. @@ -171,7 +171,7 @@ inherited from the parent tool but can be overridden or disabled. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -190,10 +190,10 @@ functions. - ToolResult object containing content and optional structured output. -#### `from_tool` +#### `from_tool` ```python -from_tool(cls, tool: Tool, name: str | None = None, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet, enabled: bool | None = None) -> TransformedTool +from_tool(cls, tool: Tool, name: str | None = None, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet, enabled: bool | None = None) -> TransformedTool ``` Create a transformed tool from a parent tool. @@ -272,7 +272,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -280,7 +280,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/docs/python-sdk/fastmcp-utilities-logging.mdx b/docs/python-sdk/fastmcp-utilities-logging.mdx index fe976dcad..ef3264dbb 100644 --- a/docs/python-sdk/fastmcp-utilities-logging.mdx +++ b/docs/python-sdk/fastmcp-utilities-logging.mdx @@ -41,7 +41,7 @@ Configure logging for FastMCP. - `rich_kwargs`: the parameters to use for creating RichHandler -### `temporary_log_level` +### `temporary_log_level` ```python temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index 23f694228..0026885eb 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -64,7 +64,20 @@ Find the name of the kwarg that is of type kwarg_type. Includes union types that contain the kwarg_type, as well as Annotated types. -### `replace_type` +### `create_function_without_params` + +```python +create_function_without_params(fn: Callable[..., Any], exclude_params: list[str]) -> Callable[..., Any] +``` + + +Create a new function with the same code but without the specified parameters in annotations. + +This is used to exclude parameters from type adapter processing when they can't be serialized. +The excluded parameters are removed from the function's __annotations__ dictionary. + + +### `replace_type` ```python replace_type(type_, type_map: dict[type, type]) @@ -99,7 +112,7 @@ list[list[str]] Base model for FastMCP models. -### `Image` +### `Image` Helper class for returning images from tools. @@ -107,7 +120,7 @@ Helper class for returning images from tools. **Methods:** -#### `to_image_content` +#### `to_image_content` ```python to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.ImageContent @@ -116,7 +129,7 @@ to_image_content(self, mime_type: str | None = None, annotations: Annotations | Convert to MCP ImageContent. -#### `to_data_uri` +#### `to_data_uri` ```python to_data_uri(self, mime_type: str | None = None) -> str @@ -125,7 +138,7 @@ to_data_uri(self, mime_type: str | None = None) -> str Get image as a data URI. -### `Audio` +### `Audio` Helper class for returning audio from tools. @@ -133,13 +146,13 @@ Helper class for returning audio from tools. **Methods:** -#### `to_audio_content` +#### `to_audio_content` ```python to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.AudioContent ``` -### `File` +### `File` Helper class for returning file data from tools. @@ -147,10 +160,10 @@ Helper class for returning file data from tools. **Methods:** -#### `to_resource_content` +#### `to_resource_content` ```python to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.EmbeddedResource ``` -### `ContextSamplingFallbackProtocol` +### `ContextSamplingFallbackProtocol` diff --git a/docs/python-sdk/fastmcp-utilities-ui.mdx b/docs/python-sdk/fastmcp-utilities-ui.mdx index 09f5a425c..5066eab5e 100644 --- a/docs/python-sdk/fastmcp-utilities-ui.mdx +++ b/docs/python-sdk/fastmcp-utilities-ui.mdx @@ -28,13 +28,14 @@ Create a complete HTML page with FastMCP styling. - `content`: HTML content to place inside the page - `title`: Page title - `additional_styles`: Extra CSS to include -- `csp_policy`: Content Security Policy header value +- `csp_policy`: Content Security Policy header value. +If empty string "", the CSP meta tag is omitted entirely. **Returns:** - Complete HTML page as string -### `create_logo` +### `create_logo` ```python create_logo(icon_url: str | None = None, alt_text: str = 'FastMCP') -> str @@ -51,7 +52,7 @@ Create logo HTML. - HTML for logo image tag. -### `create_status_message` +### `create_status_message` ```python create_status_message(message: str, is_success: bool = True) -> str @@ -68,7 +69,7 @@ Create a status message with icon. - HTML for status message -### `create_info_box` +### `create_info_box` ```python create_info_box(content: str, is_error: bool = False, centered: bool = False, monospace: bool = False) -> str @@ -87,7 +88,7 @@ Create an info box. - HTML for info box -### `create_detail_box` +### `create_detail_box` ```python create_detail_box(rows: list[tuple[str, str]]) -> str @@ -103,7 +104,7 @@ Create a detail box with key-value pairs. - HTML for detail box -### `create_button_group` +### `create_button_group` ```python create_button_group(buttons: list[tuple[str, str, str]]) -> str @@ -119,7 +120,7 @@ Create a group of buttons. - HTML for button group -### `create_secure_html_response` +### `create_secure_html_response` ```python create_secure_html_response(html: str, status_code: int = 200) -> HTMLResponse diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 652e487fb..9e8fed2c9 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -299,6 +299,24 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients. + + + Content Security Policy for the consent page. + + - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission + - Empty string `""`: Disables CSP entirely (no meta tag rendered) + - Custom string: Uses the provided value as the CSP policy + + This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives. + + ```python + # Disable CSP entirely (let org CSP policies apply) + auth = OAuthProxy(..., consent_csp_policy="") + + # Use custom CSP policy + auth = OAuthProxy(..., consent_csp_policy="default-src 'self'; style-src 'unsafe-inline'") + ``` + ### Using Built-in Providers diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index df25ca1ea..729f9884d 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -191,6 +191,20 @@ auth = OIDCProxy( ``` + + + Whether to require user consent before authorizing MCP clients. When enabled (default), users see a consent screen that displays which client is requesting access. See [OAuthProxy documentation](/servers/auth/oauth-proxy#confused-deputy-attacks) for details on confused deputy attack protection. + + + + Content Security Policy for the consent page. + + - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission + - Empty string `""`: Disables CSP entirely (no meta tag rendered) + - Custom string: Uses the provided value as the CSP policy + + This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives. + ### Using Built-in Providers diff --git a/docs/servers/icons.mdx b/docs/servers/icons.mdx index 838dd8739..a7d038f6a 100644 --- a/docs/servers/icons.mdx +++ b/docs/servers/icons.mdx @@ -7,7 +7,7 @@ tag: NEW import { VersionBadge } from '/snippets/version-badge.mdx' - + Icons provide visual representations for your MCP servers and components, helping client applications present better user interfaces. When displayed in MCP clients, icons help users quickly identify and navigate your server's capabilities. diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index c122daa2b..710b35ce3 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -98,7 +98,7 @@ def data_analysis_prompt( - + Optional list of icon representations for this prompt. See [Icons](/servers/icons) for detailed examples diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index d3c8e3cdd..86c043d35 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -107,7 +107,7 @@ def get_application_status() -> dict: - + Optional list of icon representations for this resource or template. See [Icons](/servers/icons) for detailed examples diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index 7f013f2dc..8d8d362f8 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -45,13 +45,13 @@ The `FastMCP` constructor accepts several arguments: - + URL to a website with more information about your server. Displayed in client applications - + List of icon representations for your server. Icons help users visually identify your server in client applications. See [Icons](/servers/icons) for detailed examples diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 92812378d..553f257dd 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -82,7 +82,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l - + Optional list of icon representations for this tool. See [Icons](/servers/icons) for detailed examples diff --git a/examples/auth/discord_oauth/README.md b/examples/auth/discord_oauth/README.md new file mode 100644 index 000000000..74217f833 --- /dev/null +++ b/examples/auth/discord_oauth/README.md @@ -0,0 +1,33 @@ +# Discord OAuth Example + +Demonstrates FastMCP server protection with Discord OAuth. + +## Setup + +1. Create a Discord OAuth App: + - Go to https://discord.com/developers/applications + - Click "New Application" and give it a name + - Go to OAuth2 in the left sidebar + - Add a Redirect URL: `http://localhost:8000/auth/callback` + - Copy the Client ID and Client Secret + +2. Set environment variables: + + ```bash + export FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID="your-client-id" + export FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET="your-client-secret" + ``` + +3. Run the server: + + ```bash + python server.py + ``` + +4. In another terminal, run the client: + + ```bash + python client.py + ``` + +The client will open your browser for Discord authentication. diff --git a/examples/auth/discord_oauth/client.py b/examples/auth/discord_oauth/client.py new file mode 100644 index 000000000..880b86f4d --- /dev/null +++ b/examples/auth/discord_oauth/client.py @@ -0,0 +1,32 @@ +"""Discord OAuth client example for connecting to FastMCP servers. + +This example demonstrates how to connect to a Discord OAuth-protected FastMCP server. + +To run: + python client.py +""" + +import asyncio + +from fastmcp.client import Client + +SERVER_URL = "http://127.0.0.1:8000/mcp" + + +async def main(): + try: + async with Client(SERVER_URL, auth="oauth") as client: + assert await client.ping() + print("✅ Successfully authenticated!") + + tools = await client.list_tools() + print(f"🔧 Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + except Exception as e: + print(f"❌ Authentication failed: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auth/discord_oauth/server.py b/examples/auth/discord_oauth/server.py new file mode 100644 index 000000000..424c97bdb --- /dev/null +++ b/examples/auth/discord_oauth/server.py @@ -0,0 +1,35 @@ +"""Discord OAuth server example for FastMCP. + +This example demonstrates how to protect a FastMCP server with Discord OAuth. + +Required environment variables: +- FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID: Your Discord OAuth app client ID +- FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET: Your Discord OAuth app client secret + +To run: + python server.py +""" + +import os + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.discord import DiscordProvider + +auth = DiscordProvider( + client_id=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID") or "", + client_secret=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET") or "", + base_url="http://localhost:8000", + # redirect_path="/auth/callback", # Default path - change if using a different callback URL +) + +mcp = FastMCP("Discord OAuth Example Server", auth=auth) + + +@mcp.tool +def echo(message: str) -> str: + """Echo the provided message.""" + return message + + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index c526ddcd3..a7acf1368 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -179,6 +179,28 @@ class JTIMapping(BaseModel): created_at: float # Unix timestamp +class RefreshTokenMetadata(BaseModel): + """Metadata for a refresh token, stored keyed by token hash. + + We store only metadata (not the token itself) for security - if storage + is compromised, attackers get hashes they can't reverse into usable tokens. + """ + + client_id: str + scopes: list[str] + expires_at: int | None = None + created_at: float + + +def _hash_token(token: str) -> str: + """Hash a token for secure storage lookup. + + Uses SHA-256 to create a one-way hash. The original token cannot be + recovered from the hash, providing defense in depth if storage is compromised. + """ + return hashlib.sha256(token.encode()).hexdigest() + + class ProxyDCRClient(OAuthClientInformationFull): """Client for DCR proxy with configurable redirect URI validation. @@ -246,8 +268,16 @@ def create_consent_html( server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, + csp_policy: str | None = None, ) -> str: - """Create a styled HTML consent page for OAuth authorization requests.""" + """Create a styled HTML consent page for OAuth authorization requests. + + Args: + csp_policy: Content Security Policy override. + If None, uses the built-in CSP policy with appropriate directives. + If empty string "", disables CSP entirely (no meta tag is rendered). + If a non-empty string, uses that as the CSP policy value. + """ import html as html_module client_display = html_module.escape(client_name or client_id) @@ -368,20 +398,25 @@ def create_consent_html( + TOOLTIP_STYLES ) - # Need to allow form-action for form submission - # Chrome requires explicit scheme declarations in CSP form-action when redirect chains - # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme. - parsed_redirect = urlparse(redirect_uri) - redirect_scheme = parsed_redirect.scheme.lower() + # Determine CSP policy to use + # If csp_policy is None, build the default CSP policy + # If csp_policy is empty string, CSP will be disabled entirely in create_page + # If csp_policy is a non-empty string, use it as-is + if csp_policy is None: + # Need to allow form-action for form submission + # Chrome requires explicit scheme declarations in CSP form-action when redirect chains + # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme. + parsed_redirect = urlparse(redirect_uri) + redirect_scheme = parsed_redirect.scheme.lower() - # Build form-action directive with standard schemes plus custom protocol if present - form_action_schemes = ["https:", "http:"] - if redirect_scheme and redirect_scheme not in ("http", "https"): - # Custom protocol scheme (e.g., cursor:, vscode:, etc.) - form_action_schemes.append(f"{redirect_scheme}:") + # Build form-action directive with standard schemes plus custom protocol if present + form_action_schemes = ["https:", "http:"] + if redirect_scheme and redirect_scheme not in ("http", "https"): + # Custom protocol scheme (e.g., cursor:, vscode:, etc.) + form_action_schemes.append(f"{redirect_scheme}:") - form_action_directive = " ".join(form_action_schemes) - csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}" + form_action_directive = " ".join(form_action_schemes) + csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}" return create_page( content=content, @@ -611,14 +646,18 @@ class OAuthProxy(OAuthProvider): State Management --------------- - The proxy maintains minimal but crucial state: + The proxy maintains minimal but crucial state via pluggable storage (client_storage): - _oauth_transactions: Active authorization flows with client context - _client_codes: Authorization codes with PKCE challenges and upstream tokens - - _access_tokens, _refresh_tokens: Token storage for revocation - - Token relationship mappings for cleanup and rotation + - _jti_mapping_store: Maps FastMCP token JTIs to upstream token IDs + - _refresh_token_store: Refresh token metadata (keyed by token hash) + + All state is stored in the configured client_storage backend (Redis, disk, etc.) + enabling horizontal scaling across multiple instances. Security Considerations ---------------------- + - Refresh tokens stored by hash only (defense in depth if storage compromised) - PKCE enforced end-to-end (client to proxy, proxy to upstream) - Authorization codes are single-use with short expiry - Transaction IDs are cryptographically random @@ -672,6 +711,7 @@ class OAuthProxy(OAuthProvider): jwt_signing_key: str | bytes | None = None, # Consent screen configuration require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, ): """Initialize the OAuth proxy provider. @@ -715,6 +755,12 @@ class OAuthProxy(OAuthProvider): When True, users see a consent screen before being redirected to the upstream IdP. When False, authorization proceeds directly without user confirmation. SECURITY WARNING: Only disable for local development or testing environments. + consent_csp_policy: Content Security Policy for the consent page. + If None (default), uses the built-in CSP policy with appropriate directives. + If empty string "", disables CSP entirely (no meta tag is rendered). + If a non-empty string, uses that as the CSP policy value. + This allows organizations with their own CSP policies to override or disable + the built-in CSP directives. """ # Always enable DCR since we implement it locally for MCP clients @@ -775,6 +821,7 @@ class OAuthProxy(OAuthProvider): # Consent screen configuration self._require_authorization_consent: bool = require_authorization_consent + self._consent_csp_policy: str | None = consent_csp_policy if not require_authorization_consent: logger.warning( "Authorization consent screen disabled - only use for local development or testing. " @@ -874,13 +921,17 @@ class OAuthProxy(OAuthProvider): raise_on_validation_error=True, ) - # Local state for token bookkeeping only (no client caching) - self._access_tokens: dict[str, AccessToken] = {} - self._refresh_tokens: dict[str, RefreshToken] = {} - - # Token relation mappings for cleanup - self._access_to_refresh: dict[str, str] = {} - self._refresh_to_access: dict[str, str] = {} + # Refresh token metadata storage, keyed by token hash for security. + # We only store metadata (not the token itself) - if storage is compromised, + # attackers get hashes they can't reverse into usable tokens. + self._refresh_token_store: PydanticAdapter[RefreshTokenMetadata] = ( + PydanticAdapter[RefreshTokenMetadata]( + key_value=self._client_storage, + pydantic_model=RefreshTokenMetadata, + default_collection="mcp-refresh-tokens", + raise_on_validation_error=True, + ) + ) # Use the provided token validator self._token_validator: TokenVerifier = token_verifier @@ -1233,25 +1284,18 @@ class OAuthProxy(OAuthProvider): ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days) ) - # Store FastMCP access token for MCP framework validation - self._access_tokens[fastmcp_access_token] = AccessToken( - token=fastmcp_access_token, - client_id=client.client_id, - scopes=authorization_code.scopes, - expires_at=int(time.time() + expires_in), - ) - - # Store FastMCP refresh token if provided - if fastmcp_refresh_token: - self._refresh_tokens[fastmcp_refresh_token] = RefreshToken( - token=fastmcp_refresh_token, - client_id=client.client_id, - scopes=authorization_code.scopes, - expires_at=None, + # Store refresh token metadata (keyed by hash for security) + if fastmcp_refresh_token and refresh_expires_in: + await self._refresh_token_store.put( + key=_hash_token(fastmcp_refresh_token), + value=RefreshTokenMetadata( + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=int(time.time()) + refresh_expires_in, + created_at=time.time(), + ), + ttl=refresh_expires_in, ) - # Maintain token relationships for cleanup - self._access_to_refresh[fastmcp_access_token] = fastmcp_refresh_token - self._refresh_to_access[fastmcp_refresh_token] = fastmcp_access_token logger.debug( "Issued FastMCP tokens for client=%s (access_jti=%s, refresh_jti=%s)", @@ -1295,8 +1339,29 @@ class OAuthProxy(OAuthProvider): client: OAuthClientInformationFull, refresh_token: str, ) -> RefreshToken | None: - """Load refresh token from local storage.""" - return self._refresh_tokens.get(refresh_token) + """Load refresh token metadata from distributed storage. + + Looks up by token hash and reconstructs the RefreshToken object. + Validates that the token belongs to the requesting client. + """ + token_hash = _hash_token(refresh_token) + metadata = await self._refresh_token_store.get(key=token_hash) + if not metadata: + return None + # Verify token belongs to this client (prevents cross-client token usage) + if metadata.client_id != client.client_id: + logger.warning( + "Refresh token client_id mismatch: expected %s, got %s", + client.client_id, + metadata.client_id, + ) + return None + return RefreshToken( + token=refresh_token, + client_id=metadata.client_id, + scopes=metadata.scopes, + expires_at=metadata.expires_at, + ) async def exchange_refresh_token( self, @@ -1467,30 +1532,20 @@ class OAuthProxy(OAuthProvider): "Rotated refresh token (old JTI invalidated - one-time use enforced)" ) - # Update local token tracking - self._access_tokens[new_fastmcp_access] = AccessToken( - token=new_fastmcp_access, - client_id=client.client_id, - scopes=scopes, - expires_at=int(time.time() + new_expires_in), - ) - self._refresh_tokens[new_fastmcp_refresh] = RefreshToken( - token=new_fastmcp_refresh, - client_id=client.client_id, - scopes=scopes, - expires_at=None, + # Store new refresh token metadata (keyed by hash) + await self._refresh_token_store.put( + key=_hash_token(new_fastmcp_refresh), + value=RefreshTokenMetadata( + client_id=client.client_id, + scopes=scopes, + expires_at=int(time.time()) + refresh_ttl, + created_at=time.time(), + ), + ttl=refresh_ttl, ) - # Update token relationship mappings - self._access_to_refresh[new_fastmcp_access] = new_fastmcp_refresh - self._refresh_to_access[new_fastmcp_refresh] = new_fastmcp_access - - # Clean up old token from in-memory tracking - self._refresh_tokens.pop(refresh_token.token, None) - old_access = self._refresh_to_access.pop(refresh_token.token, None) - if old_access: - self._access_tokens.pop(old_access, None) - self._access_to_refresh.pop(old_access, None) + # Delete old refresh token (by hash) + await self._refresh_token_store.delete(key=_hash_token(refresh_token.token)) logger.info( "Issued new FastMCP tokens (rotated refresh) for client=%s (access_jti=%s, refresh_jti=%s)", @@ -1571,24 +1626,13 @@ class OAuthProxy(OAuthProvider): async def revoke_token(self, token: AccessToken | RefreshToken) -> None: """Revoke token locally and with upstream server if supported. - Removes tokens from local storage and attempts to revoke them with - the upstream server if a revocation endpoint is configured. + For refresh tokens, removes from local storage by hash. + For all tokens, attempts upstream revocation if endpoint is configured. + Access token JTI mappings expire via TTL. """ - # Clean up local token storage - if isinstance(token, AccessToken): - self._access_tokens.pop(token.token, None) - # Also remove associated refresh token - paired_refresh = self._access_to_refresh.pop(token.token, None) - if paired_refresh: - self._refresh_tokens.pop(paired_refresh, None) - self._refresh_to_access.pop(paired_refresh, None) - else: # RefreshToken - self._refresh_tokens.pop(token.token, None) - # Also remove associated access token - paired_access = self._refresh_to_access.pop(token.token, None) - if paired_access: - self._access_tokens.pop(paired_access, None) - self._access_to_refresh.pop(paired_access, None) + # For refresh tokens, delete from local storage by hash + if isinstance(token, RefreshToken): + await self._refresh_token_store.delete(key=_hash_token(token.token)) # Attempt upstream revocation if endpoint is configured if self._upstream_revocation_endpoint: @@ -2106,6 +2150,7 @@ class OAuthProxy(OAuthProvider): server_name=server_name, server_icon_url=server_icon_url, server_website_url=server_website_url, + csp_policy=self._consent_csp_policy, ) response = create_secure_html_response(html) # Store CSRF in cookie with short lifetime diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 95b5e9b0e..0fc801107 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -222,6 +222,7 @@ class OIDCProxy(OAuthProxy): token_endpoint_auth_method: str | None = None, # Consent screen configuration require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, # Extra parameters extra_authorize_params: dict[str, str] | None = None, extra_token_params: dict[str, str] | None = None, @@ -262,6 +263,10 @@ class OIDCProxy(OAuthProxy): When True, users see a consent screen before being redirected to the upstream IdP. When False, authorization proceeds directly without user confirmation. SECURITY WARNING: Only disable for local development or testing environments. + consent_csp_policy: Content Security Policy for the consent page. + If None (default), uses the built-in CSP policy with appropriate directives. + If empty string "", disables CSP entirely (no meta tag is rendered). + If a non-empty string, uses that as the CSP policy value. extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. Useful for provider-specific parameters like prompt=consent or access_type=offline. Example: {"prompt": "consent", "access_type": "offline"} @@ -338,6 +343,7 @@ class OIDCProxy(OAuthProxy): "jwt_signing_key": jwt_signing_key, "token_endpoint_auth_method": token_endpoint_auth_method, "require_authorization_consent": require_authorization_consent, + "consent_csp_policy": consent_csp_policy, } if redirect_path: diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index c216e351a..591919fa8 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -25,6 +25,11 @@ if TYPE_CHECKING: logger = get_logger(__name__) +# Standard OIDC scopes that should never be prefixed with identifier_uri. +# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc +# "OIDC scopes are requested as simple string identifiers without resource prefixes" +OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"}) + class AzureProviderSettings(BaseSettings): """Settings for Azure OAuth provider.""" @@ -240,13 +245,25 @@ class AzureProvider(OAuthProxy): f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys" ) - # Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes + # Azure access tokens only include custom API scopes in the `scp` claim, + # NOT standard OIDC scopes (openid, profile, email, offline_access). + # Filter out OIDC scopes from validation - they'll still be sent to Azure + # during authorization (handled by _prefix_scopes_for_azure). + validation_scopes = None + if settings.required_scopes: + validation_scopes = [ + s for s in settings.required_scopes if s not in OIDC_SCOPES + ] + # If all scopes were OIDC scopes, use None (no scope validation) + if not validation_scopes: + validation_scopes = None + token_verifier = JWTVerifier( jwks_uri=jwks_uri, issuer=issuer, audience=settings.client_id, algorithm="RS256", - required_scopes=settings.required_scopes, # Unprefixed scopes for validation + required_scopes=validation_scopes, # Only validate non-OIDC scopes ) # Extract secret string from SecretStr @@ -277,6 +294,8 @@ class AzureProvider(OAuthProxy): client_storage=client_storage, jwt_signing_key=settings.jwt_signing_key, require_authorization_consent=require_authorization_consent, + # Advertise full scopes including OIDC (even though we only validate non-OIDC) + valid_scopes=settings.required_scopes, ) authority_info = "" @@ -328,11 +347,20 @@ class AzureProvider(OAuthProxy): return f"{auth_url}{separator}prompt=select_account" def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]: - """Prefix unprefixed scopes with identifier_uri for Azure. + """Prefix unprefixed custom API scopes with identifier_uri for Azure. This helper centralizes the scope prefixing logic used in both authorization and token refresh flows. + Scopes that are NOT prefixed: + - Standard OIDC scopes (openid, profile, email, offline_access) + - Fully-qualified URIs (contain "://") + - Scopes with path component (contain "/") + + Note: Microsoft Graph scopes (e.g., User.Read) should be passed via + `additional_authorize_scopes` or use fully-qualified format + (e.g., https://graph.microsoft.com/User.Read). + Args: scopes: List of scopes, may be prefixed or unprefixed @@ -341,11 +369,15 @@ class AzureProvider(OAuthProxy): """ prefixed = [] for scope in scopes: - if "://" in scope or "/" in scope: - # Already fully-qualified (e.g., "api://xxx/read" or "User.Read") + if scope in OIDC_SCOPES: + # Standard OIDC scopes - never prefix + prefixed.append(scope) + elif "://" in scope or "/" in scope: + # Already fully-qualified (e.g., "api://xxx/read" or + # "https://graph.microsoft.com/User.Read") prefixed.append(scope) else: - # Unprefixed client scope - prefix with identifier_uri + # Unprefixed custom API scope - prefix with identifier_uri prefixed.append(f"{self.identifier_uri}/{scope}") return prefixed diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py new file mode 100644 index 000000000..f92a378bc --- /dev/null +++ b/src/fastmcp/server/auth/providers/discord.py @@ -0,0 +1,308 @@ +"""Discord OAuth provider for FastMCP. + +This module provides a complete Discord OAuth integration that's ready to use +with just a client ID and client secret. It handles all the complexity of +Discord's OAuth flow, token validation, and user management. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.discord import DiscordProvider + + # Simple Discord OAuth protection + auth = DiscordProvider( + client_id="your-discord-client-id", + client_secret="your-discord-client-secret" + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import time +from datetime import datetime + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.settings import ENV_FILE +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import NotSet, NotSetT + +logger = get_logger(__name__) + + +class DiscordProviderSettings(BaseSettings): + """Settings for Discord OAuth provider.""" + + model_config = SettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_DISCORD_", + env_file=ENV_FILE, + extra="ignore", + ) + + client_id: str | None = None + client_secret: SecretStr | None = None + base_url: AnyHttpUrl | str | None = None + issuer_url: AnyHttpUrl | str | None = None + redirect_path: str | None = None + required_scopes: list[str] | None = None + timeout_seconds: int | None = None + allowed_client_redirect_uris: list[str] | None = None + jwt_signing_key: str | None = None + + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + + +class DiscordTokenVerifier(TokenVerifier): + """Token verifier for Discord OAuth tokens. + + Discord OAuth tokens are opaque (not JWTs), so we verify them + by calling Discord's tokeninfo API to check if they're valid and get user info. + """ + + def __init__( + self, + *, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + ): + """Initialize the Discord token verifier. + + Args: + required_scopes: Required OAuth scopes (e.g., ['email']) + timeout_seconds: HTTP request timeout + """ + super().__init__(required_scopes=required_scopes) + self.timeout_seconds = timeout_seconds + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify Discord OAuth token by calling Discord's tokeninfo API.""" + try: + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + # Use Discord's tokeninfo endpoint to validate the token + headers = { + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Discord-OAuth", + } + response = await client.get( + "https://discord.com/api/oauth2/@me", + headers=headers, + ) + + if response.status_code != 200: + logger.debug( + "Discord token verification failed: %d", + response.status_code, + ) + return None + + token_info = response.json() + + # Check if token is expired (Discord returns ISO timestamp) + expires_str = token_info.get("expires") + expires_at = None + if expires_str: + expires_dt = datetime.fromisoformat( + expires_str.replace("Z", "+00:00") + ) + expires_at = int(expires_dt.timestamp()) + if expires_at <= int(time.time()): + logger.debug("Discord token has expired") + return None + + token_scopes = token_info.get("scopes", []) + + # Check required scopes + if self.required_scopes: + token_scopes_set = set(token_scopes) + required_scopes_set = set(self.required_scopes) + if not required_scopes_set.issubset(token_scopes_set): + logger.debug( + "Discord token missing required scopes. Has %d, needs %d", + len(token_scopes_set), + len(required_scopes_set), + ) + return None + + user_data = token_info.get("user", {}) + application = token_info.get("application") or {} + client_id = str(application.get("id", "unknown")) + + # Create AccessToken with Discord user info + access_token = AccessToken( + token=token, + client_id=client_id, + scopes=token_scopes, + expires_at=expires_at, + claims={ + "sub": user_data.get("id"), + "username": user_data.get("username"), + "discriminator": user_data.get("discriminator"), + "avatar": user_data.get("avatar"), + "email": user_data.get("email"), + "verified": user_data.get("verified"), + "locale": user_data.get("locale"), + "discord_user": user_data, + "discord_token_info": token_info, + }, + ) + logger.debug("Discord token verified successfully") + return access_token + + except httpx.RequestError as e: + logger.debug("Failed to verify Discord token: %s", e) + return None + except Exception as e: + logger.debug("Discord token verification error: %s", e) + return None + + +class DiscordProvider(OAuthProxy): + """Complete Discord OAuth provider for FastMCP. + + This provider makes it trivial to add Discord OAuth protection to any + FastMCP server. Just provide your Discord OAuth app credentials and + a base URL, and you're ready to go. + + Features: + - Transparent OAuth proxy to Discord + - Automatic token validation via Discord's API + - User information extraction from Discord APIs + - Minimal configuration required + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.discord import DiscordProvider + + auth = DiscordProvider( + client_id="123456789", + client_secret="discord-client-secret-abc123...", + base_url="https://my-server.com" + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + client_id: str | NotSetT = NotSet, + client_secret: str | NotSetT = NotSet, + base_url: AnyHttpUrl | str | NotSetT = NotSet, + issuer_url: AnyHttpUrl | str | NotSetT = NotSet, + redirect_path: str | NotSetT = NotSet, + required_scopes: list[str] | NotSetT = NotSet, + timeout_seconds: int | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | NotSetT = NotSet, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | NotSetT = NotSet, + require_authorization_consent: bool = True, + ): + """Initialize Discord OAuth provider. + + Args: + client_id: Discord OAuth client ID (e.g., "123456789") + client_secret: Discord OAuth client secret (e.g., "S....") + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback") + required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include: + - "identify" for profile info (default) + - "email" for email access + - "guilds" for server membership info + timeout_seconds: HTTP request timeout for Discord API calls + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The + disk store will be encrypted using a key derived from the JWT Signing Key. + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to Discord. + When False, authorization proceeds directly without user confirmation. + SECURITY WARNING: Only disable for local development or testing environments. + """ + + settings = DiscordProviderSettings.model_validate( + { + k: v + for k, v in { + "client_id": client_id, + "client_secret": client_secret, + "base_url": base_url, + "issuer_url": issuer_url, + "redirect_path": redirect_path, + "required_scopes": required_scopes, + "timeout_seconds": timeout_seconds, + "allowed_client_redirect_uris": allowed_client_redirect_uris, + "jwt_signing_key": jwt_signing_key, + }.items() + if v is not NotSet + } + ) + + # Validate required settings + if not settings.client_id: + raise ValueError( + "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID" + ) + if not settings.client_secret: + raise ValueError( + "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET" + ) + + # Apply defaults + timeout_seconds_final = settings.timeout_seconds or 10 + required_scopes_final = settings.required_scopes or ["identify"] + allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris + + # Create Discord token verifier + token_verifier = DiscordTokenVerifier( + required_scopes=required_scopes_final, + timeout_seconds=timeout_seconds_final, + ) + + # Extract secret string from SecretStr + client_secret_str = ( + settings.client_secret.get_secret_value() if settings.client_secret else "" + ) + + # Initialize OAuth proxy with Discord endpoints + super().__init__( + upstream_authorization_endpoint="https://discord.com/oauth2/authorize", + upstream_token_endpoint="https://discord.com/api/oauth2/token", + upstream_client_id=settings.client_id, + upstream_client_secret=client_secret_str, + token_verifier=token_verifier, + base_url=settings.base_url, + redirect_path=settings.redirect_path, + issuer_url=settings.issuer_url + or settings.base_url, # Default to base_url if not specified + allowed_client_redirect_uris=allowed_client_redirect_uris_final, + client_storage=client_storage, + jwt_signing_key=settings.jwt_signing_key, + require_authorization_consent=require_authorization_consent, + ) + + logger.debug( + "Initialized Discord OAuth provider for client %s with scopes: %s", + settings.client_id, + required_scopes_final, + ) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 7b3386b42..68f22e606 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from mcp.server.auth.middleware.auth_context import ( get_access_token as _sdk_get_access_token, ) +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) @@ -111,17 +112,38 @@ def get_access_token() -> AccessToken | None: """ Get the FastMCP access token from the current context. + This function first tries to get the token from the current HTTP request's scope, + which is more reliable for long-lived connections where the SDK's auth_context_var + may become stale after token refresh. Falls back to the SDK's context var if no + request is available. + Returns: The access token if an authenticated user is available, None otherwise. """ - # - access_token: _SDKAccessToken | None = _sdk_get_access_token() + access_token: _SDKAccessToken | None = None + + # First, try to get from current HTTP request's scope (issue #1863) + # This is more reliable than auth_context_var for Streamable HTTP sessions + # where tokens may be refreshed between MCP messages + try: + request = get_http_request() + user = request.scope.get("user") + if isinstance(user, AuthenticatedUser): + access_token = user.access_token + except RuntimeError: + # No HTTP request available, fall back to context var + pass + + # Fall back to SDK's context var if we didn't get a token from the request + if access_token is None: + access_token = _sdk_get_access_token() if access_token is None or isinstance(access_token, AccessToken): return access_token - # If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible - # This is a workaround for the case where the SDK returns a different type + # If the object is not a FastMCP AccessToken, convert it to one if the + # fields are compatible (e.g. `claims` is not present in the SDK's AccessToken). + # This is a workaround for the case where the SDK or auth provider returns a different type # If it fails, it will raise a TypeError try: access_token_as_dict = access_token.model_dump() diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 1310f2335..90798a8c5 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -268,10 +268,12 @@ class ProxyTool(Tool, MirroredComponent): return cls( client=client, name=mcp_tool.name, + title=mcp_tool.title, description=mcp_tool.description, parameters=mcp_tool.inputSchema, annotations=mcp_tool.annotations, output_schema=mcp_tool.outputSchema, + icons=mcp_tool.icons, meta=mcp_tool.meta, tags=(mcp_tool.meta or {}).get("_fastmcp", {}).get("tags", []), _mirrored=True, @@ -327,8 +329,10 @@ class ProxyResource(Resource, MirroredComponent): client=client, uri=mcp_resource.uri, name=mcp_resource.name, + title=mcp_resource.title, description=mcp_resource.description, mime_type=mcp_resource.mimeType or "text/plain", + icons=mcp_resource.icons, meta=mcp_resource.meta, tags=(mcp_resource.meta or {}).get("_fastmcp", {}).get("tags", []), _mirrored=True, @@ -367,8 +371,10 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent): client=client, uri_template=mcp_template.uriTemplate, name=mcp_template.name, + title=mcp_template.title, description=mcp_template.description, mime_type=mcp_template.mimeType or "text/plain", + icons=mcp_template.icons, parameters={}, # Remote templates don't have local parameters meta=mcp_template.meta, tags=(mcp_template.meta or {}).get("_fastmcp", {}).get("tags", []), @@ -402,8 +408,10 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent): client=self._client, uri=parameterized_uri, name=self.name, + title=self.title, description=self.description, mime_type=result[0].mimeType, + icons=self.icons, meta=self.meta, tags=(self.meta or {}).get("_fastmcp", {}).get("tags", []), _value=value, @@ -437,8 +445,10 @@ class ProxyPrompt(Prompt, MirroredComponent): return cls( client=client, name=mcp_prompt.name, + title=mcp_prompt.title, description=mcp_prompt.description, arguments=arguments, + icons=mcp_prompt.icons, meta=mcp_prompt.meta, tags=(mcp_prompt.meta or {}).get("_fastmcp", {}).get("tags", []), _mirrored=True, diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index afa3d30d9..62ade0cbb 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect from collections.abc import Callable from contextvars import ContextVar +from copy import deepcopy from dataclasses import dataclass from typing import Annotated, Any, Literal, cast @@ -609,7 +610,8 @@ class TransformedTool(Tool): """ # Build transformed schema and mapping - parent_defs = parent_tool.parameters.get("$defs", {}) + # Deep copy to prevent compress_schema from mutating parent tool's $defs + parent_defs = deepcopy(parent_tool.parameters.get("$defs", {})) parent_props = parent_tool.parameters.get("properties", {}).copy() parent_required = set(parent_tool.parameters.get("required", [])) diff --git a/src/fastmcp/utilities/ui.py b/src/fastmcp/utilities/ui.py index 2ada4c83b..8baacddf2 100644 --- a/src/fastmcp/utilities/ui.py +++ b/src/fastmcp/utilities/ui.py @@ -463,12 +463,21 @@ def create_page( content: HTML content to place inside the page title: Page title additional_styles: Extra CSS to include - csp_policy: Content Security Policy header value + csp_policy: Content Security Policy header value. + If empty string "", the CSP meta tag is omitted entirely. Returns: Complete HTML page as string """ title = html.escape(title) + + # Only include CSP meta tag if policy is non-empty + csp_meta = ( + f'' + if csp_policy + else "" + ) + return f""" @@ -480,7 +489,7 @@ def create_page( {BASE_STYLES} {additional_styles} - + {csp_meta} {content} diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 70098725a..b8ead4655 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -9,7 +9,7 @@ from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider class TestAzureProvider: @@ -288,7 +288,7 @@ class TestAzureProvider: redirect_uri_provided_explicitly=True, scopes=[ "read", - "profile", + "write", ], # Client sends unprefixed scopes (from PRM which advertises unprefixed) state="abc", code_challenge="xyz", @@ -307,7 +307,7 @@ class TestAzureProvider: transaction = await provider._transaction_store.get(key=txn_id) assert transaction is not None assert "read" in transaction.scopes - assert "profile" in transaction.scopes + assert "write" in transaction.scopes # Azure provider filters resource parameter (not stored in transaction) assert transaction.resource is None @@ -320,8 +320,8 @@ class TestAzureProvider: or "api://my-api/read" in upstream_url ) assert ( - "api%3A%2F%2Fmy-api%2Fprofile" in upstream_url - or "api://my-api/profile" in upstream_url + "api%3A%2F%2Fmy-api%2Fwrite" in upstream_url + or "api://my-api/write" in upstream_url ) async def test_authorize_appends_additional_scopes(self): @@ -709,3 +709,173 @@ class TestAzureProvider: # Should only have 2 items (read processed twice, but deduplicated) assert len(result) == 2 assert result.count("api://my-api/read") == 1 + + +class TestOIDCScopeHandling: + """Tests for OIDC scope handling in Azure provider. + + Azure access tokens do NOT include OIDC scopes (openid, profile, email, + offline_access) in the `scp` claim - they're only used during authorization. + These tests verify that: + 1. OIDC scopes are never prefixed with identifier_uri + 2. OIDC scopes are filtered from token validation + 3. OIDC scopes are still advertised to clients via valid_scopes + """ + + def test_oidc_scopes_constant(self): + """Verify OIDC_SCOPES contains the standard OIDC scopes.""" + assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"} + + def test_prefix_scopes_does_not_prefix_oidc_scopes(self): + """Test that _prefix_scopes_for_azure never prefixes OIDC scopes.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + # All OIDC scopes should pass through unchanged + result = provider._prefix_scopes_for_azure( + ["openid", "profile", "email", "offline_access"] + ) + + assert result == ["openid", "profile", "email", "offline_access"] + + def test_prefix_scopes_mixed_oidc_and_custom(self): + """Test prefixing with a mix of OIDC and custom scopes.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + result = provider._prefix_scopes_for_azure( + ["read", "openid", "write", "profile"] + ) + + # Custom scopes should be prefixed, OIDC scopes should not + assert "api://my-api/read" in result + assert "api://my-api/write" in result + assert "openid" in result + assert "profile" in result + # Verify OIDC scopes are NOT prefixed + assert "api://my-api/openid" not in result + assert "api://my-api/profile" not in result + + def test_prefix_scopes_dot_notation_gets_prefixed(self): + """Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph).""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + # Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph + # or fully-qualified format like https://graph.microsoft.com/User.Read + result = provider._prefix_scopes_for_azure(["my.scope", "admin.read"]) + + assert result == ["api://my-api/my.scope", "api://my-api/admin.read"] + + def test_prefix_scopes_fully_qualified_graph_not_prefixed(self): + """Test that fully-qualified Graph scopes are not prefixed.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + result = provider._prefix_scopes_for_azure( + [ + "https://graph.microsoft.com/User.Read", + "https://graph.microsoft.com/Mail.Send", + ] + ) + + # Fully-qualified URIs pass through unchanged + assert result == [ + "https://graph.microsoft.com/User.Read", + "https://graph.microsoft.com/Mail.Send", + ] + + def test_required_scopes_with_oidc_filters_validation(self): + """Test that OIDC scopes in required_scopes are filtered from token validation.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["read", "openid", "profile"], + jwt_signing_key="test-secret", + ) + + # Token validator should only require non-OIDC scopes + assert provider._token_validator.required_scopes == ["read"] + + def test_required_scopes_all_oidc_results_in_no_validation(self): + """Test that if all required_scopes are OIDC, no scope validation occurs.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["openid", "profile"], + jwt_signing_key="test-secret", + ) + + # Token validator should have empty required scopes (all were OIDC) + assert provider._token_validator.required_scopes == [] + + def test_valid_scopes_includes_oidc_scopes(self): + """Test that valid_scopes advertises OIDC scopes to clients.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["read", "openid", "profile"], + jwt_signing_key="test-secret", + ) + + # required_scopes (used for validation) excludes OIDC scopes + assert provider.required_scopes == ["read"] + # But valid_scopes (advertised to clients) includes all scopes + assert provider.client_registration_options.valid_scopes == [ + "read", + "openid", + "profile", + ] + + def test_prepare_scopes_for_refresh_handles_oidc_scopes(self): + """Test that token refresh correctly handles OIDC scopes.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + # Simulate stored scopes that include OIDC scopes + result = provider._prepare_scopes_for_upstream_refresh( + ["read", "openid", "profile"] + ) + + # Custom scope should be prefixed, OIDC scopes should not + assert "api://my-api/read" in result + assert "openid" in result + assert "profile" in result + assert "api://my-api/openid" not in result + assert "api://my-api/profile" not in result diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py new file mode 100644 index 000000000..ee07e6c60 --- /dev/null +++ b/tests/server/auth/providers/test_discord.py @@ -0,0 +1,119 @@ +"""Tests for Discord OAuth provider.""" + +import os +from unittest.mock import patch + +import pytest + +from fastmcp.server.auth.providers.discord import DiscordProvider + + +class TestDiscordProvider: + """Test Discord OAuth provider functionality.""" + + def test_init_with_explicit_params(self): + """Test DiscordProvider initialization with explicit parameters.""" + provider = DiscordProvider( + client_id="env_client_id", + client_secret="GOCSPX-test123", + base_url="https://myserver.com", + required_scopes=["email", "identify"], + jwt_signing_key="test-secret", + ) + + assert provider._upstream_client_id == "env_client_id" + assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" + assert str(provider.base_url) == "https://myserver.com/" + + @pytest.mark.parametrize( + "scopes_env", + [ + "identify,email", + '["identify", "email"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): + """Test DiscordProvider initialization from environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET": "GOCSPX-env456", + "FASTMCP_SERVER_AUTH_DISCORD_BASE_URL": "https://envserver.com", + "FASTMCP_SERVER_AUTH_DISCORD_REQUIRED_SCOPES": scopes_env, + "FASTMCP_SERVER_AUTH_DISCORD_JWT_SIGNING_KEY": "test-secret", + }, + ): + provider = DiscordProvider() + + assert provider._upstream_client_id == "env_client_id" + assert ( + provider._upstream_client_secret.get_secret_value() == "GOCSPX-env456" + ) + assert str(provider.base_url) == "https://envserver.com/" + assert provider._token_validator.required_scopes == [ + "identify", + "email", + ] + + def test_init_missing_client_id_raises_error(self): + """Test that missing client_id raises ValueError.""" + # Clear environment variables to test proper error handling + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="client_id is required"): + DiscordProvider(client_secret="GOCSPX-test123") + + def test_init_missing_client_secret_raises_error(self): + """Test that missing client_secret raises ValueError.""" + # Clear environment variables to test proper error handling + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="client_secret is required"): + DiscordProvider(client_id="env_client_id") + + def test_init_defaults(self): + """Test that default values are applied correctly.""" + provider = DiscordProvider( + client_id="env_client_id", + client_secret="GOCSPX-test123", + jwt_signing_key="test-secret", + ) + + # Check defaults + assert provider.base_url is None + assert provider._redirect_path == "/auth/callback" + + def test_oauth_endpoints_configured_correctly(self): + """Test that OAuth endpoints are configured correctly.""" + provider = DiscordProvider( + client_id="env_client_id", + client_secret="GOCSPX-test123", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + ) + + # Check that endpoints use Discord's OAuth2 endpoints + assert ( + provider._upstream_authorization_endpoint + == "https://discord.com/oauth2/authorize" + ) + assert ( + provider._upstream_token_endpoint == "https://discord.com/api/oauth2/token" + ) + # Discord provider doesn't currently set a revocation endpoint + assert provider._upstream_revocation_endpoint is None + + def test_discord_specific_scopes(self): + """Test handling of Discord-specific scope formats.""" + # Just test that the provider accepts Discord-specific scopes without error + provider = DiscordProvider( + client_id="env_client_id", + client_secret="GOCSPX-test123", + required_scopes=[ + "identify", + "email", + ], + jwt_signing_key="test-secret", + ) + + # Provider should initialize successfully with these scopes + assert provider is not None diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py index ae539b464..afe7eab80 100644 --- a/tests/server/auth/test_oauth_consent_flow.py +++ b/tests/server/auth/test_oauth_consent_flow.py @@ -884,3 +884,179 @@ class TestConsentPageServerIcon: 'alt="<script>alert("xss")</script>Server"' in response.text ) + + +class TestConsentCSPPolicy: + """Tests for Content Security Policy customization on consent page.""" + + async def test_default_csp_includes_form_action(self): + """Test that default CSP includes form-action directive.""" + from unittest.mock import Mock + + from fastmcp import FastMCP + + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read"] + verifier.verify_token = Mock(return_value=None) + + # Create OAuthProxy with default CSP (no custom CSP) + proxy = OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=verifier, + base_url="https://proxy.example.com", + client_storage=MemoryStore(), + jwt_signing_key="test-secret", + ) + + server = FastMCP(name="Test Server", auth=proxy) + app = server.http_app() + + client_info = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await proxy.register_client(client_info) + + from fastmcp.server.auth.oauth_proxy import OAuthTransaction + + txn_id = "test-txn-id" + transaction = OAuthTransaction( + txn_id=txn_id, + client_id="test-client", + client_redirect_uri="http://localhost:12345/callback", + client_state="client-state", + code_challenge="challenge", + code_challenge_method="S256", + scopes=["read"], + created_at=time.time(), + ) + await proxy._transaction_store.put(key=txn_id, value=transaction) + + with TestClient(app) as client: + response = client.get(f"/consent?txn_id={txn_id}") + + assert response.status_code == 200 + # Default CSP should be present with form-action + assert 'http-equiv="Content-Security-Policy"' in response.text + assert "form-action" in response.text + + async def test_empty_csp_disables_csp_meta_tag(self): + """Test that empty string CSP disables CSP meta tag entirely.""" + from unittest.mock import Mock + + from fastmcp import FastMCP + + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read"] + verifier.verify_token = Mock(return_value=None) + + # Create OAuthProxy with empty CSP to disable it + proxy = OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=verifier, + base_url="https://proxy.example.com", + client_storage=MemoryStore(), + jwt_signing_key="test-secret", + consent_csp_policy="", # Empty string disables CSP + ) + + server = FastMCP(name="Test Server", auth=proxy) + app = server.http_app() + + client_info = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await proxy.register_client(client_info) + + from fastmcp.server.auth.oauth_proxy import OAuthTransaction + + txn_id = "test-txn-id" + transaction = OAuthTransaction( + txn_id=txn_id, + client_id="test-client", + client_redirect_uri="http://localhost:12345/callback", + client_state="client-state", + code_challenge="challenge", + code_challenge_method="S256", + scopes=["read"], + created_at=time.time(), + ) + await proxy._transaction_store.put(key=txn_id, value=transaction) + + with TestClient(app) as client: + response = client.get(f"/consent?txn_id={txn_id}") + + assert response.status_code == 200 + # CSP meta tag should NOT be present + assert 'http-equiv="Content-Security-Policy"' not in response.text + + async def test_custom_csp_policy_is_used(self): + """Test that custom CSP policy is applied to consent page.""" + from unittest.mock import Mock + + from fastmcp import FastMCP + + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read"] + verifier.verify_token = Mock(return_value=None) + + # Create OAuthProxy with custom CSP policy + custom_csp = "default-src 'self'; script-src 'none'" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=verifier, + base_url="https://proxy.example.com", + client_storage=MemoryStore(), + jwt_signing_key="test-secret", + consent_csp_policy=custom_csp, + ) + + server = FastMCP(name="Test Server", auth=proxy) + app = server.http_app() + + client_info = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await proxy.register_client(client_info) + + from fastmcp.server.auth.oauth_proxy import OAuthTransaction + + txn_id = "test-txn-id" + transaction = OAuthTransaction( + txn_id=txn_id, + client_id="test-client", + client_redirect_uri="http://localhost:12345/callback", + client_state="client-state", + code_challenge="challenge", + code_challenge_method="S256", + scopes=["read"], + created_at=time.time(), + ) + await proxy._transaction_store.put(key=txn_id, value=transaction) + + with TestClient(app) as client: + response = client.get(f"/consent?txn_id={txn_id}") + + assert response.status_code == 200 + # Custom CSP should be present (HTML-escaped) + assert 'http-equiv="Content-Security-Policy"' in response.text + # Check for the HTML-escaped version (single quotes become ') + import html + + assert html.escape(custom_csp, quote=True) in response.text + # Default form-action should NOT be present (we're using custom) + assert "form-action" not in response.text diff --git a/tests/server/http/test_stale_access_token.py b/tests/server/http/test_stale_access_token.py new file mode 100644 index 000000000..34f271e79 --- /dev/null +++ b/tests/server/http/test_stale_access_token.py @@ -0,0 +1,159 @@ +""" +Test for issue #1863: get_access_token() returns stale token after OAuth refresh. + +This test demonstrates the bug where auth_context_var holds a stale token, +but the current HTTP request (via request_ctx) has a fresh token. + +The test should FAIL with the current implementation and PASS after the fix. +""" + +from unittest.mock import MagicMock + +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.lowlevel.server import request_ctx +from mcp.shared.context import RequestContext +from starlette.requests import Request + +from fastmcp.server.auth import AccessToken +from fastmcp.server.dependencies import get_access_token + + +class TestStaleAccessToken: + """Test that get_access_token returns fresh token from request scope.""" + + def test_get_access_token_prefers_request_scope_over_stale_context_var(self): + """ + Regression test for issue #1863. + + Scenario: + - auth_context_var has a STALE token (set at HTTP middleware level) + - request_ctx.request.scope["user"] has a FRESH token (per MCP message) + - get_access_token() should return the FRESH token + + This simulates the case where: + 1. A Streamable HTTP session was established with token A + 2. auth_context_var was set to token A during session setup + 3. Token expired, client refreshed, got token B + 4. New MCP message arrives with token B in the request + 5. get_access_token() should return token B, not stale token A + """ + # Create STALE token (in auth_context_var) + # Using FastMCP's AccessToken to avoid conversion issues + stale_token = AccessToken( + token="stale-token-from-initial-auth", + client_id="test-client", + scopes=["read"], + ) + stale_user = AuthenticatedUser(stale_token) + + # Create FRESH token (in request.scope["user"]) + fresh_token = AccessToken( + token="fresh-token-after-refresh", + client_id="test-client", + scopes=["read"], + ) + fresh_user = AuthenticatedUser(fresh_token) + + # Create a mock request with fresh token in scope + scope = { + "type": "http", + "user": fresh_user, + "auth": MagicMock(), + } + mock_request = Request(scope) + + # Create a mock RequestContext with the request + mock_request_context = MagicMock(spec=RequestContext) + mock_request_context.request = mock_request + + # Set up the context vars: + # - auth_context_var has STALE token + # - request_ctx has request with FRESH token + auth_token = auth_context_var.set(stale_user) + request_token = request_ctx.set(mock_request_context) + + try: + # Call get_access_token - should return FRESH token + result = get_access_token() + + # Assert we get the FRESH token, not the stale one + assert result is not None, "Expected an access token but got None" + assert result.token == "fresh-token-after-refresh", ( + f"Expected fresh token 'fresh-token-after-refresh' but got '{result.token}'. " + "get_access_token() is returning the stale token from auth_context_var " + "instead of the fresh token from request.scope['user']." + ) + finally: + # Clean up context vars + auth_context_var.reset(auth_token) + request_ctx.reset(request_token) + + def test_get_access_token_falls_back_to_context_var_when_no_request(self): + """ + Verify that get_access_token falls back to auth_context_var + when there's no HTTP request available. + """ + # Create token in auth_context_var using FastMCP's AccessToken + token = AccessToken( + token="context-var-token", + client_id="test-client", + scopes=["read"], + ) + user = AuthenticatedUser(token) + + # Set up auth_context_var but NOT request_ctx + auth_token = auth_context_var.set(user) + + try: + result = get_access_token() + + assert result is not None + assert result.token == "context-var-token" + finally: + auth_context_var.reset(auth_token) + + def test_get_access_token_returns_none_when_no_auth(self): + """ + Verify that get_access_token returns None when there's no + authenticated user anywhere. + """ + result = get_access_token() + assert result is None + + def test_get_access_token_falls_back_when_scope_user_is_not_authenticated(self): + """ + Verify that get_access_token falls back to auth_context_var when + scope["user"] exists but is not an AuthenticatedUser (e.g., UnauthenticatedUser). + """ + from starlette.authentication import UnauthenticatedUser + + # Create token in auth_context_var + token = AccessToken( + token="context-var-token", + client_id="test-client", + scopes=["read"], + ) + user = AuthenticatedUser(token) + + # Create request with UnauthenticatedUser in scope + scope = { + "type": "http", + "user": UnauthenticatedUser(), + } + mock_request = Request(scope) + mock_request_context = MagicMock(spec=RequestContext) + mock_request_context.request = mock_request + + auth_token = auth_context_var.set(user) + request_token = request_ctx.set(mock_request_context) + + try: + result = get_access_token() + + # Should fall back to auth_context_var since scope user is unauthenticated + assert result is not None + assert result.token == "context-var-token" + finally: + auth_context_var.reset(auth_token) + request_ctx.reset(request_token) diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py index fd82a7f9b..9373da6a6 100644 --- a/tests/server/proxy/test_proxy_server.py +++ b/tests/server/proxy/test_proxy_server.py @@ -6,6 +6,7 @@ import pytest from anyio import create_task_group from dirty_equals import Contains from mcp import McpError +from mcp.types import Icon from pydantic import AnyUrl from fastmcp import FastMCP @@ -30,7 +31,11 @@ def fastmcp_server(): # --- Tools --- - @server.tool(tags={"greet"}) + @server.tool( + tags={"greet"}, + title="Greet", + icons=[Icon(src="https://example.com/greet-icon.png")], + ) def greet(name: str) -> str: """Greet someone by name.""" return f"Hello, {name}!" @@ -51,7 +56,12 @@ def fastmcp_server(): # --- Resources --- - @server.resource(uri="resource://wave", tags={"wave"}) + @server.resource( + uri="resource://wave", + tags={"wave"}, + title="Wave", + icons=[Icon(src="https://example.com/wave-icon.png")], + ) def wave() -> str: return "👋" @@ -59,13 +69,22 @@ def fastmcp_server(): async def get_users() -> list[dict[str, Any]]: return USERS - @server.resource(uri="data://user/{user_id}", tags={"users"}) + @server.resource( + uri="data://user/{user_id}", + tags={"users"}, + title="User Template", + icons=[Icon(src="https://example.com/user-icon.png")], + ) async def get_user(user_id: str) -> dict[str, Any] | None: return next((user for user in USERS if user["id"] == user_id), None) # --- Prompts --- - @server.prompt(tags={"welcome"}) + @server.prompt( + tags={"welcome"}, + title="Welcome", + icons=[Icon(src="https://example.com/welcome-icon.png")], + ) def welcome(name: str) -> str: return f"Welcome to FastMCP, {name}!" @@ -141,7 +160,9 @@ class TestTools: async def test_get_tools_meta(self, proxy_server): tools = await proxy_server.get_tools() greet_tool = tools["greet"] + assert greet_tool.title == "Greet" assert greet_tool.meta == {"_fastmcp": {"tags": ["greet"]}} + assert greet_tool.icons == [Icon(src="https://example.com/greet-icon.png")] async def test_get_transformed_tools( self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy @@ -263,7 +284,9 @@ class TestResources: async def test_get_resources_meta(self, proxy_server): resources = await proxy_server.get_resources() wave_resource = resources["resource://wave"] + assert wave_resource.title == "Wave" assert wave_resource.meta == {"_fastmcp": {"tags": ["wave"]}} + assert wave_resource.icons == [Icon(src="https://example.com/wave-icon.png")] async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server): assert ( @@ -362,7 +385,11 @@ class TestResourceTemplates: async def test_get_resource_templates_meta(self, proxy_server): templates = await proxy_server.get_resource_templates() get_user_template = templates["data://user/{user_id}"] + assert get_user_template.title == "User Template" assert get_user_template.meta == {"_fastmcp": {"tags": ["users"]}} + assert get_user_template.icons == [ + Icon(src="https://example.com/user-icon.png") + ] async def test_list_resource_templates_same_as_original( self, fastmcp_server, proxy_server @@ -466,7 +493,11 @@ class TestPrompts: async def test_get_prompts_meta(self, proxy_server): prompts = await proxy_server.get_prompts() welcome_prompt = prompts["welcome"] + assert welcome_prompt.title == "Welcome" assert welcome_prompt.meta == {"_fastmcp": {"tags": ["welcome"]}} + assert welcome_prompt.icons == [ + Icon(src="https://example.com/welcome-icon.png") + ] async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server): async with Client(fastmcp_server) as client: