diff --git a/.github/workflows/martian-test-failure.yml b/.github/workflows/martian-test-failure.yml
index ddf0440cf..bd12de7e3 100644
--- a/.github/workflows/martian-test-failure.yml
+++ b/.github/workflows/martian-test-failure.yml
@@ -2,7 +2,7 @@ name: Marvin Test Failure Analysis
on:
workflow_run:
- workflows: ["Run Tests"]
+ workflows: ["Tests", "Run static analysis"]
types:
- completed
@@ -23,7 +23,7 @@ jobs:
actions: read # Required for Claude to read CI results
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -35,7 +35,7 @@ jobs:
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Set up Python 3.10
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.10"
diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx
index a5d532555..b2964f350 100644
--- a/docs/deployment/http.mdx
+++ b/docs/deployment/http.mdx
@@ -510,6 +510,7 @@ When deploying to production, you'll want to optimize your server for performanc
# Run with basic configuration
uvicorn app:app --host 0.0.0.0 --port 8000
+# Ensure stateless HTTP mode is enabled (stateless_http=True)
# Run with multiple workers for production
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4
```
@@ -601,4 +602,4 @@ This guide has shown you how to create an HTTP-accessible MCP server, but you'll
- **Edge platforms** (Cloudflare Workers)
- **Kubernetes clusters** (self-managed or managed)
-The key requirements are Python 3.10+ support and the ability to expose an HTTP port. Most providers will require you to package your server (requirements.txt, Dockerfile, etc.) according to their deployment format. For managed, zero-configuration deployment, see [FastMCP Cloud](/deployment/fastmcp-cloud).
\ No newline at end of file
+The key requirements are Python 3.10+ support and the ability to expose an HTTP port. Most providers will require you to package your server (requirements.txt, Dockerfile, etc.) according to their deployment format. For managed, zero-configuration deployment, see [FastMCP Cloud](/deployment/fastmcp-cloud).
diff --git a/docs/development/tests.mdx b/docs/development/tests.mdx
index 3bbf64ce2..d47d3d092 100644
--- a/docs/development/tests.mdx
+++ b/docs/development/tests.mdx
@@ -299,17 +299,16 @@ async def test_database_tool():
### Testing Network Transports
-While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports like HTTP or SSE. FastMCP provides two approaches: in-process async servers using AnyIO task groups (preferred), and separate subprocess servers (for special cases).
+While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports like HTTP or SSE. FastMCP provides two approaches: in-process async servers (preferred), and separate subprocess servers (for special cases).
#### In-Process Network Testing (Preferred)
-For most network transport tests, use `run_server_async` with AnyIO task groups. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support:
+For most network transport tests, use `run_server_async` as an async context manager. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support:
```python
import pytest
-from anyio.abc import TaskGroup
from fastmcp import FastMCP, Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
@@ -317,19 +316,19 @@ from fastmcp.utilities.tests import run_server_async
def create_test_server() -> FastMCP:
"""Create a test server instance."""
server = FastMCP("TestServer")
-
+
@server.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
-
+
return server
@pytest.fixture
-async def http_server(task_group: TaskGroup) -> str:
- """Start server in-process using task group."""
+async def http_server() -> str:
+ """Start server in-process for testing."""
server = create_test_server()
- url = await run_server_async(task_group, server, transport="http")
- return url
+ async with run_server_async(server) as url:
+ yield url
async def test_http_transport(http_server: str):
"""Test actual HTTP transport behavior."""
@@ -338,12 +337,12 @@ async def test_http_transport(http_server: str):
) as client:
result = await client.ping()
assert result is True
-
+
greeting = await client.call_tool("greet", {"name": "World"})
assert greeting.data == "Hello, World!"
```
-The `task_group` fixture is provided globally by `conftest.py` and automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages.
+The `run_server_async` context manager automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages.
#### Subprocess Testing (Special Cases)
diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx
index fdb8b0230..81eb0642b 100644
--- a/docs/integrations/descope.mdx
+++ b/docs/integrations/descope.mdx
@@ -6,7 +6,7 @@ icon: shield-check
tag: NEW
---
-import { VersionBadge } from "/snippets/version-badge.mdx"
+import { VersionBadge } from "/snippets/version-badge.mdx";
@@ -17,28 +17,29 @@ This guide shows you how to secure your FastMCP server using [**Descope**](https
### Prerequisites
Before you begin, you will need:
+
1. To [sign up](https://www.descope.com/sign-up) for a Free Forever Descope account
-2. Your **Project ID** from the [Descope Console](https://app.descope.com/settings/project)
-3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:3000`)
+2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:3000`)
### Step 1: Configure Descope
-
- 1. Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console
- 2. Click **DCR Settings**
- 3. Enable **Dynamic Client Registration (DCR)**
- 4. Define allowed scopes
+
+ 1. Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console, and create a new MCP Server.
+ 2. Give the MCP server a name and description.
+ 3. Ensure that **Dynamic Client Registration (DCR)** is enabled. Then click **Create**.
+ 4. Once you've created the MCP Server, note your Well-Known URL.
+
DCR is required for FastMCP clients to automatically register with your authentication server.
-
- Save your Project ID from [Project Settings](https://app.descope.com/settings/project):
+
+ Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers):
```
- Project ID: P2abc...123
+ Well-Known URL: https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration
```
@@ -48,15 +49,10 @@ Before you begin, you will need:
Create a `.env` file with your Descope configuration:
```bash
-DESCOPE_PROJECT_ID=P2abc...123 # Your Descope Project ID
-DESCOPE_BASE_URL=https://api.descope.com # Descope API URL
+DESCOPE_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration # Your Descope Well-Known URL
SERVER_URL=http://localhost:3000 # Your server's base URL
```
-
-You can find your project's Descope Base URL in the [Multi-Region Support Guide](https://docs.descope.com/management/project-settings/multi-regional).
-
-
### Step 3: FastMCP Configuration
Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically:
@@ -68,9 +64,8 @@ from fastmcp.server.auth.providers.descope import DescopeProvider
# The DescopeProvider automatically discovers Descope endpoints
# and configures JWT token validation
auth_provider = DescopeProvider(
- project_id=DESCOPE_PROJECT_ID, # Your Descope Project ID
+ config_url=https://.../.well-known/openid-configuration, # Your MCP Server .well-known URL
base_url=SERVER_URL, # Your server's public URL
- descope_base_url=DESCOPE_BASE_URL, # Descope API base URL
)
# Create FastMCP server with auth
@@ -80,7 +75,7 @@ mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider)
## Testing
-To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the `project_id`, `base_url`, and `descope_base_url` with your actual values!), you can run the following command:
+To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the environment variables with your actual values!), you can run the following command:
```bash
fastmcp run server.py --transport http --port 8000
@@ -102,7 +97,6 @@ if __name__ == "__main__":
## Environment Variables
-
For production deployments, use environment variables instead of hardcoding credentials.
### Provider Selection
@@ -110,9 +104,10 @@ For production deployments, use environment variables instead of hardcoding cred
Setting this environment variable allows the Descope provider to be used automatically without explicitly instantiating it in code.
-
-Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use Descope authentication.
-
+
+ Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use
+ Descope authentication.
+
### Descope-Specific Configuration
@@ -120,28 +115,25 @@ Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use Descope au
These environment variables provide default values for the Descope provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`.
-
-Your Descope Project ID from the [Descope Console](https://app.descope.com/settings/project)
+
+Your Well-Known URL from the [Descope Console](https://app.descope.com/mcp-servers)
-Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000` for development)
-
-
-
-Descope API base URL for your [region/environment](https://docs.descope.com/management/project-settings/multi-regional)
+ Public URL of your FastMCP server (e.g., `https://your-server.com` or
+ `http://localhost:8000` for development)
Example `.env` file:
+
```bash
# Use the Descope provider
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.descope.DescopeProvider
# Descope configuration
-FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_PROJECT_ID=P2abc...123
+FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration
FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL=https://your-server.com
-FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_DESCOPE_BASE_URL=https://api.descope.com
```
With environment variables set, your server code simplifies to:
@@ -151,4 +143,4 @@ from fastmcp import FastMCP
# Authentication is automatically configured from environment
mcp = FastMCP(name="My Descope Protected Server")
-```
\ No newline at end of file
+```
diff --git a/docs/integrations/scalekit.mdx b/docs/integrations/scalekit.mdx
index 89de64ef1..802fe16ef 100644
--- a/docs/integrations/scalekit.mdx
+++ b/docs/integrations/scalekit.mdx
@@ -8,19 +8,16 @@ tag: NEW
import { VersionBadge } from "/snippets/version-badge.mdx"
-
-
+
Install auth stack to your FastMCP server with [Scalekit](https://scalekit.com) using the [Remote OAuth](/servers/auth/remote-oauth) pattern: Scalekit handles user authentication, and the MCP server validates issued tokens.
-## Configuration
-
### Prerequisites
Before you begin
-1. Get a [Scalekit account](https://app.scalekit.com/) and grab API credentials such as **Client ID**, **Client Secret** and **Environment URL** from _Dashboard > Developers > Settings_.
-2. Have your FastMCP server's endpoint ready (can be localhost for development, e.g., `http://localhost:8000/mcp`)
+1. Get a [Scalekit account](https://app.scalekit.com/) and grab your **Environment URL** from _Dashboard > Settings_ .
+2. Have your FastMCP server's base URL ready (can be localhost for development, e.g., `http://localhost:8000/`)
### Step 1: Configure MCP server in Scalekit environment
@@ -36,9 +33,10 @@ In your FastMCP project's `.env`:
```sh
SCALEKIT_ENVIRONMENT_URL=
-SCALEKIT_CLIENT_ID= # skc_7008EXAMPLE46
SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878
-MCP_URL=http://localhost:8000/mcp
+BASE_URL=http://localhost:8000/
+# Optional: additional scopes tokens must have
+# SCALEKIT_REQUIRED_SCOPES=read,write
```
@@ -48,6 +46,8 @@ MCP_URL=http://localhost:8000/mcp
Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically:
+> **Warning:** The legacy `mcp_url` and `client_id` parameters are deprecated and will be removed in a future release. Use `base_url` instead of `mcp_url` and remove `client_id` from your configuration.
+
```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
@@ -55,9 +55,9 @@ from fastmcp.server.auth.providers.scalekit import ScalekitProvider
# Discovers Scalekit endpoints and set up JWT token validation
auth_provider = ScalekitProvider(
environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL
- client_id=SCALEKIT_CLIENT_ID, # OAuth client ID
resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID
- mcp_url=SERVER_URL, # Is also aud claim
+ base_url=SERVER_URL, # Public MCP endpoint
+ required_scopes=["read"], # Optional scope enforcement
)
# Create FastMCP server with auth
@@ -75,6 +75,10 @@ def auth_status() -> dict:
```
+
+Set `required_scopes` when you need tokens to carry specific permissions. Leave it unset to allow any token issued for the resource.
+
+
## Testing
### Start the MCP server
@@ -104,16 +108,18 @@ These environment variables provide default values for the Scalekit provider, wh
Your Scalekit environment URL from the Admin Portal (e.g., `https://your-env.scalekit.com`)
-
-Your Scalekit OAuth application client ID from the Applications section
-
-
-Your Scalekit resource server ID from the Resources section
+Your Scalekit resource server ID from the MCP Servers section
-
-Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000/mcp` for development)
+
+Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000/` for development)
+
+
+Legacy `FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL` is still recognized for backward compatibility but will be removed soon-rename it to `...BASE_URL`.
+
+
+Comma-, space-, or JSON-separated list of scopes that tokens must include to access your server
@@ -125,9 +131,10 @@ FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.scalekit.ScalekitProvider
# Scalekit configuration
FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL=https://your-env.scalekit.com
-FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_CLIENT_ID=skc_123
FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID=res_456
-FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL=https://your-server.com/mcp
+FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_BASE_URL=https://your-server.com/
+# FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_REQUIRED_SCOPES=read,write
+# FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL=https://your-server.com/ # Deprecated
```
With environment variables set, your server code simplifies to:
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx
index 9d14c4964..8b282c398 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx
@@ -5,21 +5,18 @@ 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`
### `DescopeProvider`
-
Descope metadata provider for DCR (Dynamic Client Registration).
This provider implements Descope integration using metadata forwarding.
@@ -29,20 +26,20 @@ as a resource server.
IMPORTANT SETUP REQUIREMENTS:
-1. Enable Dynamic Client Registration in Descope Console:
- - Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console
- - Click **DCR Settings**
- - Enable **Dynamic Client Registration (DCR)**
- - Define allowed scopes
+1. Create an MCP Server in Descope Console:
-2. Note your Project ID:
- - Save your Project ID from [Project Settings](https://app.descope.com/settings/project)
- - Example: P2abc...123
+ - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console
+ - Create a new MCP Server
+ - Ensure that **Dynamic Client Registration (DCR)** is enabled
+ - Note your Well-Known URL
+
+2. Note your Well-Known URL:
+ - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers)
+ - Format: `https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`
For detailed setup instructions, see:
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
-
**Methods:**
#### `get_routes`
@@ -57,6 +54,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.
+- `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-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx
index 7dd465a29..546b07b06 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx
@@ -37,9 +37,9 @@ IMPORTANT SETUP REQUIREMENTS:
2. Environment Configuration:
- Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com)
- - Set SCALEKIT_CLIENT_ID from your OAuth application
- Set SCALEKIT_RESOURCE_ID from your created resource
- - Set MCP_URL to your FastMCP server's public URL
+ - 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/
@@ -61,4 +61,3 @@ 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/servers/tools.mdx b/docs/servers/tools.mdx
index e326258d2..92812378d 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -354,7 +354,9 @@ Field provides several validation and documentation features:
You can exclude certain arguments from the tool schema shown to the LLM. This is useful for arguments that are injected at runtime (such as `state`, `user_id`, or credentials) and should not be exposed to the LLM or client. Only arguments with default values can be excluded; attempting to exclude a required argument will raise an error.
-Example:
+**Note:** `exclude_args` will be deprecated in FastMCP 2.14 in favor of dependency injection with `Depends()` for better lifecycle management and more explicit dependency handling. `exclude_args` will continue to work until then.
+
+Example with `exclude_args`:
```python
@mcp.tool(
diff --git a/examples/auth/scalekit_oauth/README.md b/examples/auth/scalekit_oauth/README.md
index 63c555244..c241d76f7 100644
--- a/examples/auth/scalekit_oauth/README.md
+++ b/examples/auth/scalekit_oauth/README.md
@@ -9,8 +9,8 @@ Demonstrates FastMCP server protection with Scalekit OAuth.
**Create a Scalekit Account**:
- Go to [Scalekit Dashboard](https://app.scalekit.com/)
-- Navigate to **Developers** → **Settings**
-- Copy your Environment URL, Client ID, and Client Secret
+- Copy your Environment URL from **Developers** → **Settings**
+- Copy Resource ID (res_xxx) from **Developers** → **MCP Servers**
**Register Your MCP Server**:
@@ -23,9 +23,10 @@ Create a `.env` file:
```bash
# Required Scalekit credentials
SCALEKIT_ENVIRONMENT_URL=
-SCALEKIT_CLIENT_ID= # skc_7008EXAMPLE46
SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878
-MCP_URL=http://localhost:8000/mcp
+BASE_URL=http://localhost:8000/
+# Optional: additional scopes tokens must include (comma-separated)
+# SCALEKIT_REQUIRED_SCOPES=read,write
```
### 2. Run the Example
diff --git a/examples/auth/scalekit_oauth/server.py b/examples/auth/scalekit_oauth/server.py
index 40da711e2..68cef23b5 100644
--- a/examples/auth/scalekit_oauth/server.py
+++ b/examples/auth/scalekit_oauth/server.py
@@ -4,9 +4,12 @@ This example demonstrates how to protect a FastMCP server with Scalekit OAuth.
Required environment variables:
- SCALEKIT_ENVIRONMENT_URL: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com")
-- SCALEKIT_CLIENT_ID: Your Scalekit OAuth application client ID
- SCALEKIT_RESOURCE_ID: Your Scalekit resource ID
+Optional:
+- SCALEKIT_REQUIRED_SCOPES: Comma-separated scopes tokens must include
+- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`)
+
To run:
python server.py
"""
@@ -16,12 +19,19 @@ import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
+required_scopes_env = os.getenv("SCALEKIT_REQUIRED_SCOPES")
+required_scopes = (
+ [scope.strip() for scope in required_scopes_env.split(",") if scope.strip()]
+ if required_scopes_env
+ else None
+)
+
auth = ScalekitProvider(
environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL")
or "https://your-env.scalekit.com",
- client_id=os.getenv("SCALEKIT_CLIENT_ID") or "",
resource_id=os.getenv("SCALEKIT_RESOURCE_ID") or "",
- mcp_url=os.getenv("MCP_URL", "http://localhost:8000/mcp"),
+ base_url=os.getenv("BASE_URL", "http://localhost:8000/"),
+ required_scopes=required_scopes,
)
mcp = FastMCP("Scalekit OAuth Example Server", auth=auth)
diff --git a/pyproject.toml b/pyproject.toml
index e20f60047..33d163298 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ dependencies = [
"python-dotenv>=1.1.0",
"exceptiongroup>=1.2.2",
"httpx>=0.28.1",
- "mcp>=1.19.0,<2.0.0",
+ "mcp>=1.19.0,<2.0.0,!=1.21.1",
"openapi-pydantic>=0.5.1",
"platformdirs>=4.0.0",
"rich>=13.9.4",
@@ -15,7 +15,7 @@ dependencies = [
"authlib>=1.6.5",
"pydantic[email]>=2.11.7",
"pyperclip>=1.9.0",
- "py-key-value-aio[disk,keyring,memory]>=0.2.8,<0.3.0",
+ "py-key-value-aio[disk,memory]>=0.2.8,<0.4.0",
"uvicorn>=0.35",
"websockets>=15.0.1",
"jsonschema-path>=0.3.4",
diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py
index 516fd592e..c526ddcd3 100644
--- a/src/fastmcp/server/auth/oauth_proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy.py
@@ -44,6 +44,7 @@ from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
+ AuthorizeError,
RefreshToken,
TokenError,
)
@@ -939,6 +940,8 @@ class OAuthProxy(OAuthProvider):
"""
# Create a ProxyDCRClient with configured redirect URI validation
+ if client_info.client_id is None:
+ raise ValueError("client_id is required for client registration")
proxy_client: ProxyDCRClient = ProxyDCRClient(
client_id=client_info.client_id,
client_secret=client_info.client_secret,
@@ -968,7 +971,7 @@ class OAuthProxy(OAuthProvider):
logger.debug(
"Registered client %s with %d redirect URIs",
client_info.client_id,
- len(proxy_client.redirect_uris),
+ len(proxy_client.redirect_uris) if proxy_client.redirect_uris else 0,
)
# -------------------------------------------------------------------------
@@ -1005,6 +1008,10 @@ class OAuthProxy(OAuthProvider):
)
# Store transaction data for IdP callback processing
+ if client.client_id is None:
+ raise AuthorizeError(
+ error="invalid_client", error_description="Client ID is required"
+ )
transaction = OAuthTransaction(
txn_id=txn_id,
client_id=client.client_id,
@@ -1083,6 +1090,10 @@ class OAuthProxy(OAuthProvider):
return None
# Create authorization code object with PKCE challenge
+ if client.client_id is None:
+ raise AuthorizeError(
+ error="invalid_client", error_description="Client ID is required"
+ )
return AuthorizationCode(
code=authorization_code,
client_id=client.client_id,
@@ -1168,7 +1179,7 @@ class OAuthProxy(OAuthProvider):
expires_at=time.time() + expires_in,
token_type=idp_tokens.get("token_type", "Bearer"),
scope=" ".join(authorization_code.scopes),
- client_id=client.client_id,
+ client_id=client.client_id or "",
created_at=time.time(),
raw_token_data=idp_tokens,
)
@@ -1181,6 +1192,8 @@ class OAuthProxy(OAuthProvider):
logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8])
# Issue minimal FastMCP access token (just a reference via JTI)
+ if client.client_id is None:
+ raise TokenError("invalid_client", "Client ID is required")
fastmcp_access_token = self._jwt_issuer.issue_access_token(
client_id=client.client_id,
scopes=authorization_code.scopes,
@@ -1260,6 +1273,23 @@ class OAuthProxy(OAuthProvider):
# Refresh Token Flow
# -------------------------------------------------------------------------
+ def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
+ """Prepare scopes for upstream token refresh request.
+
+ Override this method to transform scopes before sending to upstream provider.
+ For example, Azure needs to prefix scopes and add additional Graph scopes.
+
+ The scopes parameter represents what should be stored in the RefreshToken.
+ This method returns what should be sent to the upstream provider.
+
+ Args:
+ scopes: Base scopes that will be stored in RefreshToken
+
+ Returns:
+ Scopes to send to upstream provider (may be transformed/augmented)
+ """
+ return scopes
+
async def load_refresh_token(
self,
client: OAuthClientInformationFull,
@@ -1320,12 +1350,17 @@ class OAuthProxy(OAuthProvider):
timeout=HTTP_TIMEOUT_SECONDS,
)
+ # Allow child classes to transform scopes before sending to upstream
+ # This enables provider-specific scope formatting (e.g., Azure prefixing)
+ # while keeping original scopes in storage
+ upstream_scopes = self._prepare_scopes_for_upstream_refresh(scopes)
+
try:
logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8])
token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc]
url=self._upstream_token_endpoint,
refresh_token=upstream_token_set.refresh_token,
- scope=" ".join(scopes) if scopes else None,
+ scope=" ".join(upstream_scopes) if upstream_scopes else None,
**self._extra_token_params,
)
logger.debug("Successfully refreshed upstream token")
@@ -1382,6 +1417,8 @@ class OAuthProxy(OAuthProvider):
)
# Issue new minimal FastMCP access token (just a reference via JTI)
+ if client.client_id is None:
+ raise TokenError("invalid_client", "Client ID is required")
new_access_jti = secrets.token_urlsafe(32)
new_fastmcp_access = self._jwt_issuer.issue_access_token(
client_id=client.client_id,
diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py
index 7b1173eae..95b5e9b0e 100644
--- a/src/fastmcp/server/auth/oidc_proxy.py
+++ b/src/fastmcp/server/auth/oidc_proxy.py
@@ -222,6 +222,9 @@ class OIDCProxy(OAuthProxy):
token_endpoint_auth_method: str | None = None,
# Consent screen configuration
require_authorization_consent: bool = True,
+ # Extra parameters
+ extra_authorize_params: dict[str, str] | None = None,
+ extra_token_params: dict[str, str] | None = None,
) -> None:
"""Initialize the OIDC proxy provider.
@@ -259,6 +262,11 @@ 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.
+ 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"}
+ extra_token_params: Additional parameters to forward to the upstream token endpoint.
+ Useful for provider-specific parameters during token exchange.
"""
if not config_url:
raise ValueError("Missing required config URL")
@@ -335,10 +343,24 @@ class OIDCProxy(OAuthProxy):
if redirect_path:
init_kwargs["redirect_path"] = redirect_path
+ # Build extra params, merging audience with user-provided params
+ # User params override audience if there's a conflict
+ final_authorize_params: dict[str, str] = {}
+ final_token_params: dict[str, str] = {}
+
if audience:
- extra_params = {"audience": audience}
- init_kwargs["extra_authorize_params"] = extra_params
- init_kwargs["extra_token_params"] = extra_params
+ final_authorize_params["audience"] = audience
+ final_token_params["audience"] = audience
+
+ if extra_authorize_params:
+ final_authorize_params.update(extra_authorize_params)
+ if extra_token_params:
+ final_token_params.update(extra_token_params)
+
+ if final_authorize_params:
+ init_kwargs["extra_authorize_params"] = final_authorize_params
+ if final_token_params:
+ init_kwargs["extra_token_params"] = final_token_params
super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type]
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index 4c7cb8359..c216e351a 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -327,6 +327,28 @@ class AzureProvider(OAuthProxy):
separator = "&" if "?" in auth_url else "?"
return f"{auth_url}{separator}prompt=select_account"
+ def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
+ """Prefix unprefixed scopes with identifier_uri for Azure.
+
+ This helper centralizes the scope prefixing logic used in both
+ authorization and token refresh flows.
+
+ Args:
+ scopes: List of scopes, may be prefixed or unprefixed
+
+ Returns:
+ List of scopes with identifier_uri prefix applied where needed
+ """
+ prefixed = []
+ for scope in scopes:
+ if "://" in scope or "/" in scope:
+ # Already fully-qualified (e.g., "api://xxx/read" or "User.Read")
+ prefixed.append(scope)
+ else:
+ # Unprefixed client scope - prefix with identifier_uri
+ prefixed.append(f"{self.identifier_uri}/{scope}")
+ return prefixed
+
def _build_upstream_authorize_url(
self, txn_id: str, transaction: dict[str, Any]
) -> str:
@@ -339,14 +361,7 @@ class AzureProvider(OAuthProxy):
unprefixed_scopes = transaction.get("scopes") or self.required_scopes or []
# Prefix scopes for Azure authorization request
- prefixed_scopes = []
- for scope in unprefixed_scopes:
- if "://" in scope or "/" in scope:
- # Already a full URI or path (e.g., "api://xxx/read" or "User.Read")
- prefixed_scopes.append(scope)
- else:
- # Unprefixed scope name - prefix it with identifier_uri
- prefixed_scopes.append(f"{self.identifier_uri}/{scope}")
+ prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes)
# Add Microsoft Graph scopes (not validated, not prefixed)
if self.additional_authorize_scopes:
@@ -358,3 +373,42 @@ class AzureProvider(OAuthProxy):
# Let parent build the URL with prefixed scopes
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
+
+ def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
+ """Prepare scopes for Azure token refresh.
+
+ Azure requires:
+ 1. Fully-qualified custom scopes (e.g., "api://xxx/read" not "read")
+ 2. Microsoft Graph scopes (e.g., "User.Read", "openid") sent as-is
+ 3. Additional scopes from provider config (additional_authorize_scopes)
+
+ This method transforms base client scopes for Azure while keeping them
+ unprefixed in storage to prevent accumulation.
+
+ Args:
+ scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
+
+ Returns:
+ Deduplicated list of scopes formatted for Azure token endpoint
+ """
+ logger.debug("Base scopes from storage: %s", scopes)
+
+ # Filter out any additional_authorize_scopes that may have been stored
+ # (they shouldn't be in storage, but clean them up if they are)
+ additional_scopes_set = set(self.additional_authorize_scopes or [])
+ base_scopes = [s for s in scopes if s not in additional_scopes_set]
+
+ # Prefix base scopes with identifier_uri for Azure using shared helper
+ prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
+
+ # Add additional scopes (Graph + OIDC) for the Azure request
+ # These are NOT stored in RefreshToken, only sent to Azure
+ if self.additional_authorize_scopes:
+ prefixed_scopes.extend(self.additional_authorize_scopes)
+
+ # Deduplicate while preserving order (in case older tokens have duplicates)
+ # Use dict.fromkeys() for O(n) deduplication with order preservation
+ deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
+
+ logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
+ return deduplicated_scopes
diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py
index 9e89339aa..610acf429 100644
--- a/src/fastmcp/server/auth/providers/descope.py
+++ b/src/fastmcp/server/auth/providers/descope.py
@@ -7,8 +7,10 @@ for seamless MCP client authentication.
from __future__ import annotations
+from urllib.parse import urlparse
+
import httpx
-from pydantic import AnyHttpUrl
+from pydantic import AnyHttpUrl, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.responses import JSONResponse
from starlette.routing import Route
@@ -16,6 +18,7 @@ from starlette.routing import Route
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
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
@@ -29,9 +32,16 @@ class DescopeProviderSettings(BaseSettings):
extra="ignore",
)
- project_id: str
+ config_url: AnyHttpUrl | None = None
+ project_id: str | None = None
+ descope_base_url: AnyHttpUrl | str | None = None
base_url: AnyHttpUrl
- descope_base_url: AnyHttpUrl = AnyHttpUrl("https://api.descope.com")
+ required_scopes: list[str] | None = None
+
+ @field_validator("required_scopes", mode="before")
+ @classmethod
+ def _parse_scopes(cls, v):
+ return parse_scopes(v)
class DescopeProvider(RemoteAuthProvider):
@@ -44,15 +54,15 @@ class DescopeProvider(RemoteAuthProvider):
IMPORTANT SETUP REQUIREMENTS:
- 1. Enable Dynamic Client Registration in Descope Console:
- - Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console
- - Click **DCR Settings**
- - Enable **Dynamic Client Registration (DCR)**
- - Define allowed scopes
+ 1. Create an MCP Server in Descope Console:
+ - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console
+ - Create a new MCP Server
+ - Ensure that **Dynamic Client Registration (DCR)** is enabled
+ - Note your Well-Known URL
- 2. Note your Project ID:
- - Save your Project ID from [Project Settings](https://app.descope.com/settings/project)
- - Example: P2abc...123
+ 2. Note your Well-Known URL:
+ - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers)
+ - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration``
For detailed setup instructions, see:
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
@@ -63,9 +73,8 @@ class DescopeProvider(RemoteAuthProvider):
# Create Descope metadata provider (JWT verifier created automatically)
descope_auth = DescopeProvider(
- project_id="P2abc...123",
+ config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration",
base_url="https://your-fastmcp-server.com",
- descope_base_url="https://api.descope.com",
)
# Use with FastMCP
@@ -76,50 +85,100 @@ class DescopeProvider(RemoteAuthProvider):
def __init__(
self,
*,
+ config_url: AnyHttpUrl | str | NotSetT = NotSet,
project_id: str | NotSetT = NotSet,
- base_url: AnyHttpUrl | str | NotSetT = NotSet,
descope_base_url: AnyHttpUrl | str | NotSetT = NotSet,
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
+ required_scopes: list[str] | NotSetT | None = NotSet,
token_verifier: TokenVerifier | None = None,
):
"""Initialize Descope metadata provider.
Args:
- project_id: Your Descope Project ID (e.g., "P2abc...123")
+ config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration")
+ This is the new recommended way. If provided, project_id and descope_base_url are ignored.
+ project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility.
+ descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility.
base_url: Public URL of this FastMCP server
- descope_base_url: Descope API base URL (defaults to https://api.descope.com)
+ required_scopes: Optional list of scopes that must be present in validated tokens.
+ These scopes will be included in the protected resource metadata.
token_verifier: Optional token verifier. If None, creates JWT verifier for Descope
"""
settings = DescopeProviderSettings.model_validate(
{
k: v
for k, v in {
+ "config_url": config_url,
"project_id": project_id,
- "base_url": base_url,
"descope_base_url": descope_base_url,
+ "base_url": base_url,
+ "required_scopes": required_scopes,
}.items()
if v is not NotSet
}
)
- self.project_id = settings.project_id
self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
- self.descope_base_url = str(settings.descope_base_url).rstrip("/")
+
+ # Determine which API is being used
+ if settings.config_url is not None:
+ # New API: use config_url
+ # Strip /.well-known/openid-configuration from config_url if present
+ issuer_url = str(settings.config_url)
+ if issuer_url.endswith("/.well-known/openid-configuration"):
+ issuer_url = issuer_url[: -len("/.well-known/openid-configuration")]
+
+ # Parse the issuer URL to extract descope_base_url and project_id for other uses
+ parsed_url = urlparse(issuer_url)
+ path_parts = parsed_url.path.strip("/").split("/")
+
+ # Extract project_id from path (format: /v1/apps/agentic/P.../M...)
+ if "agentic" in path_parts:
+ agentic_index = path_parts.index("agentic")
+ if agentic_index + 1 < len(path_parts):
+ self.project_id = path_parts[agentic_index + 1]
+ else:
+ raise ValueError(
+ f"Could not extract project_id from config_url: {issuer_url}"
+ )
+ else:
+ raise ValueError(
+ f"Could not find 'agentic' in config_url path: {issuer_url}"
+ )
+
+ # Extract descope_base_url (scheme + netloc)
+ self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip(
+ "/"
+ )
+ elif settings.project_id is not None and settings.descope_base_url is not None:
+ # Old API: use project_id and descope_base_url
+ self.project_id = settings.project_id
+ descope_base_url_str = str(settings.descope_base_url).rstrip("/")
+ # Ensure descope_base_url has a scheme
+ if not descope_base_url_str.startswith(("http://", "https://")):
+ descope_base_url_str = f"https://{descope_base_url_str}"
+ self.descope_base_url = descope_base_url_str
+ # Old issuer format
+ issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}"
+ else:
+ raise ValueError(
+ "Either config_url (new API) or both project_id and descope_base_url (old API) must be provided"
+ )
# Create default JWT verifier if none provided
if token_verifier is None:
token_verifier = JWTVerifier(
jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json",
- issuer=f"{self.descope_base_url}/v1/apps/{self.project_id}",
+ issuer=issuer_url,
algorithm="RS256",
audience=self.project_id,
+ required_scopes=settings.required_scopes,
)
# Initialize RemoteAuthProvider with Descope as the authorization server
super().__init__(
token_verifier=token_verifier,
- authorization_servers=[
- AnyHttpUrl(f"{self.descope_base_url}/v1/apps/{self.project_id}")
- ],
+ authorization_servers=[AnyHttpUrl(issuer_url)],
base_url=self.base_url,
)
diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py
index 28a817561..0f394e869 100644
--- a/src/fastmcp/server/auth/providers/google.py
+++ b/src/fastmcp/server/auth/providers/google.py
@@ -225,6 +225,7 @@ class GoogleProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | NotSetT = NotSet,
require_authorization_consent: bool = True,
+ extra_authorize_params: dict[str, str] | None = None,
):
"""Initialize Google OAuth provider.
@@ -252,6 +253,10 @@ class GoogleProvider(OAuthProxy):
When True, users see a consent screen before being redirected to Google.
When False, authorization proceeds directly without user confirmation.
SECURITY WARNING: Only disable for local development or testing environments.
+ extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
+ By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
+ refresh tokens are returned. You can override these defaults or add additional parameters.
+ Example: {"prompt": "select_account"} to let users choose their Google account.
"""
settings = GoogleProviderSettings.model_validate(
@@ -299,6 +304,18 @@ class GoogleProvider(OAuthProxy):
settings.client_secret.get_secret_value() if settings.client_secret else ""
)
+ # Set Google-specific defaults for extra authorize params
+ # access_type=offline ensures refresh tokens are returned
+ # prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise)
+ google_defaults = {
+ "access_type": "offline",
+ "prompt": "consent",
+ }
+ # User-provided params override defaults
+ if extra_authorize_params:
+ google_defaults.update(extra_authorize_params)
+ extra_authorize_params_final = google_defaults
+
# Initialize OAuth proxy with Google endpoints
super().__init__(
upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
@@ -314,6 +331,7 @@ class GoogleProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=settings.jwt_signing_key,
require_authorization_consent=require_authorization_consent,
+ extra_authorize_params=extra_authorize_params_final,
)
logger.debug(
diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py
index 9a1bd0c7d..c0275faa4 100644
--- a/src/fastmcp/server/auth/providers/in_memory.py
+++ b/src/fastmcp/server/auth/providers/in_memory.py
@@ -66,6 +66,22 @@ class InMemoryOAuthProvider(OAuthProvider):
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
+ # Validate scopes against valid_scopes if configured (matches MCP SDK behavior)
+ if (
+ client_info.scope is not None
+ and self.client_registration_options is not None
+ and self.client_registration_options.valid_scopes is not None
+ ):
+ requested_scopes = set(client_info.scope.split())
+ valid_scopes = set(self.client_registration_options.valid_scopes)
+ invalid_scopes = requested_scopes - valid_scopes
+ if invalid_scopes:
+ raise ValueError(
+ f"Requested scopes are not valid: {', '.join(invalid_scopes)}"
+ )
+
+ if client_info.client_id is None:
+ raise ValueError("client_id is required for client registration")
if client_info.client_id in self.clients:
# As per RFC 7591, if client_id is already known, it's an update.
# For this simple provider, we'll treat it as re-registration.
@@ -91,7 +107,7 @@ class InMemoryOAuthProvider(OAuthProvider):
# OAuthClientInformationFull should have a method like validate_redirect_uri
# For this test provider, we assume it's valid if it matches one in client_info
# The AuthorizationHandler already does robust validation using client.validate_redirect_uri
- if params.redirect_uri not in client.redirect_uris:
+ if client.redirect_uris and params.redirect_uri not in client.redirect_uris:
# This check might be too simplistic if redirect_uris can be patterns
# or if params.redirect_uri is None and client has a default.
# However, the AuthorizationHandler handles the primary validation.
@@ -110,6 +126,10 @@ class InMemoryOAuthProvider(OAuthProvider):
client_allowed_scopes = set(client.scope.split())
scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
+ if client.client_id is None:
+ raise AuthorizeError(
+ error="invalid_client", error_description="Client ID is required"
+ )
auth_code = AuthorizationCode(
code=auth_code_value,
client_id=client.client_id,
@@ -166,6 +186,8 @@ class InMemoryOAuthProvider(OAuthProvider):
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
)
+ if client.client_id is None:
+ raise TokenError("invalid_client", "Client ID is required")
self.access_tokens[access_token_value] = AccessToken(
token=access_token_value,
client_id=client.client_id,
@@ -236,6 +258,8 @@ class InMemoryOAuthProvider(OAuthProvider):
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
)
+ if client.client_id is None:
+ raise TokenError("invalid_client", "Client ID is required")
self.access_tokens[new_access_token_value] = AccessToken(
token=new_access_token_value,
client_id=client.client_id,
diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py
index 1aefaf052..6f115b867 100644
--- a/src/fastmcp/server/auth/providers/scalekit.py
+++ b/src/fastmcp/server/auth/providers/scalekit.py
@@ -8,7 +8,7 @@ authentication for seamless MCP client authentication.
from __future__ import annotations
import httpx
-from pydantic import AnyHttpUrl
+from pydantic import AnyHttpUrl, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.responses import JSONResponse
from starlette.routing import Route
@@ -16,6 +16,7 @@ from starlette.routing import Route
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
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
@@ -30,9 +31,25 @@ class ScalekitProviderSettings(BaseSettings):
)
environment_url: AnyHttpUrl
- client_id: str
resource_id: str
- mcp_url: AnyHttpUrl
+ base_url: AnyHttpUrl | None = None
+ mcp_url: AnyHttpUrl | None = None
+ required_scopes: list[str] | None = None
+
+ @field_validator("required_scopes", mode="before")
+ @classmethod
+ def _parse_scopes(cls, value: object):
+ return parse_scopes(value)
+
+ @model_validator(mode="after")
+ def _resolve_base_url(self):
+ resolved = self.base_url or self.mcp_url
+ if resolved is None:
+ msg = "Either base_url or mcp_url must be provided for ScalekitProvider"
+ raise ValueError(msg)
+
+ object.__setattr__(self, "base_url", resolved)
+ return self
class ScalekitProvider(RemoteAuthProvider):
@@ -53,9 +70,8 @@ class ScalekitProvider(RemoteAuthProvider):
2. Environment Configuration:
- Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com)
- - Set SCALEKIT_CLIENT_ID from your OAuth application
- Set SCALEKIT_RESOURCE_ID from your created resource
- - Set MCP_URL to your FastMCP server's public URL
+ - Set BASE_URL to your FastMCP server's public URL
For detailed setup instructions, see:
https://docs.scalekit.com/mcp/overview/
@@ -67,9 +83,8 @@ class ScalekitProvider(RemoteAuthProvider):
# Create Scalekit resource server provider
scalekit_auth = ScalekitProvider(
environment_url="https://your-env.scalekit.com",
- client_id="sk_client_...",
resource_id="sk_resource_...",
- mcp_url="https://your-fastmcp-server.com",
+ base_url="https://your-fastmcp-server.com",
)
# Use with FastMCP
@@ -83,44 +98,77 @@ class ScalekitProvider(RemoteAuthProvider):
environment_url: AnyHttpUrl | str | NotSetT = NotSet,
client_id: str | NotSetT = NotSet,
resource_id: str | NotSetT = NotSet,
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
mcp_url: AnyHttpUrl | str | NotSetT = NotSet,
+ required_scopes: list[str] | NotSetT = NotSet,
token_verifier: TokenVerifier | None = None,
):
"""Initialize Scalekit resource server provider.
Args:
environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com")
- client_id: Your Scalekit OAuth client ID
resource_id: Your Scalekit resource ID
- mcp_url: Public URL of this FastMCP server (used as audience)
+ base_url: Public URL of this FastMCP server
+ required_scopes: Optional list of scopes that must be present in tokens
token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit
"""
+ legacy_client_id = client_id is not NotSet
+
settings = ScalekitProviderSettings.model_validate(
{
k: v
for k, v in {
"environment_url": environment_url,
- "client_id": client_id,
"resource_id": resource_id,
+ "base_url": base_url,
"mcp_url": mcp_url,
+ "required_scopes": required_scopes,
}.items()
if v is not NotSet
}
)
+ if settings.mcp_url is not None:
+ logger.warning(
+ "ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. "
+ "Rename it to 'base_url'."
+ )
+
+ if legacy_client_id:
+ logger.warning(
+ "ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward "
+ "compatibility and will be removed in a future release."
+ )
+
self.environment_url = str(settings.environment_url).rstrip("/")
- self.client_id = settings.client_id
self.resource_id = settings.resource_id
- self.mcp_url = str(settings.mcp_url)
+ self.required_scopes = settings.required_scopes or []
+ base_url_value = str(settings.base_url)
+
+ logger.debug(
+ "Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s",
+ self.environment_url,
+ self.resource_id,
+ base_url_value,
+ self.required_scopes,
+ )
# Create default JWT verifier if none provided
if token_verifier is None:
+ logger.debug(
+ "Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s",
+ f"{self.environment_url}/keys",
+ self.environment_url,
+ self.required_scopes,
+ )
token_verifier = JWTVerifier(
jwks_uri=f"{self.environment_url}/keys",
issuer=self.environment_url,
algorithm="RS256",
- audience=self.mcp_url,
+ required_scopes=self.required_scopes or None,
)
+ else:
+ logger.debug("Using custom token verifier for ScalekitProvider")
# Initialize RemoteAuthProvider with Scalekit as the authorization server
super().__init__(
@@ -128,7 +176,7 @@ class ScalekitProvider(RemoteAuthProvider):
authorization_servers=[
AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}")
],
- base_url=self.mcp_url,
+ base_url=base_url_value,
)
def get_routes(
@@ -146,16 +194,27 @@ class ScalekitProvider(RemoteAuthProvider):
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path)
+ logger.debug(
+ "Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s",
+ mcp_path,
+ self.resource_id,
+ )
async def oauth_authorization_server_metadata(request):
"""Forward Scalekit OAuth authorization server metadata with FastMCP customizations."""
try:
+ metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}"
+ logger.debug(
+ "Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url
+ )
async with httpx.AsyncClient() as client:
- response = await client.get(
- f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}"
- )
+ response = await client.get(metadata_url)
response.raise_for_status()
metadata = response.json()
+ logger.debug(
+ "Scalekit metadata fetched successfully: metadata_keys=%s",
+ list(metadata.keys()),
+ )
return JSONResponse(metadata)
except Exception as e:
logger.error(f"Failed to fetch Scalekit metadata: {e}")
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 71ae7bbd0..fcacc3afd 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -1393,7 +1393,9 @@ class FastMCP(Generic[LifespanResultT]):
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
@@ -1444,6 +1446,7 @@ class FastMCP(Generic[LifespanResultT]):
tool_name = name # Use keyword name if provided, otherwise None
# Register the tool immediately and return the tool object
+ # Note: Deprecation warning for exclude_args is handled in Tool.from_function
tool = Tool.from_function(
fn,
name=tool_name,
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 6e4d3a104..b65d1d8fc 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -29,6 +29,7 @@ from fastmcp.utilities.types import (
Image,
NotSet,
NotSetT,
+ create_function_without_params,
find_kwarg_by_type,
get_cached_typeadapter,
replace_type,
@@ -268,6 +269,16 @@ class FunctionTool(Tool):
enabled: bool | None = None,
) -> FunctionTool:
"""Create a Tool from a function."""
+ if exclude_args and fastmcp.settings.deprecation_warnings:
+ warnings.warn(
+ "The `exclude_args` parameter will be deprecated in FastMCP 2.14. "
+ "We recommend using dependency injection with `Depends()` instead, which provides "
+ "better lifecycle management and is more explicit. "
+ "`exclude_args` will continue to work until then. "
+ "See https://gofastmcp.com/docs/servers/tools for examples.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
parsed_fn = ParsedFunction.from_function(fn, exclude_args=exclude_args)
@@ -282,10 +293,11 @@ class FunctionTool(Tool):
# Note: explicit schemas (dict) are used as-is without auto-wrapping
# Validate that explicit schemas are object type for structured content
+ # (resolving $ref references for self-referencing types)
if final_output_schema is not None and isinstance(final_output_schema, dict):
- if final_output_schema.get("type") != "object":
+ if not _is_object_schema(final_output_schema):
raise ValueError(
- f'Output schemas must have "type" set to "object" due to MCP spec limitations. Received: {final_output_schema!r}'
+ f"Output schemas must represent object types due to MCP spec limitations. Received: {final_output_schema!r}"
)
return cls(
@@ -354,6 +366,21 @@ class FunctionTool(Tool):
)
+def _is_object_schema(schema: dict[str, Any]) -> bool:
+ """Check if a JSON schema represents an object type."""
+ # Direct object type
+ if schema.get("type") == "object":
+ return True
+
+ # Schema with properties but no explicit type is treated as object
+ if "properties" in schema:
+ return True
+
+ # Self-referencing types use $ref pointing to $defs
+ # The referenced type is always an object in our use case
+ return "$ref" in schema and "$defs" in schema
+
+
@dataclass
class ParsedFunction:
fn: Callable[..., Any]
@@ -414,7 +441,14 @@ class ParsedFunction:
if exclude_args:
prune_params.extend(exclude_args)
- input_type_adapter = get_cached_typeadapter(fn)
+ # Create a function without excluded parameters in annotations
+ # This prevents Pydantic from trying to serialize non-serializable types
+ # before we can exclude them in compress_schema
+ fn_for_typeadapter = fn
+ if prune_params:
+ fn_for_typeadapter = create_function_without_params(fn, prune_params)
+
+ input_type_adapter = get_cached_typeadapter(fn_for_typeadapter)
input_schema = input_type_adapter.json_schema()
input_schema = compress_schema(
input_schema, prune_params=prune_params, prune_titles=True
@@ -466,10 +500,9 @@ class ParsedFunction:
# Generate schema for wrapped type if it's non-object
# because MCP requires that output schemas are objects
- if (
- wrap_non_object_output_schema
- and base_schema.get("type") != "object"
- ):
+ # Check if schema is an object type, resolving $ref references
+ # (self-referencing types use $ref at root level)
+ if wrap_non_object_output_schema and not _is_object_schema(base_schema):
# Use the wrapped result schema directly
wrapped_type = _WrappedResult[clean_output_type]
wrapped_adapter = get_cached_typeadapter(wrapped_type)
diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py
index 6bbdab6fc..1446d2813 100644
--- a/src/fastmcp/utilities/types.py
+++ b/src/fastmcp/utilities/types.py
@@ -175,6 +175,55 @@ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
return None
+def 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.
+ """
+ import types
+
+ if inspect.ismethod(fn):
+ actual_func = fn.__func__
+ code = actual_func.__code__ # ty: ignore[unresolved-attribute]
+ globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
+ name = actual_func.__name__ # ty: ignore[unresolved-attribute]
+ defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
+ closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
+ else:
+ code = fn.__code__ # ty: ignore[unresolved-attribute]
+ globals_dict = fn.__globals__ # ty: ignore[unresolved-attribute]
+ name = fn.__name__ # ty: ignore[unresolved-attribute]
+ defaults = fn.__defaults__ # ty: ignore[unresolved-attribute]
+ closure = fn.__closure__ # ty: ignore[unresolved-attribute]
+
+ # Create a copy of annotations without the excluded parameters
+ original_annotations = getattr(fn, "__annotations__", {})
+ new_annotations = {
+ k: v for k, v in original_annotations.items() if k not in exclude_params
+ }
+
+ new_func = types.FunctionType(
+ code,
+ globals_dict,
+ name,
+ defaults,
+ closure,
+ )
+ new_func.__dict__.update(fn.__dict__)
+ new_func.__module__ = fn.__module__
+ new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute]
+ new_func.__annotations__ = new_annotations
+
+ if inspect.ismethod(fn):
+ return types.MethodType(new_func, fn.__self__)
+ else:
+ return new_func
+
+
class Image:
"""Helper class for returning images from tools."""
diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py
index dbf41dd0b..f9e452503 100644
--- a/tests/client/auth/test_oauth_client.py
+++ b/tests/client/auth/test_oauth_client.py
@@ -18,7 +18,9 @@ def fastmcp_server(issuer_url: str):
"TestServer",
auth=InMemoryOAuthProvider(
base_url=issuer_url,
- client_registration_options=ClientRegistrationOptions(enabled=True),
+ client_registration_options=ClientRegistrationOptions(
+ enabled=True, valid_scopes=["read", "write"]
+ ),
),
)
@@ -54,7 +56,7 @@ def client_with_headless_oauth(streamable_http_server: str) -> Client:
"""Client with headless OAuth that bypasses browser interaction."""
return Client(
transport=StreamableHttpTransport(streamable_http_server),
- auth=HeadlessOAuth(mcp_url=streamable_http_server),
+ auth=HeadlessOAuth(mcp_url=streamable_http_server, scopes=["read", "write"]),
)
diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py
index 7623a7690..8ad84dc65 100644
--- a/tests/integration_tests/auth/test_github_provider_integration.py
+++ b/tests/integration_tests/auth/test_github_provider_integration.py
@@ -360,6 +360,7 @@ async def test_github_oauth_unauthorized_access(github_server: str):
async def test_github_oauth_with_mock(github_client_with_mock: Client):
"""Test complete GitHub OAuth flow with mocked callback."""
+
async with github_client_with_mock:
# Test that we can ping the server (requires successful OAuth)
assert await github_client_with_mock.ping()
diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py
index 168384eb0..70098725a 100644
--- a/tests/server/auth/providers/test_azure.py
+++ b/tests/server/auth/providers/test_azure.py
@@ -487,3 +487,225 @@ class TestAzureProvider:
parsed = urlparse(provider._upstream_authorization_endpoint)
assert parsed.netloc == "login.microsoftonline.us"
assert "/organizations/" in parsed.path
+
+ def test_prepare_scopes_for_upstream_refresh_basic_prefixing(self):
+ """Test that unprefixed scopes are correctly prefixed for Azure token refresh."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "write"],
+ jwt_signing_key="test-secret",
+ )
+
+ # Unprefixed scopes from storage should be prefixed
+ result = provider._prepare_scopes_for_upstream_refresh(["read", "write"])
+
+ assert "api://my-api/read" in result
+ assert "api://my-api/write" in result
+ assert len(result) == 2
+
+ def test_prepare_scopes_for_upstream_refresh_already_prefixed(self):
+ """Test that already-prefixed scopes remain unchanged."""
+ 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",
+ )
+
+ # Already prefixed scopes should pass through unchanged
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["api://my-api/read", "api://other-api/admin"]
+ )
+
+ assert "api://my-api/read" in result
+ assert "api://other-api/admin" in result
+ assert len(result) == 2
+
+ def test_prepare_scopes_for_upstream_refresh_with_additional_scopes(self):
+ """Test that additional_authorize_scopes are added during token refresh."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ additional_authorize_scopes=[
+ "User.Read",
+ "openid",
+ "profile",
+ "offline_access",
+ ],
+ jwt_signing_key="test-secret",
+ )
+
+ # Base scopes should be prefixed, additional scopes appended
+ result = provider._prepare_scopes_for_upstream_refresh(["read", "write"])
+
+ assert "api://my-api/read" in result
+ assert "api://my-api/write" in result
+ assert "User.Read" in result
+ assert "openid" in result
+ assert "profile" in result
+ assert "offline_access" in result
+ assert len(result) == 6
+
+ def test_prepare_scopes_for_upstream_refresh_filters_duplicate_additional_scopes(
+ self,
+ ):
+ """Test that accidentally stored additional_authorize_scopes are filtered out."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ additional_authorize_scopes=["User.Read", "openid"],
+ jwt_signing_key="test-secret",
+ )
+
+ # If additional scopes were accidentally stored, they should be filtered
+ # to prevent accumulation
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "User.Read", "openid"]
+ )
+
+ # Should have: api://my-api/read (prefixed) + User.Read + openid (added once)
+ assert "api://my-api/read" in result
+ assert result.count("User.Read") == 1
+ assert result.count("openid") == 1
+ assert len(result) == 3
+
+ def test_prepare_scopes_for_upstream_refresh_mixed_scopes(self):
+ """Test mixed scenario with both prefixed and unprefixed scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ additional_authorize_scopes=["User.Read"],
+ jwt_signing_key="test-secret",
+ )
+
+ # Mix of prefixed and unprefixed scopes
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "api://other-api/admin", "write"]
+ )
+
+ assert "api://my-api/read" in result
+ assert "api://other-api/admin" in result # Already prefixed, unchanged
+ assert "api://my-api/write" in result
+ assert "User.Read" in result
+ assert len(result) == 4
+
+ def test_prepare_scopes_for_upstream_refresh_scope_with_slash(self):
+ """Test that scopes containing '/' 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",
+ )
+
+ # Scopes with "/" should not be prefixed (already fully qualified)
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "https://graph.microsoft.com/.default"]
+ )
+
+ assert "api://my-api/read" in result
+ assert (
+ "https://graph.microsoft.com/.default" in result
+ ) # Not prefixed (contains ://)
+
+ def test_prepare_scopes_for_upstream_refresh_empty_scopes(self):
+ """Test behavior with empty scopes list."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ additional_authorize_scopes=["User.Read", "openid"],
+ jwt_signing_key="test-secret",
+ )
+
+ # Empty scopes should still add additional_authorize_scopes
+ result = provider._prepare_scopes_for_upstream_refresh([])
+
+ assert "User.Read" in result
+ assert "openid" in result
+ assert len(result) == 2
+
+ def test_prepare_scopes_for_upstream_refresh_no_additional_scopes(self):
+ """Test behavior when no additional_authorize_scopes are configured."""
+ 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",
+ )
+
+ # Should only prefix base scopes, no additional scopes added
+ result = provider._prepare_scopes_for_upstream_refresh(["read", "write"])
+
+ assert "api://my-api/read" in result
+ assert "api://my-api/write" in result
+ assert len(result) == 2
+
+ def test_prepare_scopes_for_upstream_refresh_deduplicates_scopes(self):
+ """Test that duplicate scopes are deduplicated while preserving order."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ additional_authorize_scopes=["User.Read", "openid"],
+ jwt_signing_key="test-secret",
+ )
+
+ # Test with duplicate base scopes and duplicate additional scopes
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "write", "read", "User.Read", "openid"]
+ )
+
+ # Should have deduplicated results in order
+ assert result == [
+ "api://my-api/read",
+ "api://my-api/write",
+ "User.Read",
+ "openid",
+ ]
+ assert len(result) == 4
+
+ def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants(self):
+ """Test that both prefixed and unprefixed variants are deduplicated."""
+ 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",
+ )
+
+ # Test with both prefixed and unprefixed variants of same scope
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "api://my-api/read", "write"]
+ )
+
+ # Should deduplicate - first occurrence wins (api://my-api/read from "read")
+ assert "api://my-api/read" in result
+ assert "api://my-api/write" in result
+ # Should only have 2 items (read processed twice, but deduplicated)
+ assert len(result) == 2
+ assert result.count("api://my-api/read") == 1
diff --git a/tests/server/auth/providers/test_descope.py b/tests/server/auth/providers/test_descope.py
index eb6d63b47..7ddeeb0ee 100644
--- a/tests/server/auth/providers/test_descope.py
+++ b/tests/server/auth/providers/test_descope.py
@@ -18,30 +18,21 @@ class TestDescopeProvider:
def test_init_with_explicit_params(self):
"""Test DescopeProvider initialization with explicit parameters."""
provider = DescopeProvider(
- project_id="P2abc123",
+ config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
base_url="https://myserver.com",
- descope_base_url="https://api.descope.com",
)
assert provider.project_id == "P2abc123"
assert str(provider.base_url) == "https://myserver.com/"
assert str(provider.descope_base_url) == "https://api.descope.com"
- @pytest.mark.parametrize(
- "scopes_env",
- [
- "openid,email",
- '["openid", "email"]',
- ],
- )
- def test_init_with_env_vars(self, scopes_env):
+ def test_init_with_env_vars(self):
"""Test DescopeProvider initialization from environment variables."""
with patch.dict(
os.environ,
{
- "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_PROJECT_ID": "P2env123",
+ "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL": "https://api.descope.com/v1/apps/agentic/P2env123/M123/.well-known/openid-configuration",
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL": "https://envserver.com",
- "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_DESCOPE_BASE_URL": "https://api.descope.com",
},
):
provider = DescopeProvider()
@@ -50,11 +41,32 @@ class TestDescopeProvider:
assert str(provider.base_url) == "https://envserver.com/"
assert str(provider.descope_base_url) == "https://api.descope.com"
+ def test_init_with_old_env_vars(self):
+ """Test DescopeProvider initialization from old environment variables (backwards compatibility)."""
+ with patch.dict(
+ os.environ,
+ {
+ "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_PROJECT_ID": "P2oldenv123",
+ "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_DESCOPE_BASE_URL": "https://api.descope.com",
+ "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL": "https://envserver.com",
+ },
+ ):
+ provider = DescopeProvider()
+
+ assert provider.project_id == "P2oldenv123"
+ assert str(provider.base_url) == "https://envserver.com/"
+ assert str(provider.descope_base_url) == "https://api.descope.com"
+ assert (
+ provider.token_verifier.issuer # type: ignore[attr-defined]
+ == "https://api.descope.com/v1/apps/P2oldenv123"
+ )
+
def test_environment_variable_loading(self):
"""Test that environment variables are loaded correctly."""
# This test verifies that the provider can be created with environment variables
provider = DescopeProvider(
- project_id="P2env123", base_url="http://env-server.com"
+ config_url="https://api.descope.com/v1/apps/agentic/P2env123/M123/.well-known/openid-configuration",
+ base_url="http://env-server.com",
)
# Should have loaded from environment
@@ -62,48 +74,101 @@ class TestDescopeProvider:
assert str(provider.base_url) == "http://env-server.com/"
assert str(provider.descope_base_url) == "https://api.descope.com"
- def test_descope_base_url_https_prefix_handling(self):
- """Test that descope_base_url handles missing https:// prefix."""
- # Without https:// - should add it
+ def test_config_url_parsing(self):
+ """Test that config_url is parsed correctly to extract base URL and project ID."""
+ # Standard HTTPS URL
provider1 = DescopeProvider(
- project_id="P2abc123",
+ config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
base_url="https://myserver.com",
- descope_base_url="https://api.descope.com",
)
assert str(provider1.descope_base_url) == "https://api.descope.com"
+ assert provider1.project_id == "P2abc123"
- # With https:// - should keep it
+ # HTTP URL (for local testing)
provider2 = DescopeProvider(
- project_id="P2abc123",
+ config_url="http://localhost:8080/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
base_url="https://myserver.com",
- descope_base_url="https://api.descope.com",
)
- assert str(provider2.descope_base_url) == "https://api.descope.com"
+ assert str(provider2.descope_base_url) == "http://localhost:8080"
+ assert provider2.project_id == "P2abc123"
- # With http:// - should be preserved
+ # URL without .well-known/openid-configuration suffix
provider3 = DescopeProvider(
- project_id="P2abc123",
+ config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123",
base_url="https://myserver.com",
- descope_base_url="http://localhost:8080",
)
- assert str(provider3.descope_base_url) == "http://localhost:8080"
+ assert str(provider3.descope_base_url) == "https://api.descope.com"
+ assert provider3.project_id == "P2abc123"
- def test_init_defaults(self):
- """Test that default values are applied correctly."""
+ def test_requires_config_url_or_project_id_and_descope_base_url(self):
+ """Test that either config_url or both project_id and descope_base_url are required."""
+ # Should raise error when neither API is provided
+ with pytest.raises(ValueError, match="Either config_url"):
+ DescopeProvider(
+ base_url="https://myserver.com",
+ )
+
+ def test_backwards_compatibility_with_project_id_and_descope_base_url(self):
+ """Test backwards compatibility with old API using project_id and descope_base_url."""
provider = DescopeProvider(
project_id="P2abc123",
+ descope_base_url="https://api.descope.com",
base_url="https://myserver.com",
)
- # Check defaults
+ assert provider.project_id == "P2abc123"
assert str(provider.descope_base_url) == "https://api.descope.com"
+ assert str(provider.base_url) == "https://myserver.com/"
+
+ # Check that JWT verifier uses the old issuer format
+ assert (
+ provider.token_verifier.issuer # type: ignore[attr-defined]
+ == "https://api.descope.com/v1/apps/P2abc123"
+ )
+ assert (
+ provider.token_verifier.jwks_uri # type: ignore[attr-defined]
+ == "https://api.descope.com/P2abc123/.well-known/jwks.json"
+ )
+
+ def test_backwards_compatibility_descope_base_url_without_scheme(self):
+ """Test that descope_base_url without scheme gets https:// prefix added."""
+ provider = DescopeProvider(
+ project_id="P2abc123",
+ descope_base_url="api.descope.com",
+ base_url="https://myserver.com",
+ )
+
+ assert str(provider.descope_base_url) == "https://api.descope.com"
+ assert (
+ provider.token_verifier.issuer # type: ignore[attr-defined]
+ == "https://api.descope.com/v1/apps/P2abc123"
+ )
+
+ def test_config_url_takes_precedence_over_old_api(self):
+ """Test that config_url takes precedence when both APIs are provided."""
+ provider = DescopeProvider(
+ config_url="https://api.descope.com/v1/apps/agentic/P2new123/M123/.well-known/openid-configuration",
+ project_id="P2old123", # Should be ignored
+ descope_base_url="https://old.descope.com", # Should be ignored
+ base_url="https://myserver.com",
+ )
+
+ # Should use values from config_url, not the old API
+ assert provider.project_id == "P2new123"
+ assert str(provider.descope_base_url) == "https://api.descope.com"
+ assert (
+ provider.token_verifier.issuer # type: ignore[attr-defined]
+ == "https://api.descope.com/v1/apps/agentic/P2new123/M123"
+ )
def test_jwt_verifier_configured_correctly(self):
"""Test that JWT verifier is configured correctly."""
+ config_url = "https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration"
+ issuer_url = "https://api.descope.com/v1/apps/agentic/P2abc123/M123"
+
provider = DescopeProvider(
- project_id="P2abc123",
+ config_url=config_url,
base_url="https://myserver.com",
- descope_base_url="https://api.descope.com",
)
# Check that JWT verifier uses the correct endpoints
@@ -112,19 +177,55 @@ class TestDescopeProvider:
== "https://api.descope.com/P2abc123/.well-known/jwks.json"
)
assert (
- provider.token_verifier.issuer == "https://api.descope.com/v1/apps/P2abc123" # type: ignore[attr-defined]
+ provider.token_verifier.issuer == issuer_url # type: ignore[attr-defined]
)
assert provider.token_verifier.audience == "P2abc123" # type: ignore[attr-defined]
+ def test_required_scopes_support(self):
+ """Test that required_scopes are supported and passed to JWT verifier."""
+ provider = DescopeProvider(
+ config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
+ base_url="https://myserver.com",
+ required_scopes=["read", "write"],
+ )
+
+ # Check that required_scopes are set on the token verifier
+ assert provider.token_verifier.required_scopes == ["read", "write"] # type: ignore[attr-defined]
+
+ def test_required_scopes_with_old_api(self):
+ """Test that required_scopes work with the old API (project_id + descope_base_url)."""
+ provider = DescopeProvider(
+ project_id="P2abc123",
+ descope_base_url="https://api.descope.com",
+ base_url="https://myserver.com",
+ required_scopes=["openid", "email"],
+ )
+
+ # Check that required_scopes are set on the token verifier
+ assert provider.token_verifier.required_scopes == ["openid", "email"] # type: ignore[attr-defined]
+
+ def test_required_scopes_from_env(self):
+ """Test that required_scopes can be set via environment variable."""
+ with patch.dict(
+ os.environ,
+ {
+ "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL": "https://api.descope.com/v1/apps/agentic/P2env123/M123/.well-known/openid-configuration",
+ "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL": "https://envserver.com",
+ "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_REQUIRED_SCOPES": "read,write",
+ },
+ ):
+ provider = DescopeProvider()
+
+ assert provider.token_verifier.required_scopes == ["read", "write"] # type: ignore[attr-defined]
+
@pytest.fixture
async def mcp_server_url():
"""Start Descope server."""
mcp = FastMCP(
auth=DescopeProvider(
- project_id="P2test123",
+ config_url="https://api.descope.com/v1/apps/agentic/P2test123/M123/.well-known/openid-configuration",
base_url="http://localhost:4321",
- descope_base_url="https://api.descope.com",
)
)
diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py
index ab634e3be..66d4aa8c3 100644
--- a/tests/server/auth/providers/test_google.py
+++ b/tests/server/auth/providers/test_google.py
@@ -119,3 +119,46 @@ class TestGoogleProvider:
# Provider should initialize successfully with these scopes
assert provider is not None
+
+ def test_extra_authorize_params_defaults(self):
+ """Test that Google-specific defaults are set for refresh token support."""
+ provider = GoogleProvider(
+ client_id="123456789.apps.googleusercontent.com",
+ client_secret="GOCSPX-test123",
+ jwt_signing_key="test-secret",
+ )
+
+ # Should have Google-specific defaults for refresh token support
+ assert provider._extra_authorize_params == {
+ "access_type": "offline",
+ "prompt": "consent",
+ }
+
+ def test_extra_authorize_params_override_defaults(self):
+ """Test that user can override default extra authorize params."""
+ provider = GoogleProvider(
+ client_id="123456789.apps.googleusercontent.com",
+ client_secret="GOCSPX-test123",
+ jwt_signing_key="test-secret",
+ extra_authorize_params={"prompt": "select_account"},
+ )
+
+ # User override should replace the default
+ assert provider._extra_authorize_params["prompt"] == "select_account"
+ # But other defaults should remain
+ assert provider._extra_authorize_params["access_type"] == "offline"
+
+ def test_extra_authorize_params_add_new_params(self):
+ """Test that user can add additional authorize params."""
+ provider = GoogleProvider(
+ client_id="123456789.apps.googleusercontent.com",
+ client_secret="GOCSPX-test123",
+ jwt_signing_key="test-secret",
+ extra_authorize_params={"login_hint": "user@example.com"},
+ )
+
+ # New param should be added
+ assert provider._extra_authorize_params["login_hint"] == "user@example.com"
+ # Defaults should still be present
+ assert provider._extra_authorize_params["access_type"] == "offline"
+ assert provider._extra_authorize_params["prompt"] == "consent"
diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py
index f33ca5952..70470a149 100644
--- a/tests/server/auth/providers/test_scalekit.py
+++ b/tests/server/auth/providers/test_scalekit.py
@@ -19,15 +19,36 @@ class TestScalekitProvider:
"""Test ScalekitProvider initialization with explicit parameters."""
provider = ScalekitProvider(
environment_url="https://my-env.scalekit.com",
- client_id="sk_client_123",
resource_id="sk_resource_456",
- mcp_url="https://myserver.com/",
+ base_url="https://myserver.com/",
+ required_scopes=["read"],
)
assert provider.environment_url == "https://my-env.scalekit.com"
- assert provider.client_id == "sk_client_123"
assert provider.resource_id == "sk_resource_456"
- assert str(provider.mcp_url) == "https://myserver.com/"
+ assert str(provider.base_url) == "https://myserver.com/"
+ assert provider.required_scopes == ["read"]
+
+ def test_init_with_mcp_url_only(self):
+ """Allow legacy mcp_url parameter as base_url."""
+ provider = ScalekitProvider(
+ environment_url="https://legacy.scalekit.com",
+ resource_id="sk_resource_legacy",
+ mcp_url="https://legacy-app.com/",
+ )
+
+ assert str(provider.base_url) == "https://legacy-app.com/"
+
+ def test_init_prefers_base_url_over_mcp_url(self):
+ """mcp_url should take precedence over base_url when both provided."""
+ provider = ScalekitProvider(
+ environment_url="https://my-env.scalekit.com",
+ resource_id="sk_resource_456",
+ base_url="https://preferred-base.com/",
+ mcp_url="https://unused-base.com/",
+ )
+
+ assert str(provider.base_url) == "https://preferred-base.com/"
def test_init_with_env_vars(self):
"""Test ScalekitProvider initialization from environment variables."""
@@ -35,51 +56,72 @@ class TestScalekitProvider:
os.environ,
{
"FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL": "https://env-scalekit.com",
- "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_CLIENT_ID": "skc_123",
"FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID": "res_456",
- "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL": "https://envserver.com/mcp",
+ "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_BASE_URL": "https://envserver.com/mcp",
+ "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_REQUIRED_SCOPES": "read,write",
},
):
provider = ScalekitProvider()
assert provider.environment_url == "https://env-scalekit.com"
- assert provider.client_id == "skc_123"
assert provider.resource_id == "res_456"
- assert str(provider.mcp_url) == "https://envserver.com/mcp"
+ assert str(provider.base_url) == "https://envserver.com/mcp"
+ assert provider.required_scopes == ["read", "write"]
+
+ def test_init_with_legacy_env_var(self):
+ """FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL should still be supported."""
+ with patch.dict(
+ os.environ,
+ {
+ "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL": "https://env-scalekit.com",
+ "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID": "res_456",
+ "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL": "https://legacy-env.com/",
+ },
+ ):
+ provider = ScalekitProvider()
+
+ assert str(provider.base_url) == "https://legacy-env.com/"
def test_environment_variable_loading(self):
"""Test that environment variables are loaded correctly."""
provider = ScalekitProvider(
environment_url="https://test-env.scalekit.com",
- client_id="sk_client_test_123",
resource_id="sk_resource_test_456",
- mcp_url="http://test-server.com",
+ base_url="http://test-server.com",
)
assert provider.environment_url == "https://test-env.scalekit.com"
- assert provider.client_id == "sk_client_test_123"
assert provider.resource_id == "sk_resource_test_456"
- assert str(provider.mcp_url) == "http://test-server.com/"
+ assert str(provider.base_url) == "http://test-server.com/"
+
+ def test_accepts_client_id_argument(self):
+ """client_id parameter should be accepted but ignored."""
+ provider = ScalekitProvider(
+ environment_url="https://my-env.scalekit.com",
+ resource_id="sk_resource_456",
+ base_url="https://myserver.com/",
+ client_id="client_123",
+ )
+
+ assert str(provider.base_url) == "https://myserver.com/"
def test_url_trailing_slash_handling(self):
"""Test that URLs handle trailing slashes correctly."""
provider = ScalekitProvider(
environment_url="https://my-env.scalekit.com/",
- client_id="sk_client_123",
resource_id="sk_resource_456",
- mcp_url="https://myserver.com/",
+ base_url="https://myserver.com/",
)
assert provider.environment_url == "https://my-env.scalekit.com"
- assert str(provider.mcp_url) == "https://myserver.com/"
+ assert str(provider.base_url) == "https://myserver.com/"
def test_jwt_verifier_configured_correctly(self):
"""Test that JWT verifier is configured correctly."""
provider = ScalekitProvider(
environment_url="https://my-env.scalekit.com",
- client_id="sk_client_123",
resource_id="sk_resource_456",
- mcp_url="https://myserver.com/",
+ base_url="https://myserver.com/",
)
# Check that JWT verifier uses the correct endpoints
@@ -90,15 +132,27 @@ class TestScalekitProvider:
assert (
provider.token_verifier.issuer == "https://my-env.scalekit.com" # type: ignore[attr-defined]
)
- assert provider.token_verifier.audience == "https://myserver.com/" # type: ignore[attr-defined]
+ assert (
+ provider.token_verifier.audience is None # type: ignore[attr-defined]
+ )
+
+ def test_required_scopes_hooks_into_verifier(self):
+ """Token verifier should enforce required scopes when provided."""
+ provider = ScalekitProvider(
+ environment_url="https://my-env.scalekit.com",
+ resource_id="sk_resource_456",
+ base_url="https://myserver.com/",
+ required_scopes=["read"],
+ )
+
+ assert provider.token_verifier.required_scopes == ["read"] # type: ignore[attr-defined]
def test_authorization_servers_configuration(self):
"""Test that authorization servers are configured correctly."""
provider = ScalekitProvider(
environment_url="https://my-env.scalekit.com",
- client_id="sk_client_123",
resource_id="sk_resource_456",
- mcp_url="https://myserver.com/",
+ base_url="https://myserver.com/",
)
assert len(provider.authorization_servers) == 1
@@ -114,9 +168,8 @@ async def mcp_server_url():
mcp = FastMCP(
auth=ScalekitProvider(
environment_url="https://test-env.scalekit.com",
- client_id="sk_client_test_123",
resource_id="sk_resource_test_456",
- mcp_url="http://localhost:4321",
+ base_url="http://localhost:4321",
)
)
@@ -147,9 +200,60 @@ class TestScalekitProviderIntegration:
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
- # async def test_authorized_access(self, client_with_headless_oauth: Client):
- # async with client_with_headless_oauth:
- # tools = await client_with_headless_oauth.list_tools()
- # assert tools is not None
- # assert len(tools) > 0
- # assert "add" in tools
+ async def test_metadata_route_forwards_scalekit_response(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ mcp_server_url: str,
+ ) -> None:
+ """Ensure Scalekit metadata route proxies upstream JSON."""
+
+ metadata_payload = {
+ "issuer": "https://test-env.scalekit.com",
+ "token_endpoint": "https://test-env.scalekit.com/token",
+ "authorization_endpoint": "https://test-env.scalekit.com/authorize",
+ }
+
+ class DummyResponse:
+ status_code = 200
+
+ def __init__(self, data: dict[str, str]):
+ self._data = data
+
+ def json(self):
+ return self._data
+
+ def raise_for_status(self):
+ return None
+
+ class DummyAsyncClient:
+ last_url: str | None = None
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def get(self, url: str):
+ DummyAsyncClient.last_url = url
+ return DummyResponse(metadata_payload)
+
+ real_httpx_client = httpx.AsyncClient
+
+ monkeypatch.setattr(
+ "fastmcp.server.auth.providers.scalekit.httpx.AsyncClient",
+ DummyAsyncClient,
+ )
+
+ base_url = mcp_server_url.rsplit("/mcp", 1)[0]
+ async with real_httpx_client() as client:
+ response = await client.get(
+ f"{base_url}/.well-known/oauth-authorization-server"
+ )
+
+ assert response.status_code == 200
+ assert response.json() == metadata_payload
+ assert (
+ DummyAsyncClient.last_url
+ == "https://test-env.scalekit.com/.well-known/oauth-authorization-server/resources/sk_resource_test_456"
+ )
diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py
index 2dd9cac24..b8e373e40 100644
--- a/tests/server/auth/test_oidc_proxy.py
+++ b/tests/server/auth/test_oidc_proxy.py
@@ -783,3 +783,96 @@ class TestOIDCProxyInitialization:
validate_proxy(mock_get, proxy, oidc_config)
assert proxy._extra_authorize_params == {"audience": "test-audience"}
assert proxy._extra_token_params == {"audience": "test-audience"}
+
+ def test_extra_authorize_params_initialization(self, valid_oidc_configuration_dict):
+ """Test extra authorize params initialization."""
+ with patch(
+ "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration"
+ ) as mock_get:
+ oidc_config = OIDCConfiguration.model_validate(
+ valid_oidc_configuration_dict
+ )
+ mock_get.return_value = oidc_config
+
+ proxy = OIDCProxy(
+ config_url=TEST_CONFIG_URL,
+ client_id=TEST_CLIENT_ID,
+ client_secret=TEST_CLIENT_SECRET,
+ base_url=TEST_BASE_URL,
+ jwt_signing_key="test-secret",
+ extra_authorize_params={
+ "prompt": "consent",
+ "access_type": "offline",
+ },
+ )
+
+ validate_proxy(mock_get, proxy, oidc_config)
+
+ assert proxy._extra_authorize_params == {
+ "prompt": "consent",
+ "access_type": "offline",
+ }
+ # Token params should be empty since we didn't set them
+ assert proxy._extra_token_params == {}
+
+ def test_extra_token_params_initialization(self, valid_oidc_configuration_dict):
+ """Test extra token params initialization."""
+ with patch(
+ "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration"
+ ) as mock_get:
+ oidc_config = OIDCConfiguration.model_validate(
+ valid_oidc_configuration_dict
+ )
+ mock_get.return_value = oidc_config
+
+ proxy = OIDCProxy(
+ config_url=TEST_CONFIG_URL,
+ client_id=TEST_CLIENT_ID,
+ client_secret=TEST_CLIENT_SECRET,
+ base_url=TEST_BASE_URL,
+ jwt_signing_key="test-secret",
+ extra_token_params={"custom_param": "custom_value"},
+ )
+
+ validate_proxy(mock_get, proxy, oidc_config)
+
+ # Authorize params should be empty since we didn't set them
+ assert proxy._extra_authorize_params == {}
+ assert proxy._extra_token_params == {"custom_param": "custom_value"}
+
+ def test_extra_params_merge_with_audience(self, valid_oidc_configuration_dict):
+ """Test that extra params merge with audience, with user params taking precedence."""
+ with patch(
+ "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration"
+ ) as mock_get:
+ oidc_config = OIDCConfiguration.model_validate(
+ valid_oidc_configuration_dict
+ )
+ mock_get.return_value = oidc_config
+
+ proxy = OIDCProxy(
+ config_url=TEST_CONFIG_URL,
+ client_id=TEST_CLIENT_ID,
+ client_secret=TEST_CLIENT_SECRET,
+ base_url=TEST_BASE_URL,
+ audience="original-audience",
+ jwt_signing_key="test-secret",
+ extra_authorize_params={
+ "prompt": "consent",
+ "audience": "overridden-audience", # Should override the audience param
+ },
+ extra_token_params={"custom": "value"},
+ )
+
+ validate_proxy(mock_get, proxy, oidc_config)
+
+ # User's extra_authorize_params should override audience
+ assert proxy._extra_authorize_params == {
+ "audience": "overridden-audience",
+ "prompt": "consent",
+ }
+ # Token params should have both audience (from audience param) and custom
+ assert proxy._extra_token_params == {
+ "audience": "original-audience",
+ "custom": "value",
+ }
diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py
index 01dadafde..ed90d6345 100644
--- a/tests/server/middleware/test_caching.py
+++ b/tests/server/middleware/test_caching.py
@@ -289,8 +289,8 @@ class TestResponseCachingMiddlewareIntegration:
"""Create a FastMCP server for caching tests."""
mcp = FastMCP("CachingTestServer")
- with tempfile.TemporaryDirectory() as temp_dir:
- disk_store = DiskStore(directory=temp_dir)
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
+ disk_store: DiskStore = DiskStore(directory=temp_dir)
response_caching_middleware = ResponseCachingMiddleware(
cache_storage=disk_store if request.param == "disk" else MemoryStore(),
)
diff --git a/tests/server/test_auth_integration.py b/tests/server/test_auth_integration.py
index 3ef2b4018..1eb232f8b 100644
--- a/tests/server/test_auth_integration.py
+++ b/tests/server/test_auth_integration.py
@@ -49,6 +49,8 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider):
) -> str:
# toy authorize implementation which just immediately generates an authorization
# code and completes the redirect
+ if client.client_id is None:
+ raise ValueError("client_id is required")
code = AuthorizationCode(
code=f"code_{int(time.time())}",
client_id=client.client_id,
@@ -79,6 +81,8 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider):
refresh_token = f"refresh_{secrets.token_hex(32)}"
# Store the tokens
+ if client.client_id is None:
+ raise ValueError("client_id is required")
self.tokens[access_token] = AccessToken(
token=access_token,
client_id=client.client_id,
@@ -142,6 +146,8 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider):
new_refresh_token = f"refresh_{secrets.token_hex(32)}"
# Store the new tokens
+ if client.client_id is None:
+ raise ValueError("client_id is required")
self.tokens[new_access_token] = AccessToken(
token=new_access_token,
client_id=client.client_id,
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index 7a21d78b2..22fcf5e59 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -424,7 +424,7 @@ class TestToolDecorator:
mcp = FastMCP()
with pytest.raises(
- ValueError, match='Output schemas must have "type" set to "object"'
+ ValueError, match="Output schemas must represent object types"
):
@mcp.tool(output_schema={"type": "integer"})
diff --git a/tests/server/test_tool_exclude_args.py b/tests/server/test_tool_exclude_args.py
index fec695f88..759dd5811 100644
--- a/tests/server/test_tool_exclude_args.py
+++ b/tests/server/test_tool_exclude_args.py
@@ -1,6 +1,7 @@
from typing import Any
import pytest
+from mcp.server.session import ServerSession
from fastmcp import Client, FastMCP
from fastmcp.tools.tool import Tool
@@ -92,3 +93,32 @@ async def test_tool_functionality_with_exclude_args():
"create_item", {"name": "test_item", "value": 42}
)
assert result.data == {"name": "test_item", "value": 42}
+
+
+async def test_exclude_args_with_non_serializable_type():
+ """Test that exclude_args works even when the excluded parameter type can't be serialized.
+
+ This test ensures that exclude_args works correctly when the excluded parameter
+ has a type that Pydantic cannot serialize (like ServerSession). The bug was that
+ get_cached_typeadapter would try to serialize all parameters before compress_schema
+ could exclude them, causing a PydanticSchemaGenerationError.
+ """
+
+ def my_tool(message: str, session: ServerSession | None = None) -> str:
+ """A tool that takes a non-serializable Session parameter."""
+ return message
+
+ # This should not raise an error even though ServerSession can't be serialized
+ tool = Tool.from_function(
+ my_tool,
+ name="my_tool",
+ exclude_args=["session"],
+ )
+
+ # Verify the tool was created successfully
+ assert tool is not None
+ assert tool.name == "my_tool"
+
+ # Verify the session parameter is excluded from the schema
+ assert "session" not in tool.parameters["properties"]
+ assert "message" in tool.parameters["properties"]
diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py
index c779d0942..731bfed91 100644
--- a/tests/tools/test_tool.py
+++ b/tests/tools/test_tool.py
@@ -927,7 +927,7 @@ class TestToolFromFunctionOutputSchema:
for schema in non_object_schemas:
with pytest.raises(
- ValueError, match='Output schemas must have "type" set to "object"'
+ ValueError, match="Output schemas must represent object types"
):
Tool.from_function(func, output_schema=schema)
@@ -1262,6 +1262,31 @@ class TestAutomaticStructuredContent:
"verified": True,
}
+ async def test_self_referencing_dataclass_not_wrapped(self):
+ """Test that self-referencing dataclasses are not wrapped in result field."""
+
+ @dataclass
+ class ReturnThing:
+ value: int
+ stuff: list["ReturnThing"]
+
+ def return_things() -> ReturnThing:
+ return ReturnThing(value=123, stuff=[ReturnThing(value=456, stuff=[])])
+
+ tool = Tool.from_function(return_things)
+
+ result = await tool.run({})
+
+ # Should have structured content without wrapping
+ assert result.structured_content is not None
+ # Should NOT be wrapped in "result" field
+ assert "result" not in result.structured_content
+ # Should have the actual data directly
+ assert result.structured_content == {
+ "value": 123,
+ "stuff": [{"value": 456, "stuff": []}],
+ }
+
async def test_int_return_no_structured_content_without_schema(self):
"""Test that int returns don't create structured content without output schema."""
@@ -1524,13 +1549,20 @@ class TestSerializationAlias:
# not the first validation alias 'id'
assert tool.output_schema is not None
- # Check the wrapped result schema
- assert "properties" in tool.output_schema
- assert "result" in tool.output_schema["properties"]
- assert "$defs" in tool.output_schema
-
- # Find the Component definition
- component_def = list(tool.output_schema["$defs"].values())[0]
+ # For object types, the schema may use $ref at root (self-referencing types)
+ # or have properties directly. Check both cases.
+ if "$ref" in tool.output_schema:
+ # Schema uses $ref - resolve to get the actual definition
+ assert "$defs" in tool.output_schema
+ ref_path = tool.output_schema["$ref"].replace("#/$defs/", "")
+ component_def = tool.output_schema["$defs"][ref_path]
+ else:
+ # Schema has properties directly (wrapped case)
+ assert "properties" in tool.output_schema
+ assert "result" in tool.output_schema["properties"]
+ assert "$defs" in tool.output_schema
+ # Find the Component definition
+ component_def = list(tool.output_schema["$defs"].values())[0]
# Should have 'componentId' not 'id' in properties
assert "componentId" in component_def["properties"]
@@ -1573,8 +1605,13 @@ class TestSerializationAlias:
# The result should contain the serialized form with 'componentId'
assert result.structured_content is not None
- assert result.structured_content["result"]["componentId"] == "test123"
- assert "id" not in result.structured_content["result"]
+ # Object types may be wrapped in "result" or not, depending on schema structure
+ if "result" in result.structured_content:
+ component_data = result.structured_content["result"]
+ else:
+ component_data = result.structured_content
+ assert component_data["componentId"] == "test123"
+ assert "id" not in component_data
class TestToolTitle:
diff --git a/uv.lock b/uv.lock
index cd0f46d40..5c41dbc28 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.11'",
@@ -69,15 +69,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
-[[package]]
-name = "backports-tarfile"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
-]
-
[[package]]
name = "beartype"
version = "0.22.2"
@@ -571,7 +562,7 @@ dependencies = [
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "platformdirs" },
- { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
+ { name = "py-key-value-aio", extra = ["disk", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" },
{ name = "python-dotenv" },
@@ -619,11 +610,11 @@ requires-dist = [
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema-path", specifier = ">=0.3.4" },
- { name = "mcp", specifier = ">=1.19.0,<2.0.0" },
+ { name = "mcp", specifier = ">=1.19.0,!=1.21.1,<2.0.0" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "platformdirs", specifier = ">=4.0.0" },
- { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.2.8,<0.3.0" },
+ { name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.8,<0.4.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
{ name = "pyperclip", specifier = ">=1.9.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
@@ -714,18 +705,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
-[[package]]
-name = "importlib-metadata"
-version = "8.7.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "zipp" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
-]
-
[[package]]
name = "iniconfig"
version = "2.1.0"
@@ -818,42 +797,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
]
-[[package]]
-name = "jaraco-classes"
-version = "3.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "more-itertools" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
-]
-
-[[package]]
-name = "jaraco-context"
-version = "6.0.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "backports-tarfile", marker = "python_full_version < '3.12'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
-]
-
-[[package]]
-name = "jaraco-functools"
-version = "4.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "more-itertools" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
-]
-
[[package]]
name = "jedi"
version = "0.19.2"
@@ -866,15 +809,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
]
-[[package]]
-name = "jeepney"
-version = "0.9.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
-]
-
[[package]]
name = "jiter"
version = "0.10.0"
@@ -989,24 +923,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
]
-[[package]]
-name = "keyring"
-version = "25.6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "importlib-metadata", marker = "python_full_version < '3.12'" },
- { name = "jaraco-classes" },
- { name = "jaraco-context" },
- { name = "jaraco-functools" },
- { name = "jeepney", marker = "sys_platform == 'linux'" },
- { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
- { name = "secretstorage", marker = "sys_platform == 'linux'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" },
-]
-
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -1033,7 +949,7 @@ wheels = [
[[package]]
name = "mcp"
-version = "1.19.0"
+version = "1.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1042,15 +958,16 @@ dependencies = [
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697, upload-time = "2025-11-06T23:19:58.432Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" },
+ { url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672, upload-time = "2025-11-06T23:19:56.508Z" },
]
[[package]]
@@ -1062,15 +979,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
-[[package]]
-name = "more-itertools"
-version = "10.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" },
-]
-
[[package]]
name = "openai"
version = "2.6.1"
@@ -1254,15 +1162,15 @@ wheels = [
[[package]]
name = "py-key-value-aio"
-version = "0.2.8"
+version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "py-key-value-shared" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ca/35/65310a4818acec0f87a46e5565e341c5a96fc062a9a03495ad28828ff4d7/py_key_value_aio-0.2.8.tar.gz", hash = "sha256:c0cfbb0bd4e962a3fa1a9fa6db9ba9df812899bd9312fa6368aaea7b26008b36", size = 32853, upload-time = "2025-10-24T13:31:04.688Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cd/5a/e56747d87a97ad2aff0f3700d77f186f0704c90c2da03bfed9e113dae284/py_key_value_aio-0.2.8-py3-none-any.whl", hash = "sha256:561565547ce8162128fd2bd0b9d70ce04a5f4586da8500cce79a54dfac78c46a", size = 69200, upload-time = "2025-10-24T13:31:03.81Z" },
+ { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" },
]
[package.optional-dependencies]
@@ -1270,24 +1178,21 @@ disk = [
{ name = "diskcache" },
{ name = "pathvalidate" },
]
-keyring = [
- { name = "keyring" },
-]
memory = [
{ name = "cachetools" },
]
[[package]]
name = "py-key-value-shared"
-version = "0.2.8"
+version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/26/79/05a1f9280cfa0709479319cbfd2b1c5beb23d5034624f548c83fb65b0b61/py_key_value_shared-0.2.8.tar.gz", hash = "sha256:703b4d3c61af124f0d528ba85995c3c8d78f8bd3d2b217377bd3278598070cc1", size = 8216, upload-time = "2025-10-24T13:31:03.601Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/7a/1726ceaa3343874f322dd83c9ec376ad81f533df8422b8b1e1233a59f8ce/py_key_value_shared-0.2.8-py3-none-any.whl", hash = "sha256:aff1bbfd46d065b2d67897d298642e80e5349eae588c6d11b48452b46b8d46ba", size = 14586, upload-time = "2025-10-24T13:31:02.838Z" },
+ { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" },
]
[[package]]
@@ -1485,6 +1390,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/e8/691115aa790a2fa4bfad456287061a7439aaf877edfb0befd13486440de9/pyinstrument-5.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c6711d53e600cfadb16bff68ba29c9e4f13e61196f185e32e8e29c8baa1dd606", size = 126064, upload-time = "2025-08-10T11:17:37.013Z" },
]
+[[package]]
+name = "pyjwt"
+version = "2.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+]
+
+[package.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
+]
+
[[package]]
name = "pyperclip"
version = "1.9.0"
@@ -1679,15 +1598,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
-[[package]]
-name = "pywin32-ctypes"
-version = "0.2.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
-]
-
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -1947,19 +1857,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" },
]
-[[package]]
-name = "secretstorage"
-version = "3.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cryptography" },
- { name = "jeepney" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" },
-]
-
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -2204,12 +2101,3 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
-
-[[package]]
-name = "zipp"
-version = "3.23.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
-]