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