mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Merge branch 'main' into 2-14-deprecations
This commit is contained in:
commit
98d9a2b9d0
34 changed files with 1266 additions and 373 deletions
6
.github/workflows/martian-test-failure.yml
vendored
6
.github/workflows/martian-test-failure.yml
vendored
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
168
uv.lock
generated
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue