Merge branch 'main' into 2-14-deprecations

This commit is contained in:
Jeremiah Lowin 2025-11-22 12:22:34 -05:00 committed by GitHub
commit 98d9a2b9d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1266 additions and 373 deletions

View file

@ -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"

View file

@ -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).
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).

View file

@ -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)
<VersionBadge version="2.13.0" />
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)

View file

@ -6,7 +6,7 @@ icon: shield-check
tag: NEW
---
import { VersionBadge } from "/snippets/version-badge.mdx"
import { VersionBadge } from "/snippets/version-badge.mdx";
<VersionBadge version="2.12.4" />
@ -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
<Steps>
<Step title="Enable Dynamic Client Registration">
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
<Step title="Create an MCP Server">
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.
<Warning>
DCR is required for FastMCP clients to automatically register with your authentication server.
</Warning>
</Step>
<Step title="Note Your Project ID">
Save your Project ID from [Project Settings](https://app.descope.com/settings/project):
<Step title="Note Your Well-Known URL">
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
```
</Step>
</Steps>
@ -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
```
<Note>
You can find your project's Descope Base URL in the [Multi-Region Support Guide](https://docs.descope.com/management/project-settings/multi-regional).
</Note>
### 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.
<Card>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use Descope authentication.
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use
Descope authentication.
</ParamField>
</Card>
### 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`.
<Card>
<ParamField path="FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_PROJECT_ID" required>
Your Descope Project ID from the [Descope Console](https://app.descope.com/settings/project)
<ParamField path="FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL" required>
Your Well-Known URL from the [Descope Console](https://app.descope.com/mcp-servers)
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL" required>
Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000` for development)
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_DESCOPE_BASE_URL" default="https://api.descope.com">
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)
</ParamField>
</Card>
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")
```
```

View file

@ -8,19 +8,16 @@ tag: NEW
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="2.12.5" />
<VersionBadge version="2.13.0" />
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=<YOUR_APP_ENVIRONMENT_URL>
SCALEKIT_CLIENT_ID=<YOUR_APP_CLIENT_ID> # skc_7008EXAMPLE46
SCALEKIT_RESOURCE_ID=<YOUR_APP_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
```
</Step>
@ -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:
```
<Tip>
Set `required_scopes` when you need tokens to carry specific permissions. Leave it unset to allow any token issued for the resource.
</Tip>
## 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`)
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_CLIENT_ID" required>
Your Scalekit OAuth application client ID from the Applications section
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID" required>
Your Scalekit resource server ID from the Resources section
Your Scalekit resource server ID from the MCP Servers section
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL" required>
Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000/mcp` for development)
<ParamField path="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_BASE_URL" required>
Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000/` for development)
</ParamField>
Legacy `FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL` is still recognized for backward compatibility but will be removed soon-rename it to `...BASE_URL`.
<ParamField path="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_REQUIRED_SCOPES" default="[]">
Comma-, space-, or JSON-separated list of scopes that tokens must include to access your server
</ParamField>
</Card>
@ -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:

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/descope.py#L25" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `DescopeProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/descope.py#L37" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/descope.py#L126" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
@ -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.

View file

@ -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.

View file

@ -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(

View file

@ -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=<YOUR_APP_ENVIRONMENT_URL>
SCALEKIT_CLIENT_ID=<YOUR_APP_CLIENT_ID> # skc_7008EXAMPLE46
SCALEKIT_RESOURCE_ID=<YOUR_APP_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

View file

@ -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)

View file

@ -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",

View file

@ -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,

View file

@ -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]

View file

@ -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

View file

@ -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,
)

View file

@ -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(

View file

@ -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,

View file

@ -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}")

View file

@ -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,

View file

@ -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)

View file

@ -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."""

View file

@ -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"]),
)

View file

@ -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()

View file

@ -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

View file

@ -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",
)
)

View file

@ -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"

View file

@ -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"
)

View file

@ -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",
}

View file

@ -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(),
)

View file

@ -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,

View file

@ -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"})

View file

@ -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"]

View file

@ -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:

168
uv.lock generated
View file

@ -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" },
]