diff --git a/.github/workflows/marvin.yml b/.github/workflows/marvin.yml index 9566eea11..65d8cbf8a 100644 --- a/.github/workflows/marvin.yml +++ b/.github/workflows/marvin.yml @@ -74,5 +74,6 @@ jobs: "model": "claude-sonnet-4-5-20250929", "env": { "GH_TOKEN": "${{ steps.marvin-token.outputs.token }}" - } + }, + "customInstructions": "When you complete work on an issue: (1) You MUST create a pull request using the mcp__github__create_pull_request tool instead of posting a link, and (2) You MUST add the 'marvin-pr' label to the original issue using mcp__github__update_issue. Even if PR creation fails and you post a link instead, you MUST still add the 'marvin-pr' label. Follow the PR message guidelines in CLAUDE.md." } diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 29c024e86..d8cd826a6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -59,6 +59,31 @@ jobs: - name: Run client process tests separately run: uv run pytest --inline-snapshot=disable tests -m "client_process" -x + run_tests_lowest_direct: + name: "Run tests with lowest-direct dependencies" + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.10" + + - name: Install FastMCP with lowest-direct resolution + # run with lowest-direct to test against the minimum allowed dependency versions + run: uv sync --resolution lowest-direct + + - name: Run tests (excluding integration and client_process) + run: uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal + + - name: Run client process tests separately + run: uv run pytest --inline-snapshot=disable tests -m "client_process" -x + run_integration_tests: name: "Run integration tests" runs-on: ubuntu-latest diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 8fde12fe8..0e84bd4da 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,40 @@ icon: "list-check" rss: true --- + + +**[v2.13.0: Cache Me If You Can](https://github.com/jlowin/fastmcp/releases/tag/v2.13.0)** + +FastMCP 2.13 "Cache Me If You Can" represents a fundamental maturation of the framework. After months of community feedback on authentication and state management, this release delivers the infrastructure FastMCP needs to handle production workloads: persistent storage, response caching, and pragmatic OAuth improvements that reflect real-world deployment challenges. + +💾 **Pluggable storage backends** bring persistent state to FastMCP servers. Built on [py-key-value-aio](https://github.com/strawgate/py-key-value), a new library from FastMCP maintainer Bill Easton ([@strawgate](https://github.com/strawgate)), the storage layer provides encrypted disk storage by default, platform-aware token management, and a simple key-value interface for application state. We're excited to bring this elegantly designed library into the FastMCP ecosystem - it's both powerful and remarkably easy to use, including wrappers to add encryption, TTLs, caching, and more to backends ranging from Elasticsearch, Redis, DynamoDB, filesystem, in-memory, and more! OAuth providers now automatically persist tokens across restarts, and developers can store arbitrary state without reaching for external databases. This foundation enables long-running sessions, cached credentials, and stateful applications built on MCP. + +🔐 **OAuth maturity** brings months of production learnings into the framework. The new consent screen prevents confused deputy and authorization bypass attacks discovered in earlier versions while providing a clean UX with customizable branding. The OAuth proxy now issues its own tokens with automatic key derivation from client secrets, and RFC 7662 token introspection support enables enterprise auth flows. Path prefix mounting enables OAuth-protected servers to integrate into existing web applications under custom paths like `/api`, and MCP 1.17+ compliance with RFC 9728 ensures protocol compatibility. Combined with improved error handling and platform-aware token storage, OAuth is now production-ready and security-hardened for serious applications. + +FastMCP now supports out-of-the-box authentication with: +- **[WorkOS](https://gofastmcp.com/integrations/workos)** and **[AuthKit](https://gofastmcp.com/integrations/authkit)** +- **[GitHub](https://gofastmcp.com/integrations/github)** +- **[Google](https://gofastmcp.com/integrations/google)** +- **[Azure](https://gofastmcp.com/integrations/azure)** (Entra ID) +- **[AWS Cognito](https://gofastmcp.com/integrations/aws-cognito)** +- **[Auth0](https://gofastmcp.com/integrations/auth0)** +- **[Descope](https://gofastmcp.com/integrations/descope)** +- **[Scalekit](https://gofastmcp.com/integrations/scalekit)** +- **[JWTs](https://gofastmcp.com/servers/auth/token-verification#jwt-token-verification)** +- **[RFC 7662 token introspection](https://gofastmcp.com/servers/auth/token-verification#token-introspection-protocol)** + +⚡ **Response Caching Middleware** dramatically improves performance for expensive operations. Cache tool and resource responses with configurable TTLs, reducing redundant API calls and speeding up repeated queries. + +🔄 **Server lifespans** provide proper initialization and cleanup hooks that run once per server instance instead of per client session. This fixes a long-standing source of confusion in the MCP SDK and enables proper resource management for database connections, background tasks, and other server-level state. Note: this is a breaking behavioral change if you were using the `lifespan` parameter. + +✨ **Developer experience improvements** include Pydantic input validation for better type safety, icon support for richer UX, RFC 6570 query parameters for resource templates, improved Context API methods (list_resources, list_prompts, get_prompt), and async file/directory resources. + +This release includes contributions from **20** new contributors and represents the largest feature set in a while. Thank you to everyone who tested preview builds and filed issues - your feedback shaped these improvements! + +**Full Changelog**: [v2.12.5...v2.13.0](https://github.com/jlowin/fastmcp/compare/v2.12.5...v2.13.0) + + + **[v2.12.5: Safety Pin](https://github.com/jlowin/fastmcp/releases/tag/v2.12.5)** diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 56e02e0b4..9403d3063 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -53,7 +53,7 @@ async with Client("https://fastmcp.cloud/mcp", auth=oauth) as client: - **`mcp_url`** (`str`): The full URL of the target MCP server endpoint. Used to discover OAuth server metadata - **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings - **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"` -- **`token_storage_cache_dir`** (`Path`, optional): Token cache directory. Defaults to `~/.fastmcp/oauth-mcp-client-cache/` +- **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options - **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration - **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port @@ -64,7 +64,7 @@ The OAuth flow is triggered when you use a FastMCP `Client` configured to use OA -The client first checks the `token_storage_cache_dir` for existing, valid tokens for the target server. If one is found, it will be used to authenticate the client. +The client first checks the configured `token_storage` backend for existing, valid tokens for the target server. If one is found, it will be used to authenticate the client. If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`. @@ -82,37 +82,51 @@ The user's default web browser is automatically opened, directing them to the OA Upon approval, the OAuth server redirects the user's browser to the local callback server with an `authorization_code`. The client captures this code and exchanges it with the OAuth server's token endpoint for an `access_token` (and often a `refresh_token`) using PKCE for security. -The obtained tokens are saved to the `token_storage_cache_dir` for future use, eliminating the need for repeated browser interactions. - +The obtained tokens are saved to the configured `token_storage` backend for future use, eliminating the need for repeated browser interactions. + -The access token is automatically included in the `Authorization` header for requests to the MCP server. +The access token is automatically included in the `Authorization` header for requests to the MCP server. If the access token expires, the client will automatically use the refresh token to get a new access token. -## Token Management +## Token Storage -### Token Storage + -OAuth access tokens are automatically cached in `~/.fastmcp/oauth-mcp-client-cache/` and persist between application runs. Files are keyed by the OAuth server's base URL. +By default, tokens are stored in memory and lost when your application restarts. For persistent storage, pass an `AsyncKeyValue`-compatible storage backend to the `token_storage` parameter. -### Managing Cache - -To clear the tokens for a specific server, instantiate a `FileTokenStorage` instance and call the `clear` method: + +**Security Consideration**: Use encrypted storage for production. MCP clients can accumulate OAuth credentials for many servers over time, and a compromised token store could expose access to multiple services. + ```python -from fastmcp.client.auth.oauth import FileTokenStorage +from fastmcp import Client +from fastmcp.client.auth import OAuth +from key_value.aio.stores.disk import DiskStore +from key_value.aio.wrappers.encryption import FernetEncryptionWrapper +from cryptography.fernet import Fernet +import os -storage = FileTokenStorage(server_url="https://fastmcp.cloud/mcp") -await storage.clear() +# Create encrypted disk storage +encrypted_storage = FernetEncryptionWrapper( + key_value=DiskStore(directory="~/.fastmcp/oauth-tokens"), + fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"]) +) + +oauth = OAuth( + mcp_url="https://fastmcp.cloud/mcp", + token_storage=encrypted_storage +) + +async with Client("https://fastmcp.cloud/mcp", auth=oauth) as client: + await client.ping() ``` -To clear *all* tokens for all servers, call the `clear_all` method on the `FileTokenStorage` class: +You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption. -```python -from fastmcp.client.auth.oauth import FileTokenStorage - -FileTokenStorage.clear_all() -``` + +When selecting a storage backend, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have constraints that affect production suitability. + diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index ba6cd1671..a5d532555 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -45,7 +45,7 @@ Run your server with a simple Python command: python server.py ``` -Your server is now accessible at `http://localhost:8000/mcp/` (or use your server's actual IP address for remote access). +Your server is now accessible at `http://localhost:8000/mcp` (or use your server's actual IP address for remote access). This approach is ideal when you want to get online quickly with minimal configuration. It's perfect for internal tools, development environments, or simple deployments where you don't need advanced server features. The built-in server handles all the HTTP details, letting you focus on your MCP implementation. @@ -72,7 +72,7 @@ Run with any ASGI server - here's an example with Uvicorn: uvicorn app:app --host 0.0.0.0 --port 8000 ``` -Your server is accessible at the same URL: `http://localhost:8000/mcp/` (or use your server's actual IP address for remote access). +Your server is accessible at the same URL: `http://localhost:8000/mcp` (or use your server's actual IP address for remote access). The ASGI approach shines in production environments where you need reliability and performance. You can run multiple worker processes to handle concurrent requests, add custom middleware for logging or monitoring, integrate with existing deployment pipelines, or mount your MCP server as part of a larger application. @@ -293,7 +293,7 @@ api.mount("/mcp", mcp.http_app()) # Run with: uvicorn app:api --host 0.0.0.0 --port 8000 ``` -Your existing API remains at `http://localhost:8000/api/` while MCP is available at `http://localhost:8000/mcp/`. +Your existing API remains at `http://localhost:8000/api` while MCP is available at `http://localhost:8000/mcp`. ## Mounting Authenticated Servers diff --git a/docs/deployment/running-server.mdx b/docs/deployment/running-server.mdx index 5aa0d7819..85595f92f 100644 --- a/docs/deployment/running-server.mdx +++ b/docs/deployment/running-server.mdx @@ -83,7 +83,7 @@ if __name__ == "__main__": mcp.run(transport="http", host="127.0.0.1", port=8000) ``` -Your server is now accessible at `http://localhost:8000/mcp/`. This URL is the MCP endpoint that clients will connect to. HTTP transport enables: +Your server is now accessible at `http://localhost:8000/mcp`. This URL is the MCP endpoint that clients will connect to. HTTP transport enables: - Network accessibility - Multiple concurrent clients - Integration with web infrastructure diff --git a/docs/docs.json b/docs/docs.json index fc23f2567..0308ae2db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,7 +20,10 @@ "primary": "#2d00f7" }, "contextual": { - "options": ["copy", "view"] + "options": [ + "copy", + "view" + ] }, "description": "The fast, Pythonic way to build MCP servers and clients.", "errors": { @@ -146,7 +149,10 @@ { "group": "Essentials", "icon": "cube", - "pages": ["clients/client", "clients/transports"] + "pages": [ + "clients/client", + "clients/transports" + ] }, { "group": "Core Operations", @@ -172,7 +178,10 @@ { "group": "Authentication", "icon": "user-shield", - "pages": ["clients/auth/oauth", "clients/auth/bearer"] + "pages": [ + "clients/auth/oauth", + "clients/auth/bearer" + ] } ] }, @@ -226,7 +235,10 @@ { "group": "API Integration", "icon": "globe", - "pages": ["integrations/fastapi", "integrations/openapi"] + "pages": [ + "integrations/fastapi", + "integrations/openapi" + ] } ] }, @@ -333,6 +345,7 @@ "python-sdk/fastmcp-server-auth-__init__", "python-sdk/fastmcp-server-auth-auth", "python-sdk/fastmcp-server-auth-jwt_issuer", + "python-sdk/fastmcp-server-auth-middleware", "python-sdk/fastmcp-server-auth-oauth_proxy", "python-sdk/fastmcp-server-auth-oidc_proxy", { @@ -371,7 +384,8 @@ "python-sdk/fastmcp-server-middleware-logging", "python-sdk/fastmcp-server-middleware-middleware", "python-sdk/fastmcp-server-middleware-rate_limiting", - "python-sdk/fastmcp-server-middleware-timing" + "python-sdk/fastmcp-server-middleware-timing", + "python-sdk/fastmcp-server-middleware-tool_injection" ] }, "python-sdk/fastmcp-server-openapi", @@ -458,17 +472,17 @@ "search": { "prompt": "Search the docs..." }, + "styling": { + "codeblocks": { + "theme": { + "dark": "dark-plus", + "light": "snazzy-light" + } + } + }, "theme": "almond", "thumbnails": { "appearance": "light", "background": "/assets/brand/thumbnail-background.png" - }, - "styling": { - "codeblocks": { - "theme": { - "light": "snazzy-light", - "dark": "dark-plus" - } - } } } diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx index 1b8847236..f424497ee 100644 --- a/docs/getting-started/welcome.mdx +++ b/docs/getting-started/welcome.mdx @@ -71,8 +71,9 @@ FastMCP handles all the complex protocol details so you can focus on building. I FastMCP provides the shortest path from idea to production. Deploy locally, to the cloud with [FastMCP Cloud](https://fastmcp.cloud) (free for personal servers), or to your own infrastructure. - - + +**This documentation reflects FastMCP's `main` branch**, meaning it always reflects the latest development version. Features are generally marked with version badges (e.g. `New in version: 2.13.1`) to indicate when they were introduced. Note that this may include features that are not yet released. + ## LLM-Friendly Docs diff --git a/docs/integrations/auth0.mdx b/docs/integrations/auth0.mdx index 958970eb2..160af738f 100644 --- a/docs/integrations/auth0.mdx +++ b/docs/integrations/auth0.mdx @@ -132,7 +132,7 @@ import asyncio async def main(): # The client will automatically handle Auth0 OAuth flows - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open Auth0 login in your browser print("✓ Authenticated with Auth0!") diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx index 51552781d..2072638fa 100644 --- a/docs/integrations/authkit.mdx +++ b/docs/integrations/authkit.mdx @@ -70,7 +70,7 @@ from fastmcp import Client import asyncio async def main(): - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth="oauth") as client: assert await client.ping() if __name__ == "__main__": diff --git a/docs/integrations/aws-cognito.mdx b/docs/integrations/aws-cognito.mdx index fe74b770d..965791c38 100644 --- a/docs/integrations/aws-cognito.mdx +++ b/docs/integrations/aws-cognito.mdx @@ -170,7 +170,7 @@ import asyncio async def main(): # The client will automatically handle AWS Cognito OAuth - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open AWS Cognito login in your browser print("✓ Authenticated with AWS Cognito!") diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 307bba209..90dcfd1bc 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -1,6 +1,6 @@ --- -title: Azure (Microsoft Entra) OAuth 🤝 FastMCP -sidebarTitle: Azure +title: Azure (Microsoft Entra ID) OAuth 🤝 FastMCP +sidebarTitle: Azure (Entra ID) description: Secure your FastMCP server with Azure/Microsoft Entra OAuth icon: microsoft tag: NEW @@ -122,12 +122,13 @@ auth_provider = AzureProvider( client_secret="your-client-secret", # Your Azure App Client Secret tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) base_url="http://localhost:8000", # Must match your App registration - required_scopes=["your-scope"], # Name of scope created when configuring your App + required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App # identifier_uri defaults to api://{client_id} # identifier_uri="api://your-api-id", # Optional: request additional upstream scopes in the authorize request # additional_authorize_scopes=["User.Read", "offline_access", "openid", "email"], # redirect_path="/auth/callback" # Default value, customize if needed + # base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com) ) mcp = FastMCP(name="Azure Secured App", auth=auth_provider) @@ -159,6 +160,10 @@ async def get_user_info() -> dict: Using your specific tenant ID is recommended for better security and control. + +**Important**: The `required_scopes` parameter is **REQUIRED** and must include at least one scope. Azure's OAuth API requires the `scope` parameter in all authorization requests - you cannot authenticate without specifying at least one scope. Use the unprefixed scope names from your Azure App registration (e.g., `["read", "write"]`). These scopes must be created under **Expose an API** in your App registration. + + ## Testing ### Running the Server @@ -181,7 +186,7 @@ import asyncio async def main(): # The client will automatically handle Azure OAuth - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open Azure login in your browser print("✓ Authenticated with Azure!") @@ -296,8 +301,12 @@ Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL wh Redirect path configured in your Azure App registration - -Comma-, space-, or JSON-separated list of required scopes for your API. These are validated on tokens and used as defaults if the client does not request specific scopes. + +Comma-, space-, or JSON-separated list of required scopes for your API (at least one scope required). These are validated on tokens and used as defaults if the client does not request specific scopes. Use unprefixed scope names from your Azure App registration (e.g., `read,write`). + + +Azure's OAuth API requires the `scope` parameter - you must provide at least one scope. + @@ -307,6 +316,15 @@ Comma-, space-, or JSON-separated list of additional scopes to include in the au Application ID URI used to prefix scopes during authorization. + + +Azure authority base URL. Override this to use Azure Government: + +- `login.microsoftonline.com` - Azure Public Cloud (default) +- `login.microsoftonline.us` - Azure Government + +This setting affects all Azure OAuth endpoints (authorization, token, issuer, JWKS). + Example `.env` file: diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx index edca62b3a..fdb8b0230 100644 --- a/docs/integrations/descope.mdx +++ b/docs/integrations/descope.mdx @@ -93,7 +93,7 @@ from fastmcp import Client import asyncio async def main(): - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth="oauth") as client: assert await client.ping() if __name__ == "__main__": diff --git a/docs/integrations/fastapi.mdx b/docs/integrations/fastapi.mdx index c62db7d67..c88a61599 100644 --- a/docs/integrations/fastapi.mdx +++ b/docs/integrations/fastapi.mdx @@ -366,7 +366,7 @@ combined_app = FastAPI( # Now you have: # - Regular API: http://localhost:8000/products -# - LLM-friendly MCP: http://localhost:8000/mcp/ +# - LLM-friendly MCP: http://localhost:8000/mcp # Both served from the same FastAPI application! ``` diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index dc1c4b8fb..f1b646b67 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -114,7 +114,7 @@ import asyncio async def main(): # The client will automatically handle GitHub OAuth - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open GitHub login in your browser print("✓ Authenticated with GitHub!") diff --git a/docs/integrations/google.mdx b/docs/integrations/google.mdx index 940cdfca1..b673962df 100644 --- a/docs/integrations/google.mdx +++ b/docs/integrations/google.mdx @@ -125,7 +125,7 @@ import asyncio async def main(): # The client will automatically handle Google OAuth - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open Google login in your browser print("✓ Authenticated with Google!") diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 3b6f0bf3a..b5c662725 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-resources-types.mdx b/docs/python-sdk/fastmcp-resources-types.mdx index cc71f5c6b..cebe83516 100644 --- a/docs/python-sdk/fastmcp-resources-types.mdx +++ b/docs/python-sdk/fastmcp-resources-types.mdx @@ -54,7 +54,7 @@ Set is_binary=True to read file as binary data instead of text. **Methods:** -#### `validate_absolute_path` +#### `validate_absolute_path` ```python validate_absolute_path(cls, path: Path) -> Path @@ -63,7 +63,7 @@ validate_absolute_path(cls, path: Path) -> Path Ensure path is absolute. -#### `set_binary_from_mime_type` +#### `set_binary_from_mime_type` ```python set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool @@ -72,7 +72,7 @@ set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool Set is_binary based on mime_type if not explicitly set. -#### `read` +#### `read` ```python read(self) -> str | bytes @@ -81,7 +81,7 @@ read(self) -> str | bytes Read the file content. -### `HttpResource` +### `HttpResource` A resource that reads from an HTTP endpoint. @@ -89,7 +89,7 @@ A resource that reads from an HTTP endpoint. **Methods:** -#### `read` +#### `read` ```python read(self) -> str | bytes @@ -98,7 +98,7 @@ read(self) -> str | bytes Read the HTTP content. -### `DirectoryResource` +### `DirectoryResource` A resource that lists files in a directory. @@ -106,7 +106,7 @@ A resource that lists files in a directory. **Methods:** -#### `validate_absolute_path` +#### `validate_absolute_path` ```python validate_absolute_path(cls, path: Path) -> Path @@ -115,7 +115,7 @@ validate_absolute_path(cls, path: Path) -> Path Ensure path is absolute. -#### `list_files` +#### `list_files` ```python list_files(self) -> list[Path] @@ -124,7 +124,7 @@ list_files(self) -> list[Path] List files in the directory. -#### `read` +#### `read` ```python read(self) -> str diff --git a/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx b/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx index 1ebede937..cbaa6eb04 100644 --- a/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx +++ b/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx @@ -15,69 +15,19 @@ This maintains proper OAuth 2.0 token audience boundaries. ## Functions -### `derive_jwt_key` +### `derive_jwt_key` ```python -derive_jwt_key(from_secret: str, server_salt: str) -> bytes +derive_jwt_key() -> bytes ``` -Derive JWT signing key from upstream client secret and server salt. - -Uses HKDF (RFC 5869) to derive a cryptographically secure signing key from -the upstream OAuth client secret combined with a server-specific salt. - -**Args:** -- `from_secret`: The OAuth client secret from upstream provider -- `server_salt`: Random salt unique to this server instance - -**Returns:** -- 32-byte key suitable for HS256 JWT signing - - -### `derive_encryption_key` - -```python -derive_encryption_key(from_secret: str) -> bytes -``` - - -Derive Fernet encryption key from upstream client secret. - -Uses HKDF to derive a cryptographically secure encryption key for -encrypting upstream tokens at rest. - -**Args:** -- `from_secret`: The OAuth client secret from upstream provider - -**Returns:** -- 32-byte Fernet key (base64url-encoded) - - -### `derive_key_from_secret` - -```python -derive_key_from_secret(secret: str | bytes, salt: str, info: bytes) -> bytes -``` - - -Derive 32-byte key from user-provided secret (string or bytes). - -Accepts any length input and derives a proper cryptographic key. -Uses HKDF to stretch weak inputs into strong keys. - -**Args:** -- `secret`: User-provided secret (any string or bytes) -- `salt`: Application-specific salt string -- `info`: Key purpose identifier - -**Returns:** -- 32-byte key suitable for HS256 JWT signing or Fernet encryption +Derive JWT signing key from a high-entropy or low-entropy key material and server salt. ## Classes -### `JWTIssuer` +### `JWTIssuer` Issues and validates FastMCP-signed JWT tokens using HS256. @@ -89,7 +39,7 @@ a key derived from the upstream client secret. **Methods:** -#### `issue_access_token` +#### `issue_access_token` ```python issue_access_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int = 3600) -> str @@ -111,7 +61,7 @@ which contains actual user identity and authorization data. - Signed JWT token -#### `issue_refresh_token` +#### `issue_refresh_token` ```python issue_refresh_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int) -> str @@ -133,7 +83,7 @@ token which contains actual user identity and authorization data. - Signed JWT token -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> dict[str, Any] @@ -152,44 +102,3 @@ Validates JWT signature, expiration, issuer, and audience. **Raises:** - `JoseError`: If token is invalid, expired, or has wrong claims - -### `TokenEncryption` - - -Handles encryption/decryption of upstream OAuth tokens at rest. - - -**Methods:** - -#### `encrypt` - -```python -encrypt(self, token: str) -> bytes -``` - -Encrypt a token for storage. - -**Args:** -- `token`: Plain text token - -**Returns:** -- Encrypted token bytes - - -#### `decrypt` - -```python -decrypt(self, encrypted_token: bytes) -> str -``` - -Decrypt a token from storage. - -**Args:** -- `encrypted_token`: Encrypted token bytes - -**Returns:** -- Plain text token - -**Raises:** -- `cryptography.fernet.InvalidToken`: If token is corrupted or key is wrong - diff --git a/docs/python-sdk/fastmcp-server-auth-middleware.mdx b/docs/python-sdk/fastmcp-server-auth-middleware.mdx new file mode 100644 index 000000000..42c7e9237 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-middleware.mdx @@ -0,0 +1,26 @@ +--- +title: middleware +sidebarTitle: middleware +--- + +# `fastmcp.server.auth.middleware` + + +Enhanced authentication middleware with better error messages. + +This module provides enhanced versions of MCP SDK authentication middleware +that return more helpful error messages for developers troubleshooting +authentication issues. + + +## Classes + +### `RequireAuthMiddleware` + + +Enhanced authentication middleware with detailed error messages. + +Extends the SDK's RequireAuthMiddleware to provide more actionable +error messages when authentication fails. This helps developers +understand what went wrong and how to fix it. + diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index fa0f136c0..46925bc00 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -26,7 +26,7 @@ production use with enterprise identity providers. ## Functions -### `create_consent_html` +### `create_consent_html` ```python create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None) -> str @@ -38,7 +38,7 @@ Create a styled HTML consent page for OAuth authorization requests. ## Classes -### `OAuthTransaction` +### `OAuthTransaction` OAuth transaction state for consent flow. @@ -47,7 +47,7 @@ Stored server-side to track active authorization flows with client context. Includes CSRF tokens for consent protection per MCP security best practices. -### `ClientCode` +### `ClientCode` Client authorization code with PKCE and upstream tokens. @@ -56,16 +56,17 @@ Stored server-side after upstream IdP callback. Contains the upstream tokens bound to the client's PKCE challenge for secure token exchange. -### `UpstreamTokenSet` +### `UpstreamTokenSet` Stored upstream OAuth tokens from identity provider. These tokens are obtained from the upstream provider (Google, GitHub, etc.) -and are stored encrypted at rest. They are never exposed to MCP clients. +and stored in plaintext within this model. Encryption is handled transparently +at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. -### `JTIMapping` +### `JTIMapping` Maps FastMCP token JTI to upstream token ID. @@ -74,7 +75,7 @@ This allows stateless JWT validation while still being able to look up the corresponding upstream token when tools need to access upstream APIs. -### `ProxyDCRClient` +### `ProxyDCRClient` Client for DCR proxy with configurable redirect URI validation. @@ -104,7 +105,7 @@ arise from accepting arbitrary redirect URIs. **Methods:** -#### `validate_redirect_uri` +#### `validate_redirect_uri` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl @@ -118,7 +119,7 @@ This is essential for cached token scenarios where the client may reconnect with a different port. -### `TokenHandler` +### `TokenHandler` TokenHandler that returns OAuth 2.1 compliant error responses. @@ -141,7 +142,7 @@ Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." **Methods:** -#### `response` +#### `response` ```python response(self, obj: TokenSuccessResponse | TokenErrorResponse) @@ -150,7 +151,7 @@ response(self, obj: TokenSuccessResponse | TokenErrorResponse) Override response method to provide OAuth 2.1 compliant error handling. -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -260,7 +261,7 @@ Handles provider-specific requirements: **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -272,7 +273,7 @@ provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -286,7 +287,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -303,7 +304,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -315,7 +316,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -333,7 +334,7 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -342,7 +343,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) Load refresh token from local storage. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -359,7 +360,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -378,7 +379,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -390,16 +391,17 @@ Removes tokens from local storage and attempts to revoke them with the upstream server if a revocation endpoint is configured. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` -Get OAuth routes with custom proxy token handler. +Get OAuth routes with custom handlers for better error UX. -This method creates standard OAuth routes and replaces the token endpoint -with our proxy handler that forwards requests to the upstream OAuth server. +This method creates standard OAuth routes and replaces: +- /authorize endpoint: Enhanced error responses for unregistered clients +- /token endpoint: OAuth 2.1 compliant error codes **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index 5d9f72eaa..867f110fc 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx index 097662791..c1c4b0200 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx @@ -37,7 +37,7 @@ Example: Settings for Auth0 OIDC provider. -### `Auth0Provider` +### `Auth0Provider` An Auth0 provider implementation for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx index b56454c74..69d85f724 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx @@ -37,7 +37,7 @@ Example: Settings for AWS Cognito OAuth provider. -### `AWSCognitoTokenVerifier` +### `AWSCognitoTokenVerifier` Token verifier that filters claims to Cognito-specific subset. @@ -45,7 +45,7 @@ Token verifier that filters claims to Cognito-specific subset. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -54,7 +54,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token and filter claims to Cognito-specific subset. -### `AWSCognitoProvider` +### `AWSCognitoProvider` Complete AWS Cognito OAuth provider for FastMCP. @@ -72,7 +72,7 @@ Features: **Methods:** -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 243d3628f..44f2d37c4 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -20,7 +20,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. Settings for Azure OAuth provider. -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. @@ -29,23 +29,33 @@ This provider implements Azure/Microsoft Entra ID authentication using the OAuth Proxy pattern. It supports both organizational accounts and personal Microsoft accounts depending on the tenant configuration. +Scope Handling: +- required_scopes: Provide unprefixed scope names (e.g., ["read", "write"]) + → Automatically prefixed with identifier_uri during initialization + → Validated on all tokens and advertised to MCP clients +- additional_authorize_scopes: Provide full format (e.g., ["User.Read"]) + → NOT prefixed, NOT validated, NOT advertised to clients + → Used to request Microsoft Graph or other upstream API permissions + Features: - OAuth proxy to Azure/Microsoft identity platform - JWT validation using tenant issuer and JWKS - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" +- Custom API scopes and Microsoft Graph scopes in a single provider Setup: 1. Create an App registration in Azure Portal 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) -3. Add an Application ID URI. Either use the default (api://{client_id}) or set a custom one. -4. Add a custom scope. -5. Create a client secret. -6. Get Application (client) ID, Directory (tenant) ID, and client secret +3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id}) +4. Add custom scopes (e.g., "read", "write") under "Expose an API" +5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2 +6. Create a client secret +7. Get Application (client) ID, Directory (tenant) ID, and client secret **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx index 5358f2817..c4c731a88 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx @@ -35,7 +35,7 @@ Example: Settings for GitHub OAuth provider. -### `GitHubTokenVerifier` +### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. @@ -46,7 +46,7 @@ by calling GitHub's API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify GitHub OAuth token by calling GitHub API. -### `GitHubProvider` +### `GitHubProvider` Complete GitHub OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx index 006c22db3..5e2883a0c 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx @@ -35,7 +35,7 @@ Example: Settings for Google OAuth provider. -### `GoogleTokenVerifier` +### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. @@ -46,7 +46,7 @@ by calling Google's tokeninfo API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Google OAuth token by calling Google's tokeninfo API. -### `GoogleProvider` +### `GoogleProvider` Complete Google OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index 15055f2be..a2ec529ad 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -24,7 +24,7 @@ Choose based on your WorkOS setup and authentication requirements. Settings for WorkOS OAuth provider. -### `WorkOSTokenVerifier` +### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. @@ -35,7 +35,7 @@ the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -44,7 +44,7 @@ verify_token(self, token: str) -> AccessToken | None Verify WorkOS OAuth token by calling userinfo endpoint. -### `WorkOSProvider` +### `WorkOSProvider` Complete WorkOS OAuth provider for FastMCP. @@ -65,9 +65,9 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProviderSettings` +### `AuthKitProviderSettings` -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -93,7 +93,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index f5826ace0..3b0f0539d 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -7,7 +7,7 @@ sidebarTitle: context ## Functions -### `set_context` +### `set_context` ```python set_context(context: Context) -> Generator[Context, None, None] @@ -15,7 +15,7 @@ set_context(context: Context) -> Generator[Context, None, None] ## Classes -### `LogData` +### `LogData` Data object for passing log arguments to client-side handlers. @@ -24,7 +24,7 @@ This provides an interface to match the Python standard library logging, for compatibility with structured logging. -### `Context` +### `Context` Context object providing access to MCP capabilities. @@ -72,7 +72,7 @@ The context is optional - tools that don't need it can omit the parameter. **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -81,7 +81,7 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `request_context` +#### `request_context` ```python request_context(self) -> RequestContext[ServerSession, Any, Request] @@ -92,7 +92,7 @@ Access to the underlying request context. If called outside of a request context, this will raise a ValueError. -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -105,7 +105,47 @@ Report progress for the current operation. - `total`: Optional total value e.g. 100 -#### `read_resource` +#### `list_resources` + +```python +list_resources(self) -> list[MCPResource] +``` + +List all available resources from the server. + +**Returns:** +- List of Resource objects available on the server + + +#### `list_prompts` + +```python +list_prompts(self) -> list[MCPPrompt] +``` + +List all available prompts from the server. + +**Returns:** +- List of Prompt objects available on the server + + +#### `get_prompt` + +```python +get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult +``` + +Get a prompt by name with optional arguments. + +**Args:** +- `name`: The name of the prompt to get +- `arguments`: Optional arguments to pass to the prompt + +**Returns:** +- The prompt result + + +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents] @@ -120,7 +160,7 @@ Read a resource by URI. - The resource content as either text or bytes -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -138,7 +178,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien - `extra`: Optional mapping for additional arguments -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -147,7 +187,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -156,7 +196,7 @@ request_id(self) -> str Get the unique ID for this request. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -173,7 +213,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -182,7 +222,7 @@ session(self) -> ServerSession Access to the underlying session for advanced usage. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -193,7 +233,7 @@ Send a `DEBUG`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -204,7 +244,7 @@ Send a `INFO`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -215,7 +255,7 @@ Send a `WARNING`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -226,7 +266,7 @@ Send a `ERROR`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -235,7 +275,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_tool_list_changed` +#### `send_tool_list_changed` ```python send_tool_list_changed(self) -> None @@ -244,7 +284,7 @@ send_tool_list_changed(self) -> None Send a tool list changed notification to the client. -#### `send_resource_list_changed` +#### `send_resource_list_changed` ```python send_resource_list_changed(self) -> None @@ -253,7 +293,7 @@ send_resource_list_changed(self) -> None Send a resource list changed notification to the client. -#### `send_prompt_list_changed` +#### `send_prompt_list_changed` ```python send_prompt_list_changed(self) -> None @@ -262,7 +302,7 @@ send_prompt_list_changed(self) -> None Send a prompt list changed notification to the client. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent | AudioContent @@ -275,25 +315,25 @@ completion from the client. The client must be appropriately configured, or the request will error. -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation @@ -322,7 +362,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `get_http_request` +#### `get_http_request` ```python get_http_request(self) -> Request @@ -331,7 +371,7 @@ get_http_request(self) -> Request Get the active starlette request. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -340,7 +380,7 @@ set_state(self, key: str, value: Any) -> None Set a value in the context state. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any diff --git a/docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx b/docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx new file mode 100644 index 000000000..946a5f33a --- /dev/null +++ b/docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx @@ -0,0 +1,91 @@ +--- +title: tool_injection +sidebarTitle: tool_injection +--- + +# `fastmcp.server.middleware.tool_injection` + + +A middleware for injecting tools into the MCP server context. + +## Functions + +### `list_prompts` + +```python +list_prompts(context: Context) -> list[Prompt] +``` + + +List prompts available on the server. + + +### `get_prompt` + +```python +get_prompt(context: Context, name: Annotated[str, 'The name of the prompt to render.'], arguments: Annotated[dict[str, Any] | None, 'The arguments to pass to the prompt.'] = None) -> mcp.types.GetPromptResult +``` + + +Render a prompt available on the server. + + +### `list_resources` + +```python +list_resources(context: Context) -> list[mcp.types.Resource] +``` + + +List resources available on the server. + + +### `read_resource` + +```python +read_resource(context: Context, uri: Annotated[AnyUrl | str, 'The URI of the resource to read.']) -> list[ReadResourceContents] +``` + + +Read a resource available on the server. + + +## Classes + +### `ToolInjectionMiddleware` + + +A middleware for injecting tools into the context. + + +**Methods:** + +#### `on_list_tools` + +```python +on_list_tools(self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] +``` + +Inject tools into the response. + + +#### `on_call_tool` + +```python +on_call_tool(self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult]) -> ToolResult +``` + +Intercept tool calls to injected tools. + + +### `PromptToolMiddleware` + + +A middleware for injecting prompts as tools into the context. + + +### `ResourceToolMiddleware` + + +A middleware for injecting resources as tools into the context. + diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index c66b0155b..41284afc7 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `add_resource_prefix` +### `add_resource_prefix` ```python add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -64,7 +64,7 @@ add_resource_prefix("resource:///absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `remove_resource_prefix` +### `remove_resource_prefix` ```python remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -103,7 +103,7 @@ remove_resource_prefix("resource://prefix//absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `has_resource_prefix` +### `has_resource_prefix` ```python has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> bool @@ -143,53 +143,53 @@ False ## Classes -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `run_async` +#### `run_async` ```python run_async(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -201,7 +201,7 @@ Run the FastMCP server asynchronously. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `run` +#### `run` ```python run(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -213,13 +213,13 @@ Run the FastMCP server. Note this is a synchronous function. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `get_tools` +#### `get_tools` ```python get_tools(self) -> dict[str, Tool] @@ -228,13 +228,13 @@ get_tools(self) -> dict[str, Tool] Get all tools (unfiltered), including mounted servers, indexed by key. -#### `get_tool` +#### `get_tool` ```python get_tool(self, key: str) -> Tool ``` -#### `get_resources` +#### `get_resources` ```python get_resources(self) -> dict[str, Resource] @@ -243,13 +243,13 @@ get_resources(self) -> dict[str, Resource] Get all resources (unfiltered), including mounted servers, indexed by key. -#### `get_resource` +#### `get_resource` ```python get_resource(self, key: str) -> Resource ``` -#### `get_resource_templates` +#### `get_resource_templates` ```python get_resource_templates(self) -> dict[str, ResourceTemplate] @@ -258,7 +258,7 @@ get_resource_templates(self) -> dict[str, ResourceTemplate] Get all resource templates (unfiltered), including mounted servers, indexed by key. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, key: str) -> ResourceTemplate @@ -267,7 +267,7 @@ get_resource_template(self, key: str) -> ResourceTemplate Get a registered resource template by key. -#### `get_prompts` +#### `get_prompts` ```python get_prompts(self) -> dict[str, Prompt] @@ -276,13 +276,13 @@ get_prompts(self) -> dict[str, Prompt] Get all prompts (unfiltered), including mounted servers, indexed by key. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, key: str) -> Prompt ``` -#### `custom_route` +#### `custom_route` ```python custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True) -> Callable[[Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]]] @@ -303,7 +303,7 @@ Starlette's reverse URL lookup feature) - `include_in_schema`: Whether to include in OpenAPI schema, defaults to True -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool) -> Tool @@ -321,7 +321,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str) -> None @@ -336,7 +336,7 @@ Remove a tool from the server. - `NotFoundError`: If the tool is not found -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -345,7 +345,7 @@ add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfi Add a tool transformation. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, tool_name: str) -> None @@ -354,19 +354,19 @@ remove_tool_transformation(self, tool_name: str) -> None Remove a tool transformation. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool @@ -422,7 +422,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource) -> Resource @@ -437,7 +437,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -452,7 +452,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `add_resource_fn` +#### `add_resource_fn` ```python add_resource_fn(self, fn: AnyFunction, uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> None @@ -472,7 +472,7 @@ has parameters, it will be registered as a template resource. - `tags`: Optional set of tags for categorizing the resource -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate] @@ -532,7 +532,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt) -> Prompt @@ -547,19 +547,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt @@ -637,7 +637,7 @@ Decorator to register a prompt. ``` -#### `run_stdio_async` +#### `run_stdio_async` ```python run_stdio_async(self, show_banner: bool = True, log_level: str | None = None) -> None @@ -650,7 +650,7 @@ Run the server using stdio transport. - `log_level`: Log level for the server -#### `run_http_async` +#### `run_http_async` ```python run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None) -> None @@ -670,7 +670,7 @@ Run the server using HTTP transport. - `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http) -#### `run_sse_async` +#### `run_sse_async` ```python run_sse_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None @@ -679,7 +679,7 @@ run_sse_async(self, host: str | None = None, port: int | None = None, log_level: Run the server using SSE transport. -#### `sse_app` +#### `sse_app` ```python sse_app(self, path: str | None = None, message_path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -693,7 +693,7 @@ Create a Starlette app for the SSE server. - `middleware`: A list of middleware to apply to the app -#### `streamable_http_app` +#### `streamable_http_app` ```python streamable_http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -706,7 +706,7 @@ Create a Starlette app for the StreamableHTTP server. - `middleware`: A list of middleware to apply to the app -#### `http_app` +#### `http_app` ```python http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http') -> StarletteWithLifespan @@ -723,13 +723,13 @@ Create a Starlette app using the specified HTTP transport. - A Starlette application configured with the specified transport -#### `run_streamable_http_async` +#### `run_streamable_http_async` ```python run_streamable_http_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None @@ -783,7 +783,7 @@ automatically determined based on whether the server has a custom lifespan - `prompt_separator`: Deprecated. Separator character for prompt names. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None) -> None @@ -824,7 +824,7 @@ applied using the protocol\://prefix/path format - `prompt_separator`: Deprecated. Separator for prompt names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -833,7 +833,7 @@ from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route Create a FastMCP server from an OpenAPI specification. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -842,7 +842,7 @@ from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] Create a FastMCP server from a FastAPI application. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -856,7 +856,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `from_client` +#### `from_client` ```python from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPProxy @@ -865,10 +865,10 @@ from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPPr Create a FastMCP proxy server from a FastMCP client. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` -### `MountedServer` +### `MountedServer` diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index 4a0bb8448..5f9451a0d 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -7,7 +7,7 @@ sidebarTitle: settings ## Classes -### `ExtendedEnvSettingsSource` +### `ExtendedEnvSettingsSource` A special EnvSettingsSource that allows for multiple env var prefixes to be used. @@ -17,17 +17,17 @@ Raises a deprecation warning if the old `FASTMCP_SERVER_` prefix is used. **Methods:** -#### `get_field_value` +#### `get_field_value` ```python get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool] ``` -### `ExtendedSettingsConfigDict` +### `ExtendedSettingsConfigDict` -### `ExperimentalSettings` +### `ExperimentalSettings` -### `Settings` +### `Settings` FastMCP settings. @@ -35,7 +35,7 @@ FastMCP settings. **Methods:** -#### `get_setting` +#### `get_setting` ```python get_setting(self, attr: str) -> Any @@ -45,7 +45,7 @@ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `set_setting` +#### `set_setting` ```python set_setting(self, attr: str, value: Any) -> None @@ -55,13 +55,13 @@ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `settings_customise_sources` +#### `settings_customise_sources` ```python settings_customise_sources(cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource) -> tuple[PydanticBaseSettingsSource, ...] ``` -#### `settings` +#### `settings` ```python settings(self) -> Self @@ -71,13 +71,13 @@ This property is for backwards compatibility with FastMCP < 2.8.0, which accessed fastmcp.settings.settings -#### `normalize_log_level` +#### `normalize_log_level` ```python normalize_log_level(cls, v) ``` -#### `server_auth_class` +#### `server_auth_class` ```python server_auth_class(self) -> AuthProvider | None diff --git a/docs/python-sdk/fastmcp-tools-tool_manager.mdx b/docs/python-sdk/fastmcp-tools-tool_manager.mdx index bf07cb0fd..592b18d58 100644 --- a/docs/python-sdk/fastmcp-tools-tool_manager.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_manager.mdx @@ -15,7 +15,7 @@ Manages FastMCP tools. **Methods:** -#### `has_tool` +#### `has_tool` ```python has_tool(self, key: str) -> bool @@ -24,7 +24,7 @@ has_tool(self, key: str) -> bool Check if a tool exists. -#### `get_tool` +#### `get_tool` ```python get_tool(self, key: str) -> Tool @@ -33,7 +33,7 @@ get_tool(self, key: str) -> Tool Get tool by key. -#### `get_tools` +#### `get_tools` ```python get_tools(self) -> dict[str, Tool] @@ -42,7 +42,7 @@ get_tools(self) -> dict[str, Tool] Gets the complete, unfiltered inventory of local tools. -#### `add_tool_from_fn` +#### `add_tool_from_fn` ```python add_tool_from_fn(self, fn: Callable[..., Any], name: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, serializer: Callable[[Any], str] | None = None, exclude_args: list[str] | None = None) -> Tool @@ -51,7 +51,7 @@ add_tool_from_fn(self, fn: Callable[..., Any], name: str | None = None, descript Add a tool to the server. -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool) -> Tool @@ -60,7 +60,7 @@ add_tool(self, tool: Tool) -> Tool Register a tool with the server. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -69,7 +69,7 @@ add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfi Add a tool transformation. -#### `get_tool_transformation` +#### `get_tool_transformation` ```python get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None @@ -78,7 +78,7 @@ get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None Get a tool transformation. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, tool_name: str) -> None @@ -87,7 +87,7 @@ remove_tool_transformation(self, tool_name: str) -> None Remove a tool transformation. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, key: str) -> None @@ -102,7 +102,7 @@ Remove a tool from the server. - `NotFoundError`: If the tool is not found -#### `call_tool` +#### `call_tool` ```python call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult diff --git a/docs/python-sdk/fastmcp-utilities-cli.mdx b/docs/python-sdk/fastmcp-utilities-cli.mdx index 4ed3ab1da..c0bd2aeb8 100644 --- a/docs/python-sdk/fastmcp-utilities-cli.mdx +++ b/docs/python-sdk/fastmcp-utilities-cli.mdx @@ -37,7 +37,7 @@ run, inspect, and dev commands. - Tuple of (MCPServerConfig, resolved_server_spec) -### `log_server_banner` +### `log_server_banner` ```python log_server_banner(server: FastMCP[Any], transport: Literal['stdio', 'http', 'sse', 'streamable-http']) -> None diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 27083e882..df25ca1ea 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -93,14 +93,22 @@ mcp = FastMCP(name="My Server", auth=auth) HTTP request timeout in seconds for fetching OIDC configuration + + + + Custom token verifier for validating tokens. When provided, FastMCP uses your custom verifier instead of creating a default `JWTVerifier`. + + Cannot be used with `algorithm` or `required_scopes` parameters - configure these on your verifier instead. The verifier's `required_scopes` are automatically loaded and advertised. + + JWT algorithm to use for token verification (e.g., "RS256"). If not specified, - uses the provider's default. + uses the provider's default. Only used when `token_verifier` is not provided. - List of OAuth scopes to request from the provider. These are automatically - included in authorization requests. + List of OAuth scopes for token validation. These are automatically + included in authorization requests. Only used when `token_verifier` is not provided. diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx index d3380d4ff..1460b8643 100644 --- a/docs/servers/auth/token-verification.mdx +++ b/docs/servers/auth/token-verification.mdx @@ -210,6 +210,67 @@ Static token verification stores tokens as plain text and should never be used i +### Debug/Custom Token Verification + +The `DebugTokenVerifier` provides maximum flexibility for testing and special cases where standard token verification isn't applicable. It delegates validation to a user-provided callable, making it useful for prototyping, testing scenarios, or handling opaque tokens without introspection endpoints. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.debug import DebugTokenVerifier + +# Accept all tokens (useful for rapid development) +verifier = DebugTokenVerifier() + +mcp = FastMCP(name="Development Server", auth=verifier) +``` + +By default, `DebugTokenVerifier` accepts any non-empty token as valid. This eliminates authentication barriers during early development, allowing you to focus on core functionality before adding security. + +For more controlled testing, provide custom validation logic: + +```python +from fastmcp.server.auth.providers.debug import DebugTokenVerifier + +# Synchronous validation - check token prefix +verifier = DebugTokenVerifier( + validate=lambda token: token.startswith("dev-"), + client_id="development-client", + scopes=["read", "write"] +) + +mcp = FastMCP(name="Development Server", auth=verifier) +``` + +The validation callable can also be async, enabling database lookups or external service calls: + +```python +from fastmcp.server.auth.providers.debug import DebugTokenVerifier + +# Asynchronous validation - check against cache +async def validate_token(token: str) -> bool: + # Check if token exists in Redis, database, etc. + return await redis.exists(f"valid_tokens:{token}") + +verifier = DebugTokenVerifier( + validate=validate_token, + client_id="api-client", + scopes=["api:access"] +) + +mcp = FastMCP(name="Custom API", auth=verifier) +``` + +**Use Cases:** + +- **Testing**: Accept any token during integration tests without setting up token infrastructure +- **Prototyping**: Quickly validate concepts without authentication complexity +- **Opaque tokens without introspection**: When you have tokens from an IDP that provides no introspection endpoint, and you're willing to accept tokens without validation (validation happens later at the upstream service) +- **Custom token formats**: Implement validation for non-standard token formats or legacy systems + + +`DebugTokenVerifier` bypasses standard security checks. Only use in controlled environments (development, testing) or when you fully understand the security implications. For production, use proper JWT or introspection-based verification. + + ### Test Token Generation Test token generation helps when you need to test JWT verification without setting up complete identity infrastructure. FastMCP includes utilities for generating test key pairs and signed tokens. diff --git a/docs/servers/icons.mdx b/docs/servers/icons.mdx index f14b4d6f5..838dd8739 100644 --- a/docs/servers/icons.mdx +++ b/docs/servers/icons.mdx @@ -115,6 +115,7 @@ For small icons or when you want to embed the icon directly, use data URIs: ```python from mcp.types import Icon +from fastmcp.utilities.types import Image # SVG icon as data URI svg_icon = Icon( @@ -126,4 +127,13 @@ svg_icon = Icon( def my_tool() -> str: """A tool with an embedded SVG icon.""" return "result" + +# Generating a data URI from a local image file. +img = Image(path="./assets/brand/favicon.png") +icon = Icon(src=img.to_data_uri()) + +@mcp.tool(icons=[icon]) +def file_icon_tool() -> str: + """A tool with an icon generated from a local file.""" + return "result" ``` diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index 7147eccc3..f60ea1821 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -143,6 +143,10 @@ The py-key-value-aio library includes additional implementations for various sto For configuration details on these backends, consult the [py-key-value-aio documentation](https://github.com/strawgate/py-key-value). + +Before using these backends in production, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have specific constraints that make them unsuitable for production use. + + ## Use Cases in FastMCP ### Server-Side OAuth Token Storage diff --git a/docs/tutorials/rest-api.mdx b/docs/tutorials/rest-api.mdx index 445932626..a0857aca9 100644 --- a/docs/tutorials/rest-api.mdx +++ b/docs/tutorials/rest-api.mdx @@ -103,7 +103,7 @@ from fastmcp import Client async def main(): # Connect to the MCP server we just created - async with Client("http://127.0.0.1:8000/mcp/") as client: + async with Client("http://127.0.0.1:8000/mcp") as client: # List the tools that were automatically generated tools = await client.list_tools() diff --git a/docs/updates.mdx b/docs/updates.mdx index 134dc895c..ac65c4ff4 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,6 +5,24 @@ icon: "sparkles" tag: NEW --- + + +FastMCP 2.13 "Cache Me If You Can" represents a fundamental maturation of the framework. After months of community feedback on authentication and state management, this release delivers the infrastructure FastMCP needs to handle production workloads: persistent storage, response caching, and pragmatic OAuth improvements that reflect real-world deployment challenges. + +💾 **Pluggable storage backends** bring persistent state to FastMCP servers. Built on [py-key-value-aio](https://github.com/strawgate/py-key-value), a new library from FastMCP maintainer Bill Easton ([@strawgate](https://github.com/strawgate)), the storage layer provides encrypted disk storage by default, platform-aware token management, and a simple key-value interface for application state. We're excited to bring this elegantly designed library into the FastMCP ecosystem - it's both powerful and remarkably easy to use, including wrappers to add encryption, TTLs, caching, and more to backends ranging from Elasticsearch, Redis, DynamoDB, filesystem, in-memory, and more! + +🔐 **OAuth maturity** brings months of production learnings into the framework. The new consent screen prevents confused deputy and authorization bypass attacks discovered in earlier versions, while the OAuth proxy now issues its own tokens with automatic key derivation. RFC 7662 token introspection support enables enterprise auth flows, and path prefix mounting enables OAuth-protected servers to integrate into existing web applications. FastMCP now supports out-of-the-box authentication with [WorkOS](https://gofastmcp.com/integrations/workos) and [AuthKit](https://gofastmcp.com/integrations/authkit), [GitHub](https://gofastmcp.com/integrations/github), [Google](https://gofastmcp.com/integrations/google), [Azure](https://gofastmcp.com/integrations/azure) (Entra ID), [AWS Cognito](https://gofastmcp.com/integrations/aws-cognito), [Auth0](https://gofastmcp.com/integrations/auth0), [Descope](https://gofastmcp.com/integrations/descope), [Scalekit](https://gofastmcp.com/integrations/scalekit), [JWTs](https://gofastmcp.com/servers/auth/token-verification#jwt-token-verification), and [RFC 7662 token introspection](https://gofastmcp.com/servers/auth/token-verification#token-introspection-protocol). + +⚡ **Response Caching Middleware** dramatically improves performance for expensive operations, while **Server lifespans** provide proper initialization and cleanup hooks that run once per server instance instead of per client session. + +✨ **Developer experience improvements** include Pydantic input validation, icon support, RFC 6570 query parameters for resource templates, improved Context API methods, and async file/directory resources. + + + str: @mcp.tool async def remember( - contents: list[str] = Field( - description="List of observations or memories to store" - ), + contents: Annotated[ + list[str], Field(description="List of observations or memories to store") + ], ): deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) try: diff --git a/pyproject.toml b/pyproject.toml index adfe8ef4e..ac89b01a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,12 +12,13 @@ dependencies = [ "platformdirs>=4.0.0", "rich>=13.9.4", "cyclopts>=3.0.0", - "authlib>=1.5.2", + "authlib>=1.6.5", "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", - "openapi-core>=0.19.5", - "py-key-value-aio[disk,keyring,memory]>=0.2.6,<0.3.0", + "py-key-value-aio[disk,keyring,memory]>=0.2.8,<0.3.0", + "uvicorn>=0.35", "websockets>=15.0.1", + "jsonschema-path>=0.3.4", ] requires-python = ">=3.10" @@ -102,6 +103,11 @@ fallback-version = "0.0.0" [tool.pytest.ini_options] asyncio_mode = "auto" # filterwarnings = ["error::DeprecationWarning"] +filterwarnings = [ + # Suppress OAuth in-memory token storage warnings in tests + # Tests intentionally use ephemeral storage; this warning is for end users + "ignore:Using in-memory token storage:UserWarning", +] timeout = 5 env = [ "FASTMCP_TEST_MODE=1", @@ -137,12 +143,34 @@ unknown-argument = "ignore" # 61 errors call-non-callable = "ignore" # 7 errors [tool.ruff.lint] -extend-select = ["I", "UP"] +fixable = ["ALL"] +ignore = [ + "COM812", + "PLR0913", # Too many arguments, MCP Servers have a lot of arguments, OKAY?! + "SIM102", # Dont require combining if statements +] +extend-select = [ + "B", # flake8-bugbear: Catches actual bugs like mutable default arguments + "C4", # flake8-comprehensions: More efficient/readable comprehensions + "I", # flake8-builtins: Catches builtins that are not explicitly imported + "PIE", # flake8-pie: More idiomatic Python code + "RUF", # Ruff-specific: Modern best practices unique to Ruff + "SIM", # flake8-simplify: Simplifies verbose code patterns + "UP", # flake8-unused-imports: Catches unused imports +] + [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "I001", "RUF013"] # allow imports not at the top of the file "src/fastmcp/__init__.py" = ["E402"] +"!src/**.py" = [ # Only enforce extended ruff rules for code in src/ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "PIE", # flake8-pie + "RUF", # Ruff-specific + "SIM", # flake8-simplify +] [tool.codespell] ignore-words-list = "asend,shttp,te" diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index 3a596584a..d47bf88c8 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -48,9 +48,9 @@ def __getattr__(name: str): __all__ = [ - "FastMCP", - "Context", - "client", "Client", + "Context", + "FastMCP", + "client", "settings", ] diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 71eecd44e..17d7e1c99 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -78,7 +78,7 @@ def with_argv(args: list[str] | None): original = sys.argv[:] try: # Preserve the script name (sys.argv[0]) and replace the rest - sys.argv = [sys.argv[0]] + args + sys.argv = [sys.argv[0], *args] yield finally: sys.argv = original @@ -277,7 +277,7 @@ async def dev( # Run the MCP Inspector command process = subprocess.run( - [npx_cmd, inspector_cmd] + uv_cmd, + [npx_cmd, inspector_cmd, *uv_cmd], check=True, env=env, ) @@ -502,7 +502,7 @@ async def run( process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) except subprocess.CalledProcessError as e: - logger.error( + logger.exception( f"Failed to run: {e}", extra={ "server_spec": server_spec, @@ -526,7 +526,7 @@ async def run( skip_source=skip_source, ) except Exception as e: - logger.error( + logger.exception( f"Failed to run: {e}", extra={ "server_spec": server_spec, @@ -766,13 +766,12 @@ async def inspect( console.print(formatted_json.decode("utf-8")) except Exception as e: - logger.error( + logger.exception( f"Failed to inspect server: {e}", extra={ "server_spec": server_spec, "error": str(e), }, - exc_info=True, ) console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}") sys.exit(1) diff --git a/src/fastmcp/client/__init__.py b/src/fastmcp/client/__init__.py index 25ed3cd58..af895e9d8 100644 --- a/src/fastmcp/client/__init__.py +++ b/src/fastmcp/client/__init__.py @@ -15,18 +15,18 @@ from .transports import ( from .auth import OAuth, BearerAuth __all__ = [ + "BearerAuth", "Client", "ClientTransport", - "WSTransport", + "FastMCPTransport", + "NodeStdioTransport", + "NpxStdioTransport", + "OAuth", + "PythonStdioTransport", "SSETransport", "StdioTransport", - "PythonStdioTransport", - "NodeStdioTransport", - "UvxStdioTransport", - "UvStdioTransport", - "NpxStdioTransport", - "FastMCPTransport", "StreamableHttpTransport", - "OAuth", - "BearerAuth", + "UvStdioTransport", + "UvxStdioTransport", + "WSTransport", ] diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 0795d6f76..f454febec 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -36,8 +36,6 @@ logger = get_logger(__name__) class ClientNotFoundError(Exception): """Raised when OAuth client credentials are not found on the server.""" - pass - async def check_if_auth_required( mcp_url: str, httpx_kwargs: dict[str, Any] | None = None @@ -58,7 +56,7 @@ async def check_if_auth_required( return True # Check for WWW-Authenticate header - if "WWW-Authenticate" in response.headers: + if "WWW-Authenticate" in response.headers: # noqa: SIM103 return True # If we get a successful response, auth may not be required @@ -194,8 +192,10 @@ class OAuth(OAuthClientProvider): from warnings import warn warn( - message="Using in-memory token storage is not recommended for production use -- " - + "tokens will be lost on server restart." + message="Using in-memory token storage -- tokens will be lost when the client restarts. " + + "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. " + + "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.", + stacklevel=2, ) self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( @@ -272,8 +272,10 @@ class OAuth(OAuthClientProvider): if result.error: raise result.error return result.code, result.state # type: ignore - except TimeoutError: - raise TimeoutError(f"OAuth callback timed out after {TIMEOUT} seconds") + except TimeoutError as e: + raise TimeoutError( + f"OAuth callback timed out after {TIMEOUT} seconds" + ) from e finally: server.should_exit = True await anyio.sleep(0.1) # Allow server to shut down gracefully diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index c1957c277..d24350d38 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -61,15 +61,15 @@ from .transports import ( __all__ = [ "Client", - "SessionKwargs", - "RootsHandler", - "RootsList", + "ClientSamplingHandler", + "ElicitationHandler", "LogHandler", "MessageHandler", - "ClientSamplingHandler", - "SamplingHandler", - "ElicitationHandler", "ProgressHandler", + "RootsHandler", + "RootsList", + "SamplingHandler", + "SessionKwargs", ] logger = get_logger(__name__) @@ -362,10 +362,10 @@ class Client(Generic[ClientTransportT]): await self._session_state.session.initialize() ) yield - except anyio.ClosedResourceError: - raise RuntimeError("Server session was closed unexpectedly") - except TimeoutError: - raise RuntimeError("Failed to initialize server session") + except anyio.ClosedResourceError as e: + raise RuntimeError("Server session was closed unexpectedly") from e + except TimeoutError as e: + raise RuntimeError("Failed to initialize server session") from e finally: self._session_state.session = None self._session_state.initialize_result = None diff --git a/src/fastmcp/client/sampling.py b/src/fastmcp/client/sampling.py index 71ef28540..cf7dad77a 100644 --- a/src/fastmcp/client/sampling.py +++ b/src/fastmcp/client/sampling.py @@ -11,7 +11,7 @@ from mcp.types import SamplingMessage from fastmcp.server.sampling.handler import ServerSamplingHandler -__all__ = ["SamplingMessage", "SamplingParams", "SamplingHandler"] +__all__ = ["SamplingHandler", "SamplingMessage", "SamplingParams"] ClientSamplingHandler: TypeAlias = Callable[ diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 92c88a0de..25f81afc4 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -46,16 +46,16 @@ ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport") __all__ = [ "ClientTransport", - "SSETransport", - "StreamableHttpTransport", - "StdioTransport", - "PythonStdioTransport", "FastMCPStdioTransport", - "NodeStdioTransport", - "UvxStdioTransport", - "UvStdioTransport", - "NpxStdioTransport", "FastMCPTransport", + "NodeStdioTransport", + "NpxStdioTransport", + "PythonStdioTransport", + "SSETransport", + "StdioTransport", + "StreamableHttpTransport", + "UvStdioTransport", + "UvxStdioTransport", "infer_transport", ] @@ -109,9 +109,8 @@ class ClientTransport(abc.ABC): # Basic representation for subclasses return f"<{self.__class__.__name__}>" - async def close(self): + async def close(self): # noqa: B027 """Close the transport.""" - pass def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): if auth is not None: @@ -141,10 +140,10 @@ class WSTransport(ClientTransport): ) -> AsyncIterator[ClientSession]: try: from mcp.client.websocket import websocket_client - except ImportError: + except ImportError as e: raise ImportError( "The websocket transport is not available. Please install fastmcp[websockets] or install the websockets package manually." - ) + ) from e async with websocket_client(self.url) as transport: read_stream, write_stream = transport @@ -207,7 +206,7 @@ class SSETransport(ClientTransport): # instead we simply leave the kwarg out if it's not provided if self.sse_read_timeout is not None: client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds() - if session_kwargs.get("read_timeout_seconds", None) is not None: + if session_kwargs.get("read_timeout_seconds") is not None: read_timeout_seconds = cast( datetime.timedelta, session_kwargs.get("read_timeout_seconds") ) @@ -277,7 +276,7 @@ class StreamableHttpTransport(ClientTransport): # instead we simply leave the kwarg out if it's not provided if self.sse_read_timeout is not None: client_kwargs["sse_read_timeout"] = self.sse_read_timeout - if session_kwargs.get("read_timeout_seconds", None) is not None: + if session_kwargs.get("read_timeout_seconds") is not None: client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds") if self.httpx_client_factory is not None: @@ -451,8 +450,7 @@ async def _stdio_transport_connect_task( if log_file is None: log_file_handle = sys.stderr elif isinstance(log_file, Path): - log_file_handle = open(log_file, "a") - stack.callback(log_file_handle.close) + log_file_handle = stack.enter_context(log_file.open("a")) else: # Must be TextIO - use it directly log_file_handle = log_file @@ -852,26 +850,28 @@ class FastMCPTransport(ClientTransport): server_read, server_write = server_streams # Create a cancel scope for the server task - async with anyio.create_task_group() as tg: - async with _enter_server_lifespan(server=self.server): - tg.start_soon( - lambda: self.server._mcp_server.run( - server_read, - server_write, - self.server._mcp_server.create_initialization_options(), - raise_exceptions=self.raise_exceptions, - ) + async with ( + anyio.create_task_group() as tg, + _enter_server_lifespan(server=self.server), + ): + tg.start_soon( + lambda: self.server._mcp_server.run( + server_read, + server_write, + self.server._mcp_server.create_initialization_options(), + raise_exceptions=self.raise_exceptions, ) + ) - try: - async with ClientSession( - read_stream=client_read, - write_stream=client_write, - **session_kwargs, - ) as client_session: - yield client_session - finally: - tg.cancel_scope.cancel() + try: + async with ClientSession( + read_stream=client_read, + write_stream=client_write, + **session_kwargs, + ) as client_session: + yield client_session + finally: + tg.cancel_scope.cancel() def __repr__(self) -> str: return f"" @@ -952,7 +952,7 @@ class MCPConfigTransport(ClientTransport): # if there's exactly one server, create a client for that server elif len(self.config.mcpServers) == 1: - self.transport = list(self.config.mcpServers.values())[0].to_transport() + self.transport = next(iter(self.config.mcpServers.values())).to_transport() self._underlying_transports.append(self.transport) # otherwise create a composite client diff --git a/src/fastmcp/contrib/component_manager/__init__.py b/src/fastmcp/contrib/component_manager/__init__.py index 6bb6c89ba..9f7e26044 100644 --- a/src/fastmcp/contrib/component_manager/__init__.py +++ b/src/fastmcp/contrib/component_manager/__init__.py @@ -1,4 +1,4 @@ from .component_manager import set_up_component_manager from .component_service import ComponentService -__all__ = ["set_up_component_manager", "ComponentService"] +__all__ = ["ComponentService", "set_up_component_manager"] diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py index 01a24eff0..e0de23a8c 100644 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -97,11 +97,11 @@ def make_endpoint(action, component, config): return JSONResponse( {"message": f"{action.capitalize()}d {component}: {name}"} ) - except NotFoundError: + except NotFoundError as e: raise StarletteHTTPException( status_code=404, detail=f"Unknown {component}: {name}", - ) + ) from e return endpoint diff --git a/src/fastmcp/contrib/mcp_mixin/__init__.py b/src/fastmcp/contrib/mcp_mixin/__init__.py index 8b4cca0e2..48a536632 100644 --- a/src/fastmcp/contrib/mcp_mixin/__init__.py +++ b/src/fastmcp/contrib/mcp_mixin/__init__.py @@ -2,7 +2,7 @@ from .mcp_mixin import MCPMixin, mcp_tool, mcp_resource, mcp_prompt __all__ = [ "MCPMixin", - "mcp_tool", - "mcp_resource", "mcp_prompt", + "mcp_resource", + "mcp_tool", ] diff --git a/src/fastmcp/experimental/sampling/handlers/openai.py b/src/fastmcp/experimental/sampling/handlers/openai.py index 2ff0bbbc1..0ff610835 100644 --- a/src/fastmcp/experimental/sampling/handlers/openai.py +++ b/src/fastmcp/experimental/sampling/handlers/openai.py @@ -21,10 +21,10 @@ try: ChatCompletionUserMessageParam, ) from openai.types.shared.chat_model import ChatModel -except ImportError: +except ImportError as e: raise ImportError( "The `openai` package is not installed. Please install `fastmcp[openai]` or add `openai` to your dependencies manually." - ) + ) from e from typing_extensions import override diff --git a/src/fastmcp/experimental/server/openapi/__init__.py b/src/fastmcp/experimental/server/openapi/__init__.py index 96ac769cd..cff036339 100644 --- a/src/fastmcp/experimental/server/openapi/__init__.py +++ b/src/fastmcp/experimental/server/openapi/__init__.py @@ -22,17 +22,14 @@ from .components import ( # Export public symbols - maintaining backward compatibility __all__ = [ - # Server - "FastMCPOpenAPI", - # Routing - "MCPType", - "RouteMap", - "RouteMapFn", - "ComponentFn", "DEFAULT_ROUTE_MAPPINGS", - "_determine_route_type", - # Components - "OpenAPITool", + "ComponentFn", + "FastMCPOpenAPI", + "MCPType", "OpenAPIResource", "OpenAPIResourceTemplate", + "OpenAPITool", + "RouteMap", + "RouteMapFn", + "_determine_route_type", ] diff --git a/src/fastmcp/experimental/server/openapi/components.py b/src/fastmcp/experimental/server/openapi/components.py index 37b40e272..961c6363a 100644 --- a/src/fastmcp/experimental/server/openapi/components.py +++ b/src/fastmcp/experimental/server/openapi/components.py @@ -146,11 +146,11 @@ class OpenAPITool(Tool): if e.response.text: error_message += f" - {e.response.text}" - raise ValueError(error_message) + raise ValueError(error_message) from e except httpx.RequestError as e: # Handle request errors (connection, timeout, etc.) - raise ValueError(f"Request error: {str(e)}") + raise ValueError(f"Request error: {e!s}") from e class OpenAPIResource(Resource): @@ -165,9 +165,11 @@ class OpenAPIResource(Resource): name: str, description: str, mime_type: str = "application/json", - tags: set[str] = set(), + tags: set[str] | None = None, timeout: float | None = None, ): + if tags is None: + tags = set() super().__init__( uri=AnyUrl(uri), # Convert string to AnyUrl name=name, @@ -276,11 +278,11 @@ class OpenAPIResource(Resource): if e.response.text: error_message += f" - {e.response.text}" - raise ValueError(error_message) + raise ValueError(error_message) from e except httpx.RequestError as e: # Handle request errors (connection, timeout, etc.) - raise ValueError(f"Request error: {str(e)}") + raise ValueError(f"Request error: {e!s}") from e class OpenAPIResourceTemplate(ResourceTemplate): @@ -295,9 +297,11 @@ class OpenAPIResourceTemplate(ResourceTemplate): name: str, description: str, parameters: dict[str, Any], - tags: set[str] = set(), + tags: set[str] | None = None, timeout: float | None = None, ): + if tags is None: + tags = set() super().__init__( uri_template=uri_template, name=name, @@ -342,7 +346,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): # Export public symbols __all__ = [ - "OpenAPITool", "OpenAPIResource", "OpenAPIResourceTemplate", + "OpenAPITool", ] diff --git a/src/fastmcp/experimental/server/openapi/routing.py b/src/fastmcp/experimental/server/openapi/routing.py index 092b2445b..1e3a54cea 100644 --- a/src/fastmcp/experimental/server/openapi/routing.py +++ b/src/fastmcp/experimental/server/openapi/routing.py @@ -121,10 +121,10 @@ def _determine_route_type( # Export public symbols __all__ = [ + "DEFAULT_ROUTE_MAPPINGS", + "ComponentFn", "MCPType", "RouteMap", "RouteMapFn", - "ComponentFn", - "DEFAULT_ROUTE_MAPPINGS", "_determine_route_type", ] diff --git a/src/fastmcp/experimental/utilities/openapi/__init__.py b/src/fastmcp/experimental/utilities/openapi/__init__.py index 92a76ec61..f71bc7a6a 100644 --- a/src/fastmcp/experimental/utilities/openapi/__init__.py +++ b/src/fastmcp/experimental/utilities/openapi/__init__.py @@ -40,29 +40,24 @@ from .json_schema_converter import ( # Export public symbols - maintaining backward compatibility __all__ = [ - # Models "HTTPRoute", + "HttpMethod", + "JsonSchema", "ParameterInfo", + "ParameterLocation", "RequestBodyInfo", "ResponseInfo", - "HttpMethod", - "ParameterLocation", - "JsonSchema", - # Parser - "parse_openapi_to_http_routes", - # Formatters + "_combine_schemas", + "_make_optional_parameter_nullable", + "clean_schema_for_display", + "convert_openapi_schema_to_json_schema", + "convert_schema_definitions", + "extract_output_schema_from_responses", "format_array_parameter", "format_deep_object_parameter", "format_description_with_responses", "format_json_for_description", "format_simple_description", "generate_example_from_schema", - # Schemas - "_combine_schemas", - "extract_output_schema_from_responses", - "clean_schema_for_display", - "_make_optional_parameter_nullable", - # JSON Schema Converter - "convert_openapi_schema_to_json_schema", - "convert_schema_definitions", + "parse_openapi_to_http_routes", ] diff --git a/src/fastmcp/experimental/utilities/openapi/director.py b/src/fastmcp/experimental/utilities/openapi/director.py index e7c860498..eb8b280fc 100644 --- a/src/fastmcp/experimental/utilities/openapi/director.py +++ b/src/fastmcp/experimental/utilities/openapi/director.py @@ -63,7 +63,7 @@ class RequestDirector: # Step 4: Handle request body if body is not None: - if isinstance(body, dict) or isinstance(body, list): + if isinstance(body, dict | list): request_data["json"] = body else: request_data["content"] = body diff --git a/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py b/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py index 2625aea08..23e4f6e2d 100644 --- a/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py +++ b/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py @@ -164,10 +164,10 @@ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]: if isinstance(current_type, str): result["type"] = [current_type, "null"] elif isinstance(current_type, list) and "null" not in current_type: - result["type"] = current_type + ["null"] + result["type"] = [*current_type, "null"] elif "oneOf" in result: # Convert oneOf to anyOf with null - result["anyOf"] = result.pop("oneOf") + [{"type": "null"}] + result["anyOf"] = [*result.pop("oneOf"), {"type": "null"}] elif "anyOf" in result: # Add null to anyOf if not present if not any(item.get("type") == "null" for item in result["anyOf"]): diff --git a/src/fastmcp/experimental/utilities/openapi/models.py b/src/fastmcp/experimental/utilities/openapi/models.py index c1d13b2c0..03d2eb68d 100644 --- a/src/fastmcp/experimental/utilities/openapi/models.py +++ b/src/fastmcp/experimental/utilities/openapi/models.py @@ -79,10 +79,10 @@ class HTTPRoute(FastMCPBaseModel): # Export public symbols __all__ = [ "HTTPRoute", + "HttpMethod", + "JsonSchema", "ParameterInfo", + "ParameterLocation", "RequestBodyInfo", "ResponseInfo", - "HttpMethod", - "ParameterLocation", - "JsonSchema", ] diff --git a/src/fastmcp/experimental/utilities/openapi/parser.py b/src/fastmcp/experimental/utilities/openapi/parser.py index bc81fc050..7b40ecba7 100644 --- a/src/fastmcp/experimental/utilities/openapi/parser.py +++ b/src/fastmcp/experimental/utilities/openapi/parser.py @@ -178,7 +178,7 @@ class OpenAPIParser( else: # Special handling for components if part == "components" and hasattr(target, "components"): - target = getattr(target, "components") + target = target.components elif hasattr(target, part): # Fallback check target = getattr(target, part, None) else: @@ -554,9 +554,7 @@ class OpenAPIParser( if "$ref" in obj and isinstance(obj["$ref"], str): ref = obj["$ref"] # Handle both converted and unconverted refs - if ref.startswith("#/$defs/"): - schema_name = ref.split("/")[-1] - elif ref.startswith("#/components/schemas/"): + if ref.startswith(("#/$defs/", "#/components/schemas/")): schema_name = ref.split("/")[-1] else: return @@ -815,6 +813,6 @@ class OpenAPIParser( # Export public symbols __all__ = [ - "parse_openapi_to_http_routes", "OpenAPIParser", + "parse_openapi_to_http_routes", ] diff --git a/src/fastmcp/experimental/utilities/openapi/schemas.py b/src/fastmcp/experimental/utilities/openapi/schemas.py index 101081b18..679fe2397 100644 --- a/src/fastmcp/experimental/utilities/openapi/schemas.py +++ b/src/fastmcp/experimental/utilities/openapi/schemas.py @@ -585,9 +585,9 @@ def extract_output_schema_from_responses( # Export public symbols __all__ = [ - "clean_schema_for_display", "_combine_schemas", "_combine_schemas_and_map_params", - "extract_output_schema_from_responses", "_make_optional_parameter_nullable", + "clean_schema_for_display", + "extract_output_schema_from_responses", ] diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py index 47e248c76..878d60faf 100644 --- a/src/fastmcp/mcp_config.py +++ b/src/fastmcp/mcp_config.py @@ -288,9 +288,8 @@ class MCPConfig(BaseModel): @classmethod def from_file(cls, file_path: Path) -> Self: """Load configuration from JSON file.""" - if file_path.exists(): - if content := file_path.read_text().strip(): - return cls.model_validate_json(content) + if file_path.exists() and (content := file_path.read_text().strip()): + return cls.model_validate_json(content) raise ValueError(f"No MCP servers defined in the config: {file_path}") diff --git a/src/fastmcp/prompts/__init__.py b/src/fastmcp/prompts/__init__.py index 1a8d91255..f230b8c64 100644 --- a/src/fastmcp/prompts/__init__.py +++ b/src/fastmcp/prompts/__init__.py @@ -2,8 +2,8 @@ from .prompt import Prompt, PromptMessage, Message from .prompt_manager import PromptManager __all__ = [ + "Message", "Prompt", "PromptManager", "PromptMessage", - "Message", ] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index a0e1bff31..f8498237d 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -207,10 +207,7 @@ class FunctionPrompt(Prompt): # Auto-detect context parameter if not provided context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) - if context_kwarg: - prune_params = [context_kwarg] - else: - prune_params = None + prune_params = [context_kwarg] if context_kwarg else None parameters = compress_schema(parameters, prune_params=prune_params) @@ -290,10 +287,7 @@ class FunctionPrompt(Prompt): if ( param.annotation == inspect.Parameter.empty or param.annotation is str - ): - converted_kwargs[param_name] = param_value - # If argument is not a string, pass as-is (already properly typed) - elif not isinstance(param_value, str): + ) or not isinstance(param_value, str): converted_kwargs[param_name] = param_value else: # Try to convert string argument using type adapter @@ -314,7 +308,7 @@ class FunctionPrompt(Prompt): raise PromptError( f"Could not convert argument '{param_name}' with value '{param_value}' " f"to expected type {param.annotation}. Error: {e}" - ) + ) from e else: # Parameter not in function signature, pass as-is converted_kwargs[param_name] = param_value @@ -376,10 +370,12 @@ class FunctionPrompt(Prompt): content=TextContent(type="text", text=content), ) ) - except Exception: - raise PromptError("Could not convert prompt result to message.") + except Exception as e: + raise PromptError( + "Could not convert prompt result to message." + ) from e return messages - except Exception: + except Exception as e: logger.exception(f"Error rendering prompt {self.name}") - raise PromptError(f"Error rendering prompt {self.name}.") + raise PromptError(f"Error rendering prompt {self.name}.") from e diff --git a/src/fastmcp/resources/__init__.py b/src/fastmcp/resources/__init__.py index 3b36a4a62..ebacf5ecf 100644 --- a/src/fastmcp/resources/__init__.py +++ b/src/fastmcp/resources/__init__.py @@ -10,13 +10,13 @@ from .types import ( from .resource_manager import ResourceManager __all__ = [ - "Resource", - "TextResource", "BinaryResource", - "FunctionResource", - "FileResource", - "HttpResource", "DirectoryResource", - "ResourceTemplate", + "FileResource", + "FunctionResource", + "HttpResource", + "Resource", "ResourceManager", + "ResourceTemplate", + "TextResource", ] diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 90de857a0..f27070447 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -217,9 +217,7 @@ class FunctionResource(Resource): if isinstance(result, Resource): return await result.read() - elif isinstance(result, bytes): - return result - elif isinstance(result, str): + elif isinstance(result, bytes | str): return result else: return pydantic_core.to_json(result, fallback=str).decode() diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 07331ae18..7367fb3e9 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -235,8 +235,8 @@ class ResourceManager: # Then check templates (local and mounted) only if not found in concrete resources templates = await self.get_resource_templates() - for template_key in templates.keys(): - if match_uri_template(uri_str, template_key): + for template_key in templates: + if match_uri_template(uri_str, template_key) is not None: return True return False @@ -262,7 +262,7 @@ class ResourceManager: templates = await self.get_resource_templates() for storage_key, template in templates.items(): # Try to match against the storage key (which might be a custom key) - if params := match_uri_template(uri_str, storage_key): + if (params := match_uri_template(uri_str, storage_key)) is not None: try: return await template.create_resource( uri_str, @@ -318,7 +318,7 @@ class ResourceManager: # 1b. Check local templates if not found in concrete resources for key, template in self._templates.items(): - if params := match_uri_template(uri_str, key): + if (params := match_uri_template(uri_str, key)) is not None: try: resource = await template.create_resource(uri_str, params=params) return await resource.read() diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py index 61c29bf3b..5af01035a 100644 --- a/src/fastmcp/resources/types.py +++ b/src/fastmcp/resources/types.py @@ -5,11 +5,11 @@ from __future__ import annotations import json from pathlib import Path -import anyio -import anyio.to_thread import httpx import pydantic.json +from anyio import Path as AsyncPath from pydantic import Field, ValidationInfo +from typing_extensions import override from fastmcp.exceptions import ResourceError from fastmcp.resources.resource import Resource @@ -54,6 +54,10 @@ class FileResource(Resource): description="MIME type of the resource content", ) + @property + def _async_path(self) -> AsyncPath: + return AsyncPath(self.path) + @pydantic.field_validator("path") @classmethod def validate_absolute_path(cls, path: Path) -> Path: @@ -71,12 +75,13 @@ class FileResource(Resource): mime_type = info.data.get("mime_type", "text/plain") return not mime_type.startswith("text/") + @override async def read(self) -> str | bytes: """Read the file content.""" try: if self.is_binary: - return await anyio.to_thread.run_sync(self.path.read_bytes) - return await anyio.to_thread.run_sync(self.path.read_text) + return await self._async_path.read_bytes() + return await self._async_path.read_text() except Exception as e: raise ResourceError(f"Error reading file {self.path}") from e @@ -89,11 +94,12 @@ class HttpResource(Resource): default="application/json", description="MIME type of the resource content" ) + @override async def read(self) -> str | bytes: """Read the HTTP content.""" async with httpx.AsyncClient() as client: response = await client.get(self.url) - response.raise_for_status() + _ = response.raise_for_status() return response.text @@ -111,6 +117,10 @@ class DirectoryResource(Resource): default="application/json", description="MIME type of the resource content" ) + @property + def _async_path(self) -> AsyncPath: + return AsyncPath(self.path) + @pydantic.field_validator("path") @classmethod def validate_absolute_path(cls, path: Path) -> Path: @@ -119,33 +129,29 @@ class DirectoryResource(Resource): raise ValueError("Path must be absolute") return path - def list_files(self) -> list[Path]: + async def list_files(self) -> list[Path]: """List files in the directory.""" - if not self.path.exists(): + if not await self._async_path.exists(): raise FileNotFoundError(f"Directory not found: {self.path}") - if not self.path.is_dir(): + if not await self._async_path.is_dir(): raise NotADirectoryError(f"Not a directory: {self.path}") - try: - if self.pattern: - return ( - list(self.path.glob(self.pattern)) - if not self.recursive - else list(self.path.rglob(self.pattern)) - ) - return ( - list(self.path.glob("*")) - if not self.recursive - else list(self.path.rglob("*")) - ) - except Exception as e: - raise ResourceError(f"Error listing directory {self.path}: {e}") + pattern = self.pattern or "*" + glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob + try: + return [Path(p) async for p in glob_fn(pattern) if await p.is_file()] + except Exception as e: + raise ResourceError(f"Error listing directory {self.path}") from e + + @override async def read(self) -> str: # Always returns JSON string """Read the directory listing.""" try: - files = await anyio.to_thread.run_sync(self.list_files) - file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] + files: list[Path] = await self.list_files() + + file_list = [str(f.relative_to(self.path)) for f in files] + return json.dumps({"files": file_list}, indent=2) - except Exception: - raise ResourceError(f"Error reading directory {self.path}") + except Exception as e: + raise ResourceError(f"Error reading directory {self.path}") from e diff --git a/src/fastmcp/server/__init__.py b/src/fastmcp/server/__init__.py index c17dd0e4e..69ded232c 100644 --- a/src/fastmcp/server/__init__.py +++ b/src/fastmcp/server/__init__.py @@ -3,4 +3,4 @@ from .context import Context from . import dependencies -__all__ = ["FastMCP", "Context"] +__all__ = ["Context", "FastMCP"] diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index e7111ec97..e33ad022a 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -5,19 +5,23 @@ from .auth import ( AccessToken, AuthProvider, ) +from .providers.debug import DebugTokenVerifier from .providers.jwt import JWTVerifier, StaticTokenVerifier from .oauth_proxy import OAuthProxy +from .oidc_proxy import OIDCProxy __all__ = [ - "AuthProvider", - "OAuthProvider", - "TokenVerifier", - "JWTVerifier", - "StaticTokenVerifier", - "RemoteAuthProvider", "AccessToken", + "AuthProvider", + "DebugTokenVerifier", + "JWTVerifier", + "OAuthProvider", "OAuthProxy", + "OIDCProxy", + "RemoteAuthProvider", + "StaticTokenVerifier", + "TokenVerifier", ] diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index 2bec554f6..adae95b7d 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -23,7 +23,7 @@ from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) -from pydantic import AnyHttpUrl +from pydantic import AnyHttpUrl, Field from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.routing import Route @@ -32,7 +32,7 @@ from starlette.routing import Route class AccessToken(_SDKAccessToken): """AccessToken that includes all JWT claims.""" - claims: dict[str, Any] = {} + claims: dict[str, Any] = Field(default_factory=dict) class AuthProvider(TokenVerifierProtocol): diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 50dee0462..6f1336d96 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -365,7 +365,19 @@ def create_consent_html( ) # Need to allow form-action for form submission - csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; form-action *" + # Chrome requires explicit scheme declarations in CSP form-action when redirect chains + # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme. + parsed_redirect = urlparse(redirect_uri) + redirect_scheme = parsed_redirect.scheme.lower() + + # Build form-action directive with standard schemes plus custom protocol if present + form_action_schemes = ["https:", "http:"] + if redirect_scheme and redirect_scheme not in ("http", "https"): + # Custom protocol scheme (e.g., cursor:, vscode:, etc.) + form_action_schemes.append(f"{redirect_scheme}:") + + form_action_directive = " ".join(form_action_schemes) + csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; form-action {form_action_directive}" return create_page( content=content, @@ -375,6 +387,96 @@ def create_consent_html( ) +def create_error_html( + error_title: str, + error_message: str, + error_details: dict[str, str] | None = None, + server_name: str | None = None, + server_icon_url: str | None = None, +) -> str: + """Create a styled HTML error page for OAuth errors. + + Args: + error_title: The error title (e.g., "OAuth Error", "Authorization Failed") + error_message: The main error message to display + error_details: Optional dictionary of error details to show (e.g., {"Error Code": "invalid_client"}) + server_name: Optional server name to display + server_icon_url: Optional URL to server icon/logo + + Returns: + Complete HTML page as a string + """ + import html as html_module + + error_message_escaped = html_module.escape(error_message) + + # Build error message box + error_box = f""" +
+

{error_message_escaped}

+
+ """ + + # Build error details section if provided + details_section = "" + if error_details: + detail_rows_html = "\n".join( + [ + f""" +
+
{html_module.escape(label)}:
+
{html_module.escape(value)}
+
+ """ + for label, value in error_details.items() + ] + ) + + details_section = f""" +
+ Error Details +
+ {detail_rows_html} +
+
+ """ + + # Build the page content + content = f""" +
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} +

{html_module.escape(error_title)}

+ {error_box} + {details_section} +
+ """ + + # Additional styles needed for this page + # Override .info-box.error to use normal text color instead of red + additional_styles = ( + INFO_BOX_STYLES + + DETAILS_STYLES + + DETAIL_BOX_STYLES + + """ + .info-box.error { + color: #111827; + } + """ + ) + + # Simple CSP policy for error pages (no forms needed) + csp_policy = ( + "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'" + ) + + return create_page( + content=content, + title=error_title, + additional_styles=additional_styles, + csp_policy=csp_policy, + ) + + # ------------------------------------------------------------------------- # Handler Classes # ------------------------------------------------------------------------- @@ -1569,7 +1671,9 @@ class OAuthProxy(OAuthProvider): # IdP Callback Forwarding # ------------------------------------------------------------------------- - async def _handle_idp_callback(self, request: Request) -> RedirectResponse: + async def _handle_idp_callback( + self, request: Request + ) -> HTMLResponse | RedirectResponse: """Handle callback from upstream IdP and forward to client. This implements the DCR-compliant callback forwarding: @@ -1584,32 +1688,37 @@ class OAuthProxy(OAuthProvider): error = request.query_params.get("error") if error: + error_description = request.query_params.get("error_description") logger.error( "IdP callback error: %s - %s", error, - request.query_params.get("error_description"), + error_description, ) - # TODO: Forward error to client callback - return RedirectResponse( - url=f"data:text/html,

OAuth Error

{error}: {request.query_params.get('error_description', 'Unknown error')}

", - status_code=302, + # Show error page to user + html_content = create_error_html( + error_title="OAuth Error", + error_message=f"Authentication failed: {error_description or 'Unknown error'}", + error_details={"Error Code": error} if error else None, ) + return HTMLResponse(content=html_content, status_code=400) if not idp_code or not txn_id: logger.error("IdP callback missing code or transaction ID") - return RedirectResponse( - url="data:text/html,

OAuth Error

Missing authorization code or transaction ID

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message="Missing authorization code or transaction ID from the identity provider.", ) + return HTMLResponse(content=html_content, status_code=400) # Look up transaction data transaction_model = await self._transaction_store.get(key=txn_id) if not transaction_model: logger.error("IdP callback with invalid transaction ID: %s", txn_id) - return RedirectResponse( - url="data:text/html,

OAuth Error

Invalid or expired transaction

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message="Invalid or expired authorization transaction. Please try authenticating again.", ) + return HTMLResponse(content=html_content, status_code=400) transaction = transaction_model.model_dump() # Exchange IdP code for tokens (server-side) @@ -1663,11 +1772,11 @@ class OAuthProxy(OAuthProvider): except Exception as e: logger.error("IdP token exchange failed: %s", e) - # TODO: Forward error to client callback - return RedirectResponse( - url=f"data:text/html,

OAuth Error

Token exchange failed: {e}

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message=f"Token exchange with identity provider failed: {e}", ) + return HTMLResponse(content=html_content, status_code=500) # Generate our own authorization code for the client client_code = secrets.token_urlsafe(32) @@ -1714,10 +1823,11 @@ class OAuthProxy(OAuthProvider): except Exception as e: logger.error("Error in IdP callback handler: %s", e, exc_info=True) - return RedirectResponse( - url="data:text/html,

OAuth Error

Internal server error during IdP callback

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message="Internal server error during OAuth callback processing. Please try again.", ) + return HTMLResponse(content=html_content, status_code=500) # ------------------------------------------------------------------------- # Consent Interstitial diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 063f4a3ad..d9a3df510 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -123,10 +123,10 @@ class OIDCConfiguration(BaseModel): try: AnyHttpUrl(value) - except Exception: + except Exception as e: message = f"Invalid URL for configuration metadata: {attr}" logger.error(message) - raise ValueError(message) + raise ValueError(message) from e enforce("issuer", True) enforce("authorization_endpoint", True) @@ -206,6 +206,7 @@ class OIDCProxy(OAuthProxy): audience: str | None = None, timeout_seconds: int | None = None, # Token verifier + token_verifier: TokenVerifier | None = None, algorithm: str | None = None, required_scopes: list[str] | None = None, # FastMCP server configuration @@ -231,8 +232,11 @@ class OIDCProxy(OAuthProxy): client_secret: Client secret for upstream server audience: Audience for upstream server timeout_seconds: HTTP request timeout in seconds - algorithm: Token verifier algorithm - required_scopes: Required OAuth scopes + token_verifier: Optional custom token verifier (e.g., IntrospectionTokenVerifier for opaque tokens). + If not provided, a JWTVerifier will be created using the OIDC configuration. + Cannot be used with algorithm or required_scopes parameters (configure these on your verifier instead). + algorithm: Token verifier algorithm (only used if token_verifier is not provided) + required_scopes: Required scopes for token validation (only used if token_verifier is not provided) base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. @@ -268,6 +272,19 @@ class OIDCProxy(OAuthProxy): if not base_url: raise ValueError("Missing required base URL") + # Validate that verifier-specific parameters are not used with custom verifier + if token_verifier is not None: + if algorithm is not None: + raise ValueError( + "Cannot specify 'algorithm' when providing a custom token_verifier. " + "Configure the algorithm on your token verifier instead." + ) + if required_scopes is not None: + raise ValueError( + "Cannot specify 'required_scopes' when providing a custom token_verifier. " + "Configure required scopes on your token verifier instead." + ) + if isinstance(config_url, str): config_url = AnyHttpUrl(config_url) @@ -287,12 +304,14 @@ class OIDCProxy(OAuthProxy): else None ) - token_verifier = self.get_token_verifier( - algorithm=algorithm, - audience=audience, - required_scopes=required_scopes, - timeout_seconds=timeout_seconds, - ) + # Use custom verifier if provided, otherwise create default JWTVerifier + if token_verifier is None: + token_verifier = self.get_token_verifier( + algorithm=algorithm, + audience=audience, + required_scopes=required_scopes, + timeout_seconds=timeout_seconds, + ) init_kwargs = { "upstream_authorization_endpoint": str( diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 2217d0aa8..4c7cb8359 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,7 +6,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator @@ -46,6 +46,7 @@ class AzureProviderSettings(BaseSettings): additional_authorize_scopes: list[str] | None = None allowed_client_redirect_uris: list[str] | None = None jwt_signing_key: str | None = None + base_authority: str = "login.microsoftonline.com" @field_validator("required_scopes", mode="before") @classmethod @@ -93,6 +94,7 @@ class AzureProvider(OAuthProxy): from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider + # Standard Azure (Public Cloud) auth = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", @@ -103,6 +105,16 @@ class AzureProvider(OAuthProxy): # identifier_uri defaults to api://{client_id} ) + # Azure Government + auth_gov = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + required_scopes=["read", "write"], + base_authority="login.microsoftonline.us", # Override for Azure Gov + base_url="http://localhost:8000", + ) + mcp = FastMCP("My App", auth=auth) ``` """ @@ -113,16 +125,17 @@ class AzureProvider(OAuthProxy): client_id: str | NotSetT = NotSet, client_secret: str | NotSetT = NotSet, tenant_id: str | NotSetT = NotSet, - identifier_uri: str | None | NotSetT = NotSet, + identifier_uri: str | NotSetT | None = NotSet, base_url: str | NotSetT = NotSet, issuer_url: str | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, - additional_authorize_scopes: list[str] | None | NotSetT = NotSet, + required_scopes: list[str] | NotSetT | None = NotSet, + additional_authorize_scopes: list[str] | NotSetT | None = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | NotSetT = NotSet, require_authorization_consent: bool = True, + base_authority: str | NotSetT = NotSet, ) -> None: """Initialize Azure OAuth provider. @@ -138,6 +151,8 @@ class AzureProvider(OAuthProxy): issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback") + base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). + For Azure Government, use "login.microsoftonline.us". required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]). - Automatically prefixed with identifier_uri during initialization - Validated on all tokens @@ -180,6 +195,7 @@ class AzureProvider(OAuthProxy): "additional_authorize_scopes": additional_authorize_scopes, "allowed_client_redirect_uris": allowed_client_redirect_uris, "jwt_signing_key": jwt_signing_key, + "base_authority": base_authority, }.items() if v is not NotSet } @@ -202,32 +218,35 @@ class AzureProvider(OAuthProxy): ) raise ValueError(msg) + # Validate required_scopes has at least one scope if not settings.required_scopes: - raise ValueError("required_scopes is required") + msg = ( + "required_scopes must include at least one scope - set via parameter or " + "FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES. Azure's OAuth API requires " + "the 'scope' parameter in authorization requests. Use the unprefixed scope " + "names from your Azure App registration (e.g., ['read', 'write'])" + ) + raise ValueError(msg) # Apply defaults self.identifier_uri = settings.identifier_uri or f"api://{settings.client_id}" self.additional_authorize_scopes = settings.additional_authorize_scopes or [] tenant_id_final = settings.tenant_id - # Prefix required scopes with identifier_uri for Azure - # Azure returns scopes as full URIs (e.g., "api://xxx/read") in tokens - prefixed_required_scopes = [ - f"{self.identifier_uri}/{scope}" for scope in settings.required_scopes - ] - # Always validate tokens against the app's API client ID using JWT - issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0" + base_authority_final = settings.base_authority + issuer = f"https://{base_authority_final}/{tenant_id_final}/v2.0" jwks_uri = ( - f"https://login.microsoftonline.com/{tenant_id_final}/discovery/v2.0/keys" + f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys" ) + # Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes token_verifier = JWTVerifier( jwks_uri=jwks_uri, issuer=issuer, audience=settings.client_id, algorithm="RS256", - required_scopes=prefixed_required_scopes, + required_scopes=settings.required_scopes, # Unprefixed scopes for validation ) # Extract secret string from SecretStr @@ -237,10 +256,10 @@ class AzureProvider(OAuthProxy): # Build Azure OAuth endpoints with tenant authorization_endpoint = ( - f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize" + f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/authorize" ) token_endpoint = ( - f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/token" + f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/token" ) # Initialize OAuth proxy with Azure endpoints @@ -260,11 +279,15 @@ class AzureProvider(OAuthProxy): require_authorization_consent=require_authorization_consent, ) + authority_info = "" + if base_authority_final != "login.microsoftonline.com": + authority_info = f" using authority {base_authority_final}" logger.info( - "Initialized Azure OAuth provider for client %s with tenant %s%s", + "Initialized Azure OAuth provider for client %s with tenant %s%s%s", settings.client_id, tenant_id_final, f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", + authority_info, ) async def authorize( @@ -298,19 +321,40 @@ class AzureProvider(OAuthProxy): "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)", original_resource, ) - # Scopes are already prefixed: - # - self.required_scopes was prefixed during __init__ - # - Client scopes come from PRM which advertises prefixed scopes - scopes = params_to_use.scopes or self.required_scopes - - final_scopes = list(scopes) - # Add Microsoft Graph scopes separately - these use shorthand format (e.g., "User.Read") - # and should not be prefixed with identifier_uri. Azure returns them as-is in tokens. - if self.additional_authorize_scopes: - final_scopes.extend(self.additional_authorize_scopes) - - modified_params = params_to_use.model_copy(update={"scopes": final_scopes}) - - auth_url = await super().authorize(client, modified_params) + # Don't modify the scopes in params - they stay unprefixed for MCP clients + # We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url) + auth_url = await super().authorize(client, params_to_use) separator = "&" if "?" in auth_url else "?" return f"{auth_url}{separator}prompt=select_account" + + def _build_upstream_authorize_url( + self, txn_id: str, transaction: dict[str, Any] + ) -> str: + """Build Azure authorization URL with prefixed scopes. + + Overrides parent to prefix scopes with identifier_uri before sending to Azure, + while keeping unprefixed scopes in the transaction for MCP clients. + """ + # Get unprefixed scopes from transaction + 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}") + + # Add Microsoft Graph scopes (not validated, not prefixed) + if self.additional_authorize_scopes: + prefixed_scopes.extend(self.additional_authorize_scopes) + + # Temporarily modify transaction dict for parent's URL building + modified_transaction = transaction.copy() + modified_transaction["scopes"] = prefixed_scopes + + # Let parent build the URL with prefixed scopes + return super()._build_upstream_authorize_url(txn_id, modified_transaction) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index c37718801..1482ab0f3 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -11,7 +11,7 @@ from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, RSAKeyPair from fastmcp.server.auth.providers.jwt import JWTVerifier as BearerAuthProvider # Re-export for backwards compatibility -__all__ = ["BearerAuthProvider", "RSAKeyPair", "JWKData", "JWKSData"] +__all__ = ["BearerAuthProvider", "JWKData", "JWKSData", "RSAKeyPair"] # Deprecated in 2.11 if fastmcp.settings.deprecation_warnings: diff --git a/src/fastmcp/server/auth/providers/debug.py b/src/fastmcp/server/auth/providers/debug.py new file mode 100644 index 000000000..5b6de01e3 --- /dev/null +++ b/src/fastmcp/server/auth/providers/debug.py @@ -0,0 +1,114 @@ +"""Debug token verifier for testing and special cases. + +This module provides a flexible token verifier that delegates validation +to a custom callable. Useful for testing, development, or scenarios where +standard verification isn't possible (like opaque tokens without introspection). + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.debug import DebugTokenVerifier + + # Accept all tokens (default - useful for testing) + auth = DebugTokenVerifier() + + # Custom sync validation logic + auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-")) + + # Custom async validation logic + async def check_cache(token: str) -> bool: + return await redis.exists(f"token:{token}") + + auth = DebugTokenVerifier(validate=check_cache) + + mcp = FastMCP("My Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class DebugTokenVerifier(TokenVerifier): + """Token verifier with custom validation logic. + + This verifier delegates token validation to a user-provided callable. + By default, it accepts all non-empty tokens (useful for testing). + + Use cases: + - Testing: Accept any token without real verification + - Development: Custom validation logic for prototyping + - Opaque tokens: When you have tokens with no introspection endpoint + + WARNING: This bypasses standard security checks. Only use in controlled + environments or when you understand the security implications. + """ + + def __init__( + self, + validate: Callable[[str], bool] + | Callable[[str], Awaitable[bool]] = lambda token: True, + client_id: str = "debug-client", + scopes: list[str] | None = None, + required_scopes: list[str] | None = None, + ): + """Initialize the debug token verifier. + + Args: + validate: Callable that takes a token string and returns True if valid. + Can be sync or async. Default accepts all tokens. + client_id: Client ID to assign to validated tokens + scopes: Scopes to assign to validated tokens + required_scopes: Required scopes (inherited from TokenVerifier base class) + """ + super().__init__(required_scopes=required_scopes) + self.validate = validate + self.client_id = client_id + self.scopes = scopes or [] + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token using custom validation logic. + + Args: + token: The token string to validate + + Returns: + AccessToken if validation succeeds, None otherwise + """ + # Reject empty tokens + if not token or not token.strip(): + logger.debug("Rejecting empty token") + return None + + try: + # Call validation function and await if result is awaitable + result = self.validate(token) + if inspect.isawaitable(result): + is_valid = await result + else: + is_valid = result + + if not is_valid: + logger.debug("Token validation failed: callable returned False") + return None + + # Return valid AccessToken + return AccessToken( + token=token, + client_id=self.client_id, + scopes=self.scopes, + expires_at=None, # No expiration + claims={"token": token}, # Store original token in claims + ) + + except Exception as e: + logger.debug("Token validation error: %s", e, exc_info=True) + return None diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py index 09475bb03..9a1bd0c7d 100644 --- a/src/fastmcp/server/auth/providers/in_memory.py +++ b/src/fastmcp/server/auth/providers/in_memory.py @@ -96,10 +96,10 @@ class InMemoryOAuthProvider(OAuthProvider): # or if params.redirect_uri is None and client has a default. # However, the AuthorizationHandler handles the primary validation. pass # Let's assume AuthorizationHandler did its job. - except Exception: # Replace with specific validation error if client.validate_redirect_uri existed + except Exception as e: # Replace with specific validation error if client.validate_redirect_uri existed raise AuthorizeError( error="invalid_request", error_description="Invalid redirect_uri." - ) + ) from e auth_code_value = f"test_auth_code_{secrets.token_hex(16)}" expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS diff --git a/src/fastmcp/server/auth/providers/introspection.py b/src/fastmcp/server/auth/providers/introspection.py index c9e865bd9..b890a9046 100644 --- a/src/fastmcp/server/auth/providers/introspection.py +++ b/src/fastmcp/server/auth/providers/introspection.py @@ -97,8 +97,8 @@ class IntrospectionTokenVerifier(TokenVerifier): client_id: str | NotSetT = NotSet, client_secret: str | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, - base_url: AnyHttpUrl | str | None | NotSetT = NotSet, + required_scopes: list[str] | NotSetT | None = NotSet, + base_url: AnyHttpUrl | str | NotSetT | None = NotSet, ): """ Initialize the introspection token verifier. diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 552654ff7..1d6f4baa4 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -150,7 +150,7 @@ class JWTVerifierSettings(BaseSettings): public_key: str | None = None jwks_uri: str | None = None - issuer: str | None = None + issuer: str | list[str] | None = None algorithm: str | None = None audience: str | list[str] | None = None required_scopes: list[str] | None = None @@ -184,28 +184,28 @@ class JWTVerifier(TokenVerifier): def __init__( self, *, - public_key: str | None | NotSetT = NotSet, - jwks_uri: str | None | NotSetT = NotSet, - issuer: str | None | NotSetT = NotSet, - audience: str | list[str] | None | NotSetT = NotSet, - algorithm: str | None | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, - base_url: AnyHttpUrl | str | None | NotSetT = NotSet, + public_key: str | NotSetT | None = NotSet, + jwks_uri: str | NotSetT | None = NotSet, + issuer: str | list[str] | NotSetT | None = NotSet, + audience: str | list[str] | NotSetT | None = NotSet, + algorithm: str | NotSetT | None = NotSet, + required_scopes: list[str] | NotSetT | None = NotSet, + base_url: AnyHttpUrl | str | NotSetT | None = NotSet, ): """ - Initialize the JWT token verifier. + Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint. - Args: - public_key: For asymmetric algorithms (RS256, ES256, etc.): PEM-encoded public key. - For symmetric algorithms (HS256, HS384, HS512): The shared secret string. - jwks_uri: URI to fetch JSON Web Key Set (only for asymmetric algorithms) - issuer: Expected issuer claim - audience: Expected audience claim(s) - algorithm: JWT signing algorithm. Supported algorithms: - - Asymmetric: RS256/384/512, ES256/384/512, PS256/384/512 (default: RS256) - - Symmetric: HS256, HS384, HS512 - required_scopes: Required scopes for all tokens - base_url: Base URL for TokenVerifier protocol + Parameters: + public_key (str | NotSetT | None): PEM-encoded public key for asymmetric algorithms or shared secret for symmetric algorithms. + jwks_uri (str | NotSetT | None): URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS. + issuer (str | list[str] | NotSetT | None): Expected issuer claim value or list of allowed issuer values. + audience (str | list[str] | NotSetT | None): Expected audience claim value or list of allowed audience values. + algorithm (str | NotSetT | None): JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. + required_scopes (list[str] | NotSetT | None): Scopes that must be present in validated tokens. + base_url (AnyHttpUrl | str | NotSetT | None): Base URL passed to the parent TokenVerifier. + + Raises: + ValueError: If neither or both of `public_key` and `jwks_uri` are provided, or if `algorithm` is unsupported. """ settings = JWTVerifierSettings.model_validate( { @@ -283,7 +283,7 @@ class JWTVerifier(TokenVerifier): return await self._get_jwks_key(kid) except Exception as e: - raise ValueError(f"Failed to extract key ID from token: {e}") + raise ValueError(f"Failed to extract key ID from token: {e}") from e async def _get_jwks_key(self, kid: str | None) -> str: """Fetch key from JWKS with simple caching.""" @@ -342,10 +342,10 @@ class JWTVerifier(TokenVerifier): raise ValueError("No keys found in JWKS") except httpx.HTTPError as e: - raise ValueError(f"Failed to fetch JWKS: {e}") + raise ValueError(f"Failed to fetch JWKS: {e}") from e except Exception as e: self.logger.debug(f"JWKS fetch failed: {e}") - raise ValueError(f"Failed to fetch JWKS: {e}") + raise ValueError(f"Failed to fetch JWKS: {e}") from e def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: """ @@ -366,13 +366,13 @@ class JWTVerifier(TokenVerifier): async def load_access_token(self, token: str) -> AccessToken | None: """ - Validates the provided JWT bearer token. + Validate a JWT bearer token and return an AccessToken when the token is valid. - Args: - token: The JWT token string to validate + Parameters: + token (str): The JWT bearer token string to validate. Returns: - AccessToken object if valid, None if invalid or expired + AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. """ try: # Get verification key (static or from JWKS) @@ -401,7 +401,18 @@ class JWTVerifier(TokenVerifier): # Validate issuer - note we use issuer instead of issuer_url here because # issuer is optional, allowing users to make this check optional if self.issuer: - if claims.get("iss") != self.issuer: + iss = claims.get("iss") + + # Handle different combinations of issuer types + issuer_valid = False + if isinstance(self.issuer, list): + # self.issuer is a list - check if token issuer matches any expected issuer + issuer_valid = iss in self.issuer + else: + # self.issuer is a string - check for equality + issuer_valid = iss == self.issuer + + if not issuer_valid: self.logger.debug( "Token validation failed: issuer mismatch for client %s", client_id, diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py index 40019d688..13cb41e93 100644 --- a/src/fastmcp/server/auth/providers/supabase.py +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -83,7 +83,7 @@ class SupabaseProvider(RemoteAuthProvider): *, project_url: AnyHttpUrl | str | NotSetT = NotSet, base_url: AnyHttpUrl | str | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, + required_scopes: list[str] | NotSetT | None = NotSet, token_verifier: TokenVerifier | None = None, ): """Initialize Supabase metadata provider. diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 99a95dcdd..1d87ff5ec 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -169,7 +169,7 @@ class WorkOSProvider(OAuthProxy): base_url: AnyHttpUrl | str | NotSetT = NotSet, issuer_url: AnyHttpUrl | str | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, + required_scopes: list[str] | NotSetT | None = NotSet, timeout_seconds: int | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, client_storage: AsyncKeyValue | None = None, @@ -338,7 +338,7 @@ class AuthKitProvider(RemoteAuthProvider): *, authkit_domain: AnyHttpUrl | str | NotSetT = NotSet, base_url: AnyHttpUrl | str | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, + required_scopes: list[str] | NotSetT | None = NotSet, token_verifier: TokenVerifier | None = None, ): """Initialize AuthKit metadata provider. diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 1c565db55..94d983db3 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -188,8 +188,8 @@ class Context: """ try: return request_ctx.get() - except LookupError: - raise ValueError("Context is not available outside of a request") + except LookupError as e: + raise ValueError("Context is not available outside of a request") from e async def report_progress( self, progress: float, total: float | None = None, message: str | None = None @@ -224,8 +224,6 @@ class Context: Returns: List of Resource objects available on the server """ - if self.fastmcp is None: - raise ValueError("Context is not available outside of a request") return await self.fastmcp._list_resources_mcp() async def list_prompts(self) -> list[MCPPrompt]: @@ -234,8 +232,6 @@ class Context: Returns: List of Prompt objects available on the server """ - if self.fastmcp is None: - raise ValueError("Context is not available outside of a request") return await self.fastmcp._list_prompts_mcp() async def get_prompt( @@ -250,8 +246,6 @@ class Context: Returns: The prompt result """ - if self.fastmcp is None: - raise ValueError("Context is not available outside of a request") return await self.fastmcp._get_prompt_mcp(name, arguments) async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]: @@ -263,8 +257,6 @@ class Context: Returns: The resource content as either text or bytes """ - if self.fastmcp is None: - raise ValueError("Context is not available outside of a request") return await self.fastmcp._read_resource_mcp(uri) async def log( @@ -350,7 +342,7 @@ class Context: session_id = str(uuid4()) # Save the session id to the session attributes - setattr(session, "_fastmcp_id", session_id) + session._fastmcp_id = session_id return session_id @property @@ -603,13 +595,11 @@ class Context: choice_literal = Literal[tuple(response_type)] # type: ignore response_type = ScalarElicitationType[choice_literal] # type: ignore # if the user provided a primitive scalar, wrap it in an object schema - elif response_type in {bool, int, float, str}: - response_type = ScalarElicitationType[response_type] # type: ignore - # if the user provided a Literal type, wrap it in an object schema - elif get_origin(response_type) is Literal: - response_type = ScalarElicitationType[response_type] # type: ignore - # if the user provided an Enum type, wrap it in an object schema - elif isinstance(response_type, type) and issubclass(response_type, Enum): + elif ( + response_type in {bool, int, float, str} + or get_origin(response_type) is Literal + or (isinstance(response_type, type) and issubclass(response_type, Enum)) + ): response_type = ScalarElicitationType[response_type] # type: ignore response_type = cast(type[T], response_type) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 24b3c1c07..4a9481834 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from typing import TYPE_CHECKING from mcp.server.auth.middleware.auth_context import ( @@ -16,11 +17,11 @@ if TYPE_CHECKING: from fastmcp.server.context import Context __all__ = [ - "get_context", - "get_http_request", - "get_http_headers", - "get_access_token", "AccessToken", + "get_access_token", + "get_context", + "get_http_headers", + "get_http_request", ] @@ -43,10 +44,8 @@ def get_http_request() -> Request: from mcp.server.lowlevel.server import request_ctx request = None - try: + with contextlib.suppress(LookupError): request = request_ctx.get().request - except LookupError: - pass if request is None: raise RuntimeError("No active HTTP request found.") diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py index 25e96d44f..f5b1951d7 100644 --- a/src/fastmcp/server/elicitation.py +++ b/src/fastmcp/server/elicitation.py @@ -20,8 +20,8 @@ __all__ = [ "AcceptedElicitation", "CancelledElicitation", "DeclinedElicitation", - "get_elicitation_schema", "ScalarElicitationType", + "get_elicitation_schema", ] logger = get_logger(__name__) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 2ac186761..8e89650ca 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -342,9 +342,8 @@ def create_streamable_http_app( # Create a lifespan manager to start and stop the session manager @asynccontextmanager async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: - async with server._lifespan_manager(): - async with session_manager.run(): - yield + async with server._lifespan_manager(), session_manager.run(): + yield # Create and return the app with lifespan app = create_base_app( diff --git a/src/fastmcp/server/middleware/__init__.py b/src/fastmcp/server/middleware/__init__.py index 531142ae0..1e2035b21 100644 --- a/src/fastmcp/server/middleware/__init__.py +++ b/src/fastmcp/server/middleware/__init__.py @@ -5,7 +5,7 @@ from .middleware import ( ) __all__ = [ + "CallNext", "Middleware", "MiddlewareContext", - "CallNext", ] diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 133d6ca95..52540248e 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -46,7 +46,7 @@ class CachableReadResourceContents(BaseModel): @classmethod def get_sizes(cls, values: Sequence[Self]) -> int: - return sum([item.get_size() for item in values]) + return sum(item.get_size() for item in values) @classmethod def wrap(cls, values: Sequence[ReadResourceContents]) -> list[Self]: diff --git a/src/fastmcp/server/middleware/error_handling.py b/src/fastmcp/server/middleware/error_handling.py index 0e222cd7b..7cb730d90 100644 --- a/src/fastmcp/server/middleware/error_handling.py +++ b/src/fastmcp/server/middleware/error_handling.py @@ -64,7 +64,7 @@ class ErrorHandlingMiddleware(Middleware): error_key = f"{error_type}:{method}" self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 - base_message = f"Error in {method}: {error_type}: {str(error)}" + base_message = f"Error in {method}: {error_type}: {error!s}" if self.include_traceback: self.logger.error(f"{base_message}\n{traceback.format_exc()}") @@ -91,24 +91,24 @@ class ErrorHandlingMiddleware(Middleware): if error_type in (ValueError, TypeError): return McpError( - ErrorData(code=-32602, message=f"Invalid params: {str(error)}") + ErrorData(code=-32602, message=f"Invalid params: {error!s}") ) elif error_type in (FileNotFoundError, KeyError, NotFoundError): return McpError( - ErrorData(code=-32001, message=f"Resource not found: {str(error)}") + ErrorData(code=-32001, message=f"Resource not found: {error!s}") ) elif error_type is PermissionError: return McpError( - ErrorData(code=-32000, message=f"Permission denied: {str(error)}") + ErrorData(code=-32000, message=f"Permission denied: {error!s}") ) # asyncio.TimeoutError is a subclass of TimeoutError in Python 3.10, alias in 3.11+ elif error_type in (TimeoutError, asyncio.TimeoutError): return McpError( - ErrorData(code=-32000, message=f"Request timeout: {str(error)}") + ErrorData(code=-32000, message=f"Request timeout: {error!s}") ) else: return McpError( - ErrorData(code=-32603, message=f"Internal error: {str(error)}") + ErrorData(code=-32603, message=f"Internal error: {error!s}") ) async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: @@ -120,7 +120,7 @@ class ErrorHandlingMiddleware(Middleware): # Transform and re-raise transformed_error = self._transform_error(error) - raise transformed_error + raise transformed_error from error def get_error_stats(self) -> dict[str, int]: """Get error statistics for monitoring.""" @@ -200,7 +200,7 @@ class RetryMiddleware(Middleware): delay = self._calculate_delay(attempt) self.logger.warning( f"Request {context.method} failed (attempt {attempt + 1}/{self.max_retries + 1}): " - f"{type(error).__name__}: {str(error)}. Retrying in {delay:.1f}s..." + f"{type(error).__name__}: {error!s}. Retrying in {delay:.1f}s..." ) await anyio.sleep(delay) diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index 38b99b316..80ec3e73d 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -27,9 +27,9 @@ if TYPE_CHECKING: from fastmcp.server.context import Context __all__ = [ + "CallNext", "Middleware", "MiddlewareContext", - "CallNext", ] logger = logging.getLogger(__name__) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 3aa752abc..e23cdde49 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -513,11 +513,11 @@ class OpenAPITool(Tool): if e.response.text: error_message += f" - {e.response.text}" - raise ValueError(error_message) + raise ValueError(error_message) from e except httpx.RequestError as e: # Handle request errors (connection, timeout, etc.) - raise ValueError(f"Request error: {str(e)}") + raise ValueError(f"Request error: {e!s}") from e class OpenAPIResource(Resource): @@ -531,9 +531,11 @@ class OpenAPIResource(Resource): name: str, description: str, mime_type: str = "application/json", - tags: set[str] = set(), + tags: set[str] | None = None, timeout: float | None = None, ): + if tags is None: + tags = set() super().__init__( uri=AnyUrl(uri), # Convert string to AnyUrl name=name, @@ -632,11 +634,11 @@ class OpenAPIResource(Resource): if e.response.text: error_message += f" - {e.response.text}" - raise ValueError(error_message) + raise ValueError(error_message) from e except httpx.RequestError as e: # Handle request errors (connection, timeout, etc.) - raise ValueError(f"Request error: {str(e)}") + raise ValueError(f"Request error: {e!s}") from e class OpenAPIResourceTemplate(ResourceTemplate): @@ -650,9 +652,11 @@ class OpenAPIResourceTemplate(ResourceTemplate): name: str, description: str, parameters: dict[str, Any], - tags: set[str] = set(), + tags: set[str] | None = None, timeout: float | None = None, ): + if tags is None: + tags = set() super().__init__( uri_template=uri_template, name=name, diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 6847befcd..dc87e98b7 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -198,7 +198,9 @@ class ProxyResourceManager(ResourceManager, ProxyManagerMixin): elif isinstance(result[0], BlobResourceContents): return result[0].blob else: - raise ResourceError(f"Unsupported content type: {type(result[0])}") + raise ResourceError( + f"Unsupported content type: {type(result[0])}" + ) from None class ProxyPromptManager(PromptManager, ProxyManagerMixin): @@ -558,7 +560,7 @@ class ProxyClient(Client[ClientTransportT]): kwargs["log_handler"] = ProxyClient.default_log_handler if "progress_handler" not in kwargs: kwargs["progress_handler"] = ProxyClient.default_progress_handler - super().__init__(**kwargs | dict(transport=transport)) + super().__init__(**kwargs | {"transport": transport}) @classmethod async def default_sampling_handler( @@ -572,7 +574,7 @@ class ProxyClient(Client[ClientTransportT]): """ ctx = get_context() content = await ctx.sample( - [msg for msg in messages], + list(messages), system_prompt=params.systemPrompt, temperature=params.temperature, max_tokens=params.maxTokens, @@ -649,7 +651,6 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]): The stateful proxy client will be forced disconnected when the session is exited. So we do nothing here. """ - pass async def clear(self): """ diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 15dd1ac7b..1ce5fbf38 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -15,7 +15,11 @@ from collections.abc import ( Mapping, Sequence, ) -from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager +from contextlib import ( + AbstractAsyncContextManager, + AsyncExitStack, + asynccontextmanager, +) from dataclasses import dataclass from functools import partial from pathlib import Path @@ -150,7 +154,7 @@ class FastMCP(Generic[LifespanResultT]): version: str | None = None, website_url: str | None = None, icons: list[mcp.types.Icon] | None = None, - auth: AuthProvider | None | NotSetT = NotSet, + auth: AuthProvider | NotSetT | None = NotSet, middleware: Sequence[Middleware] | None = None, lifespan: LifespanCallable | None = None, dependencies: list[str] | None = None, @@ -1062,10 +1066,10 @@ class FastMCP(Generic[LifespanResultT]): try: result = await self._call_tool_middleware(key, arguments) return result.to_mcp_result() - except DisabledError: - raise NotFoundError(f"Unknown tool: {key}") - except NotFoundError: - raise NotFoundError(f"Unknown tool: {key}") + except DisabledError as e: + raise NotFoundError(f"Unknown tool: {key}") from e + except NotFoundError as e: + raise NotFoundError(f"Unknown tool: {key}") from e async def _call_tool_middleware( self, @@ -1142,12 +1146,12 @@ class FastMCP(Generic[LifespanResultT]): return list[ReadResourceContents]( await self._read_resource_middleware(uri) ) - except DisabledError: + except DisabledError as e: # convert to NotFoundError to avoid leaking resource presence - raise NotFoundError(f"Unknown resource: {str(uri)!r}") - except NotFoundError: + raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e + except NotFoundError as e: # standardize NotFound message - raise NotFoundError(f"Unknown resource: {str(uri)!r}") + raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e async def _read_resource_middleware( self, @@ -1158,10 +1162,7 @@ class FastMCP(Generic[LifespanResultT]): """ # Convert string URI to AnyUrl if needed - if isinstance(uri, str): - uri_param = AnyUrl(uri) - else: - uri_param = uri + uri_param = AnyUrl(uri) if isinstance(uri, str) else uri mw_context = MiddlewareContext( message=mcp.types.ReadResourceRequestParams(uri=uri_param), @@ -1241,12 +1242,12 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: return await self._get_prompt_middleware(name, arguments) - except DisabledError: + except DisabledError as e: # convert to NotFoundError to avoid leaking prompt presence - raise NotFoundError(f"Unknown prompt: {name}") - except NotFoundError: + raise NotFoundError(f"Unknown prompt: {name}") from e + except NotFoundError as e: # standardize NotFound message - raise NotFoundError(f"Unknown prompt: {name}") + raise NotFoundError(f"Unknown prompt: {name}") from e async def _get_prompt_middleware( self, name: str, arguments: dict[str, Any] | None = None @@ -1369,7 +1370,7 @@ class FastMCP(Generic[LifespanResultT]): description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | None | NotSetT = NotSet, + output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, @@ -1386,7 +1387,7 @@ class FastMCP(Generic[LifespanResultT]): description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | None | NotSetT = NotSet, + output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, @@ -1402,7 +1403,7 @@ class FastMCP(Generic[LifespanResultT]): description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, - output_schema: dict[str, Any] | None | NotSetT = NotSet, + output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, @@ -2029,14 +2030,14 @@ class FastMCP(Generic[LifespanResultT]): port=port, path=server_path, ) - _uvicorn_config_from_user = uvicorn_config or {} + uvicorn_config_from_user = uvicorn_config or {} config_kwargs: dict[str, Any] = { "timeout_graceful_shutdown": 0, "lifespan": "on", "ws": "websockets-sansio", } - config_kwargs.update(_uvicorn_config_from_user) + config_kwargs.update(uvicorn_config_from_user) if "log_config" not in config_kwargs and "log_level" not in config_kwargs: config_kwargs["log_level"] = default_log_level_to_use @@ -2605,8 +2606,8 @@ class FastMCP(Generic[LifespanResultT]): # - Connected clients: reuse existing session for all requests # - Disconnected clients: create fresh sessions per request for isolation if client.is_connected(): - _proxy_logger = get_logger(__name__) - _proxy_logger.info( + proxy_logger = get_logger(__name__) + proxy_logger.info( "Proxy detected connected client - reusing existing session for all requests. " "This may cause context mixing in concurrent scenarios." ) @@ -2678,10 +2679,7 @@ class FastMCP(Generic[LifespanResultT]): return False if self.include_tags is not None: - if any(itag in component.tags for itag in self.include_tags): - return True - else: - return False + return bool(any(itag in component.tags for itag in self.include_tags)) return True diff --git a/src/fastmcp/tools/__init__.py b/src/fastmcp/tools/__init__.py index 8fa723915..6406020dc 100644 --- a/src/fastmcp/tools/__init__.py +++ b/src/fastmcp/tools/__init__.py @@ -2,4 +2,4 @@ from .tool import Tool, FunctionTool from .tool_manager import ToolManager from .tool_transform import forward, forward_raw -__all__ = ["Tool", "ToolManager", "FunctionTool", "forward", "forward_raw"] +__all__ = ["FunctionTool", "Tool", "ToolManager", "forward", "forward_raw"] diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index b3bb67398..b58645579 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -173,7 +173,7 @@ class Tool(FastMCPComponent): tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, - output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet, + output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None, @@ -212,13 +212,13 @@ class Tool(FastMCPComponent): tool: Tool, *, name: str | None = None, - title: str | None | NotSetT = NotSet, - description: str | None | NotSetT = NotSet, + title: str | NotSetT | None = NotSet, + description: str | NotSetT | None = NotSet, tags: set[str] | None = None, - annotations: ToolAnnotations | None | NotSetT = NotSet, - output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet, + annotations: ToolAnnotations | NotSetT | None = NotSet, + output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, - meta: dict[str, Any] | None | NotSetT = NotSet, + meta: dict[str, Any] | NotSetT | None = NotSet, transform_args: dict[str, ArgTransform] | None = None, enabled: bool | None = None, transform_fn: Callable[..., Any] | None = None, @@ -255,7 +255,7 @@ class FunctionTool(Tool): tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, - output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet, + output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None, @@ -446,9 +446,8 @@ class ParsedFunction: # we ensure that no output schema is automatically generated. clean_output_type = replace_type( output_type, - { - t: _UnserializableType - for t in ( + dict.fromkeys( # type: ignore[arg-type] + ( Image, Audio, File, @@ -458,8 +457,9 @@ class ParsedFunction: mcp.types.AudioContent, mcp.types.ResourceLink, mcp.types.EmbeddedResource, - ) - }, + ), + _UnserializableType, + ), ) try: diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 0cc5ed960..efbd1fb8c 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -365,15 +365,15 @@ class TransformedTool(Tool): cls, tool: Tool, name: str | None = None, - title: str | None | NotSetT = NotSet, - description: str | None | NotSetT = NotSet, + title: str | NotSetT | None = NotSet, + description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, - annotations: ToolAnnotations | None | NotSetT = NotSet, - output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet, - serializer: Callable[[Any], str] | None | NotSetT = NotSet, - meta: dict[str, Any] | None | NotSetT = NotSet, + annotations: ToolAnnotations | NotSetT | None = NotSet, + output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet, + serializer: Callable[[Any], str] | NotSetT | None = NotSet, + meta: dict[str, Any] | NotSetT | None = NotSet, enabled: bool | None = None, ) -> TransformedTool: """Create a transformed tool from a parent tool. diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 09d619750..24a776616 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -240,12 +240,11 @@ def log_server_banner( info_table.add_row("📦", "Transport:", display_transport) # Show connection info based on transport - if transport in ("http", "streamable-http", "sse"): - if host and port: - server_url = f"http://{host}:{port}" - if path: - server_url += f"/{path.lstrip('/')}" - info_table.add_row("🔗", "Server URL:", server_url) + if transport in ("http", "streamable-http", "sse") and host and port: + server_url = f"http://{host}:{port}" + if path: + server_url += f"/{path.lstrip('/')}" + info_table.add_row("🔗", "Server URL:", server_url) # Add documentation link info_table.add_row("", "", "") diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index 047a9b55c..b4563090d 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -412,7 +412,7 @@ class InspectFormat(str, Enum): MCP = "mcp" -async def format_fastmcp_info(info: FastMCPInfo) -> bytes: +def format_fastmcp_info(info: FastMCPInfo) -> bytes: """Format FastMCPInfo as FastMCP-specific JSON. This includes FastMCP-specific fields like tags, enabled, annotations, etc. @@ -501,6 +501,6 @@ async def format_info( # This works for both v1 and v2 servers if info is None: info = await inspect_fastmcp(mcp) - return await format_fastmcp_info(info) + return format_fastmcp_info(info) else: raise ValueError(f"Unknown format: {format}") diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index b6ba9266a..f10c6798e 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -61,7 +61,7 @@ from pydantic import ( ) from typing_extensions import NotRequired, TypedDict -__all__ = ["json_schema_to_type", "JSONSchema"] +__all__ = ["JSONSchema", "json_schema_to_type"] FORMAT_TYPES: dict[str, Any] = { @@ -368,7 +368,7 @@ def _schema_to_type( return types[0] else: if has_null: - return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007 + return Union[(*types, type(None))] # type: ignore else: return Union[tuple(types)] # type: ignore # noqa: UP007 @@ -389,7 +389,7 @@ def _schema_to_type( if len(types) == 1: return types[0] | None # type: ignore else: - return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007 + return Union[(*types, type(None))] # type: ignore return Union[tuple(types)] # type: ignore # noqa: UP007 return _get_from_type_handler(schema, schemas)(schema) @@ -578,7 +578,7 @@ def _create_dataclass( return _merge_defaults(data, original_schema) return data - setattr(cls, "_apply_defaults", _apply_defaults) + cls._apply_defaults = _apply_defaults # type: ignore[attr-defined] # Store completed class _classes[cache_key] = cls diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py index b6c83fa4a..e2361bb03 100644 --- a/src/fastmcp/utilities/logging.py +++ b/src/fastmcp/utilities/logging.py @@ -147,6 +147,18 @@ def temporary_log_level( yield +_level_to_no: dict[ + Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, int | None +] = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + None: None, +} + + class _ClampedLogFilter(logging.Filter): min_level: tuple[int, str] | None max_level: tuple[int, str] | None @@ -161,29 +173,13 @@ class _ClampedLogFilter(logging.Filter): self.min_level = None self.max_level = None - if min_level_no := self._level_to_no(level=min_level): + if min_level_no := _level_to_no.get(min_level): self.min_level = (min_level_no, str(min_level)) - if max_level_no := self._level_to_no(level=max_level): + if max_level_no := _level_to_no.get(max_level): self.max_level = (max_level_no, str(max_level)) super().__init__() - def _level_to_no( - self, level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None - ) -> int | None: - if level == "DEBUG": - return logging.DEBUG - elif level == "INFO": - return logging.INFO - elif level == "WARNING": - return logging.WARNING - elif level == "ERROR": - return logging.ERROR - elif level == "CRITICAL": - return logging.CRITICAL - else: - return None - @override def filter(self, record: logging.LogRecord) -> bool: if self.max_level: diff --git a/src/fastmcp/utilities/mcp_server_config/__init__.py b/src/fastmcp/utilities/mcp_server_config/__init__.py index cbbfe5aa3..6cdfadcc5 100644 --- a/src/fastmcp/utilities/mcp_server_config/__init__.py +++ b/src/fastmcp/utilities/mcp_server_config/__init__.py @@ -15,11 +15,11 @@ from fastmcp.utilities.mcp_server_config.v1.sources.base import Source from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource __all__ = [ - "Source", "Deployment", "Environment", - "UVEnvironment", - "MCPServerConfig", "FileSystemSource", + "MCPServerConfig", + "Source", + "UVEnvironment", "generate_schema", ] diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py index 8209c7f4f..0d1b1f8b1 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py @@ -19,7 +19,6 @@ class Environment(BaseModel, ABC): Returns: Full command ready for subprocess execution """ - pass async def prepare(self, output_dir: Path | None = None) -> None: """Prepare the environment (optional, can be no-op). @@ -27,4 +26,4 @@ class Environment(BaseModel, ABC): Args: output_dir: Directory for persistent environment setup """ - pass # Default no-op implementation + # Default no-op implementation diff --git a/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py index fa6509353..cc1e9412b 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py @@ -17,7 +17,6 @@ class Source(BaseModel, ABC): need preparation (e.g., local files), this is a no-op. """ # Default implementation for sources that don't need preparation - pass @abstractmethod async def load_server(self) -> Any: diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index d0e8ae90b..0cb242eb7 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -175,16 +175,16 @@ class HTTPRoute(FastMCPBaseModel): # Export public symbols __all__ = [ "HTTPRoute", + "HttpMethod", + "JsonSchema", "ParameterInfo", + "ParameterLocation", "RequestBodyInfo", "ResponseInfo", - "HttpMethod", - "ParameterLocation", - "JsonSchema", - "parse_openapi_to_http_routes", + "_handle_nullable_fields", "extract_output_schema_from_responses", "format_deep_object_parameter", - "_handle_nullable_fields", + "parse_openapi_to_http_routes", ] # Type variables for generic parser @@ -321,7 +321,7 @@ class OpenAPIParser( else: # Special handling for components if part == "components" and hasattr(target, "components"): - target = getattr(target, "components") + target = target.components elif hasattr(target, part): # Fallback check target = getattr(target, part, None) else: @@ -1178,10 +1178,10 @@ def _add_null_to_type(schema: dict[str, Any]) -> None: elif isinstance(current_type, list): # Add null to array if not already present if "null" not in current_type: - schema["type"] = current_type + ["null"] + schema["type"] = [*current_type, "null"] elif "oneOf" in schema: # Convert oneOf to anyOf with null type - schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}] + schema["anyOf"] = [*schema.pop("oneOf"), {"type": "null"}] elif "anyOf" in schema: # Add null type to anyOf if not already present if not any(item.get("type") == "null" for item in schema["anyOf"]): @@ -1233,7 +1233,7 @@ def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | An # Handle properties nullable fields if has_property_nullable_field and "properties" in result: - for prop_name, prop_schema in result["properties"].items(): + for _prop_name, prop_schema in result["properties"].items(): if isinstance(prop_schema, dict) and "nullable" in prop_schema: nullable_value = prop_schema.pop("nullable") if nullable_value and ( diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 19d278b41..1b3159aad 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -6,7 +6,7 @@ import multiprocessing import socket import time from collections.abc import AsyncGenerator, Callable, Generator -from contextlib import asynccontextmanager, contextmanager +from contextlib import asynccontextmanager, contextmanager, suppress from typing import TYPE_CHECKING, Any, Literal from urllib.parse import parse_qs, urlparse @@ -216,10 +216,8 @@ async def run_server_async( finally: # Cleanup: cancel the task server_task.cancel() - try: + with suppress(asyncio.CancelledError): await server_task - except asyncio.CancelledError: - pass @contextmanager diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index a41ce6ccc..6bbdab6fc 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -190,34 +190,33 @@ class Image: if path is not None and data is not None: raise ValueError("Only one of path or data can be provided") - self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None + self.path = self._get_expanded_path(path) self.data = data self._format = format self._mime_type = self._get_mime_type() self.annotations = annotations + @staticmethod + def _get_expanded_path(path: str | Path | None) -> Path | None: + """Expand environment variables and user home in path.""" + return Path(os.path.expandvars(str(path))).expanduser() if path else None + def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" if self._format: return f"image/{self._format.lower()}" if self.path: - suffix = self.path.suffix.lower() - return { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - }.get(suffix, "application/octet-stream") + # Workaround for WEBP in Py3.10 + mimetypes.add_type("image/webp", ".webp") + resp = mimetypes.guess_type(self.path, strict=False) + if resp and resp[0] is not None: + return resp[0] + return "application/octet-stream" return "image/png" # default for raw binary data - def to_image_content( - self, - mime_type: str | None = None, - annotations: Annotations | None = None, - ) -> mcp.types.ImageContent: - """Convert to MCP ImageContent.""" + def _get_data(self) -> str: + """Get raw image data as base64-encoded string.""" if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() @@ -225,6 +224,15 @@ class Image: data = base64.b64encode(self.data).decode() else: raise ValueError("No image data available") + return data + + def to_image_content( + self, + mime_type: str | None = None, + annotations: Annotations | None = None, + ) -> mcp.types.ImageContent: + """Convert to MCP ImageContent.""" + data = self._get_data() return mcp.types.ImageContent( type="image", @@ -233,6 +241,11 @@ class Image: annotations=annotations or self.annotations, ) + def to_data_uri(self, mime_type: str | None = None) -> str: + """Get image as a data URI.""" + data = self._get_data() + return f"data:{mime_type or self._mime_type};base64,{data}" + class Audio: """Helper class for returning audio from tools.""" diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 914b505a2..7110d5cfd 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -94,10 +94,13 @@ async def nested_sse_server(): from starlette.applications import Starlette from starlette.routing import Mount + from fastmcp.server.http import create_sse_app from fastmcp.utilities.http import find_available_port server = create_test_server() - sse_app = server.sse_app(path="/mcp/sse/", message_path="/mcp/messages") + sse_app = create_sse_app( + server=server, message_path="/mcp/messages", sse_path="/mcp/sse/" + ) # Nest the app under multiple mounts to test URL resolution inner = Starlette(routes=[Mount("/nest-inner", app=sse_app)]) diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index 95a050884..c895cf739 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -567,6 +567,112 @@ class TestCustomResourceKeys: await manager.get_resource("greet://world") +class TestQueryOnlyTemplates: + """Test resource templates with only query parameters (no path params).""" + + async def test_template_with_only_query_params_no_query_string(self): + """Test that templates with only query params work without query string. + + Regression test for bug where empty parameter dict {} was treated as falsy, + causing templates with only query parameters to fail when no query string + was provided in the URI. + """ + manager = ResourceManager() + + def get_config(format: str = "json") -> str: + return f"Config in {format} format" + + template = ResourceTemplate.from_function( + fn=get_config, + uri_template="data://config{?format}", + name="config", + ) + manager.add_template(template) + + # Should work without query param (uses default) + resource = await manager.get_resource("data://config") + content = await resource.read() + assert content == "Config in json format" + + # Should also work via read_resource + content = await manager.read_resource("data://config") + assert content == "Config in json format" + + async def test_template_with_only_query_params_with_query_string(self): + """Test that templates with only query params work with query string.""" + manager = ResourceManager() + + def get_config(format: str = "json") -> str: + return f"Config in {format} format" + + template = ResourceTemplate.from_function( + fn=get_config, + uri_template="data://config{?format}", + name="config", + ) + manager.add_template(template) + + # Should work with query param (overrides default) + resource = await manager.get_resource("data://config?format=xml") + content = await resource.read() + assert content == "Config in xml format" + + # Should also work via read_resource + content = await manager.read_resource("data://config?format=xml") + assert content == "Config in xml format" + + async def test_template_with_only_multiple_query_params(self): + """Test template with only multiple query parameters.""" + manager = ResourceManager() + + def get_data(format: str = "json", limit: int = 10) -> str: + return f"Data in {format} (limit: {limit})" + + template = ResourceTemplate.from_function( + fn=get_data, + uri_template="data://items{?format,limit}", + name="items", + ) + manager.add_template(template) + + # No query params - use all defaults + content = await manager.read_resource("data://items") + assert content == "Data in json (limit: 10)" + + # Partial query params + content = await manager.read_resource("data://items?format=xml") + assert content == "Data in xml (limit: 10)" + + # All query params + content = await manager.read_resource("data://items?format=xml&limit=20") + assert content == "Data in xml (limit: 20)" + + async def test_has_resource_with_query_only_template(self): + """Test that has_resource() works with query-only templates. + + Regression test for bug where empty parameter dict {} was treated as falsy, + causing has_resource() to return False for query-only templates when no + query string was provided. + """ + manager = ResourceManager() + + def get_config(format: str = "json") -> str: + return f"Config in {format} format" + + template = ResourceTemplate.from_function( + fn=get_config, + uri_template="data://config{?format}", + name="config", + ) + manager.add_template(template) + + # Should find resource without query param (uses default) + assert await manager.has_resource("data://config") + + # Should also find resource with query param + assert await manager.has_resource("data://config?format=xml") + + class TestResourceErrorHandling: """Test error handling in the ResourceManager.""" diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 95d0eb754..168384eb0 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -10,7 +10,6 @@ from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl from fastmcp.server.auth.providers.azure import AzureProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier class TestAzureProvider: @@ -61,10 +60,11 @@ class TestAzureProvider: assert provider._upstream_client_id == "env-client-id" assert provider._upstream_client_secret.get_secret_value() == "env-secret" assert str(provider.base_url) == "https://envserver.com/" - # Scopes should be prefixed with identifier_uri in token validator + # Scopes are stored unprefixed for token validation + # (Azure returns unprefixed scopes in JWT tokens) assert provider._token_validator.required_scopes == [ - "api://env-client-id/read", - "api://env-client-id/write", + "read", + "write", ] # Check tenant is in the endpoints parsed_auth = urlparse(provider._upstream_authorization_endpoint) @@ -74,27 +74,63 @@ class TestAzureProvider: def test_init_missing_client_id_raises_error(self): """Test that missing client_id raises ValueError.""" - with pytest.raises(ValueError, match="client_id is required"): - AzureProvider( - client_secret="test_secret", - tenant_id="test-tenant", - ) + # Clear environment variables to ensure we're testing the parameter validation + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="client_id is required"): + AzureProvider( + client_secret="test_secret", + tenant_id="test-tenant", + required_scopes=["read"], + ) def test_init_missing_client_secret_raises_error(self): """Test that missing client_secret raises ValueError.""" - with pytest.raises(ValueError, match="client_secret is required"): - AzureProvider( - client_id="test_client", - tenant_id="test-tenant", - ) + # Clear environment variables to ensure we're testing the parameter validation + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="client_secret is required"): + AzureProvider( + client_id="test_client", + tenant_id="test-tenant", + required_scopes=["read"], + ) def test_init_missing_tenant_id_raises_error(self): """Test that missing tenant_id raises ValueError.""" - with pytest.raises(ValueError, match="tenant_id is required"): - AzureProvider( - client_id="test_client", - client_secret="test_secret", - ) + # Clear environment variables to ensure we're testing the parameter validation + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="tenant_id is required"): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + required_scopes=["read"], + ) + + def test_init_missing_required_scopes_raises_error(self): + """Test that missing required_scopes raises ValueError.""" + # Clear environment variables to ensure we're testing the parameter validation + with patch.dict(os.environ, {}, clear=True): + with pytest.raises( + ValueError, match="required_scopes must include at least one scope" + ): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + ) + + def test_init_empty_required_scopes_raises_error(self): + """Test that empty required_scopes raises ValueError.""" + # Clear environment variables to ensure we're testing the parameter validation + with patch.dict(os.environ, {}, clear=True): + with pytest.raises( + ValueError, match="required_scopes must include at least one scope" + ): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + required_scopes=[], + ) def test_init_defaults(self): """Test that default values are applied correctly.""" @@ -176,11 +212,12 @@ class TestAzureProvider: # Provider should initialize successfully with these scopes assert provider is not None - # Scopes should be prefixed in token validator + # Scopes are stored unprefixed for token validation + # (Azure returns unprefixed scopes in JWT tokens) assert provider._token_validator.required_scopes == [ - "api://test_client/read", - "api://test_client/write", - "api://test_client/admin", + "read", + "write", + "admin", ] def test_init_does_not_require_api_client_id_anymore(self): @@ -196,6 +233,8 @@ class TestAzureProvider: def test_init_with_custom_audience_uses_jwt_verifier(self): """When audience is provided, JWTVerifier is configured with JWKS and issuer.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier + provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -214,11 +253,12 @@ class TestAzureProvider: ) assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0" assert verifier.audience == "test_client" - # Scopes should be prefixed with identifier_uri - assert verifier.required_scopes == ["api://my-api/.default"] + # Scopes are stored unprefixed for token validation + # (Azure returns unprefixed scopes like ".default" in JWT tokens) + assert verifier.required_scopes == [".default"] - async def test_authorize_filters_resource_and_accepts_prefixed_scopes(self): - """authorize() should drop resource parameter and accept prefixed scopes from clients.""" + async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self): + """authorize() should drop resource parameter and store unprefixed scopes for MCP clients.""" provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -247,9 +287,9 @@ class TestAzureProvider: redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, scopes=[ - "api://my-api/read", - "api://my-api/profile", - ], # Client sends prefixed scopes from PRM + "read", + "profile", + ], # Client sends unprefixed scopes (from PRM which advertises unprefixed) state="abc", code_challenge="xyz", resource="https://should.be.ignored", @@ -263,14 +303,27 @@ class TestAzureProvider: assert "txn_id" in qs, "Should redirect to consent page with transaction ID" txn_id = qs["txn_id"][0] - # Verify transaction contains correct parameters (resource filtered, scopes prefixed) + # Verify transaction stores UNPREFIXED scopes for MCP clients transaction = await provider._transaction_store.get(key=txn_id) assert transaction is not None - assert "api://my-api/read" in transaction.scopes - assert "api://my-api/profile" in transaction.scopes + assert "read" in transaction.scopes + assert "profile" in transaction.scopes # Azure provider filters resource parameter (not stored in transaction) assert transaction.resource is None + # Verify the upstream Azure URL will have PREFIXED scopes + upstream_url = provider._build_upstream_authorize_url( + txn_id, transaction.model_dump() + ) + assert ( + "api%3A%2F%2Fmy-api%2Fread" in upstream_url + or "api://my-api/read" in upstream_url + ) + assert ( + "api%3A%2F%2Fmy-api%2Fprofile" in upstream_url + or "api://my-api/profile" in upstream_url + ) + async def test_authorize_appends_additional_scopes(self): """authorize() should append additional_authorize_scopes to the authorization request.""" provider = AzureProvider( @@ -301,7 +354,7 @@ class TestAzureProvider: params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, - scopes=["api://my-api/read"], # Client sends prefixed scopes from PRM + scopes=["read"], # Client sends unprefixed scopes state="abc", code_challenge="xyz", ) @@ -314,9 +367,123 @@ class TestAzureProvider: assert "txn_id" in qs, "Should redirect to consent page with transaction ID" txn_id = qs["txn_id"][0] - # Verify transaction contains correct scopes (prefixed + unprefixed additional) + # Verify transaction stores ONLY MCP scopes (unprefixed) + # additional_authorize_scopes are NOT stored in transaction transaction = await provider._transaction_store.get(key=txn_id) assert transaction is not None - assert "api://my-api/read" in transaction.scopes - assert "Mail.Read" in transaction.scopes - assert "User.Read" in transaction.scopes + assert "read" in transaction.scopes + assert "Mail.Read" not in transaction.scopes # Not in transaction + assert "User.Read" not in transaction.scopes # Not in transaction + + # Verify upstream URL includes both MCP scopes (prefixed) AND additional Graph scopes + upstream_url = provider._build_upstream_authorize_url( + txn_id, transaction.model_dump() + ) + assert ( + "api%3A%2F%2Fmy-api%2Fread" in upstream_url + or "api://my-api/read" in upstream_url + ) + assert "Mail.Read" in upstream_url + assert "User.Read" in upstream_url + + def test_base_authority_defaults_to_public_cloud(self): + """Test that base_authority defaults to login.microsoftonline.com.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + assert ( + provider._upstream_authorization_endpoint + == "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize" + ) + assert ( + provider._upstream_token_endpoint + == "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token" + ) + assert ( + provider._token_validator.issuer # type: ignore[attr-defined] + == "https://login.microsoftonline.com/test-tenant/v2.0" + ) + assert ( + provider._token_validator.jwks_uri # type: ignore[attr-defined] + == "https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys" + ) + + def test_base_authority_azure_government(self): + """Test Azure Government endpoints with login.microsoftonline.us.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="gov-tenant-id", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + assert ( + provider._upstream_authorization_endpoint + == "https://login.microsoftonline.us/gov-tenant-id/oauth2/v2.0/authorize" + ) + assert ( + provider._upstream_token_endpoint + == "https://login.microsoftonline.us/gov-tenant-id/oauth2/v2.0/token" + ) + assert ( + provider._token_validator.issuer # type: ignore[attr-defined] + == "https://login.microsoftonline.us/gov-tenant-id/v2.0" + ) + assert ( + provider._token_validator.jwks_uri # type: ignore[attr-defined] + == "https://login.microsoftonline.us/gov-tenant-id/discovery/v2.0/keys" + ) + + def test_base_authority_from_environment_variable(self): + """Test that base_authority can be set via environment variable.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID": "env-client-id", + "FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET": "env-secret", + "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID": "env-tenant-id", + "FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES": "read", + "FASTMCP_SERVER_AUTH_AZURE_BASE_AUTHORITY": "login.microsoftonline.us", + "FASTMCP_SERVER_AUTH_AZURE_JWT_SIGNING_KEY": "test-secret", + }, + ): + provider = AzureProvider() + + assert ( + provider._upstream_authorization_endpoint + == "https://login.microsoftonline.us/env-tenant-id/oauth2/v2.0/authorize" + ) + assert ( + provider._upstream_token_endpoint + == "https://login.microsoftonline.us/env-tenant-id/oauth2/v2.0/token" + ) + assert ( + provider._token_validator.issuer # type: ignore[attr-defined] + == "https://login.microsoftonline.us/env-tenant-id/v2.0" + ) + assert ( + provider._token_validator.jwks_uri # type: ignore[attr-defined] + == "https://login.microsoftonline.us/env-tenant-id/discovery/v2.0/keys" + ) + + def test_base_authority_with_special_tenant_values(self): + """Test that base_authority works with special tenant values like 'organizations'.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="organizations", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + parsed = urlparse(provider._upstream_authorization_endpoint) + assert parsed.netloc == "login.microsoftonline.us" + assert "/organizations/" in parsed.path diff --git a/tests/server/auth/test_debug_verifier.py b/tests/server/auth/test_debug_verifier.py new file mode 100644 index 000000000..4cf2c6efb --- /dev/null +++ b/tests/server/auth/test_debug_verifier.py @@ -0,0 +1,169 @@ +"""Unit tests for DebugTokenVerifier.""" + +import re + +from fastmcp.server.auth.providers.debug import DebugTokenVerifier + + +class TestDebugTokenVerifier: + """Test DebugTokenVerifier initialization and validation.""" + + def test_init_defaults(self): + """Test initialization with default parameters.""" + verifier = DebugTokenVerifier() + + assert verifier.client_id == "debug-client" + assert verifier.scopes == [] + assert verifier.required_scopes == [] + assert callable(verifier.validate) + + def test_init_custom_parameters(self): + """Test initialization with custom parameters.""" + verifier = DebugTokenVerifier( + validate=lambda t: t.startswith("valid-"), + client_id="custom-client", + scopes=["read", "write"], + required_scopes=["admin"], + ) + + assert verifier.client_id == "custom-client" + assert verifier.scopes == ["read", "write"] + assert verifier.required_scopes == ["admin"] + + async def test_verify_token_default_accepts_all(self): + """Test that default verifier accepts all non-empty tokens.""" + verifier = DebugTokenVerifier() + + result = await verifier.verify_token("any-token") + + assert result is not None + assert result.token == "any-token" + assert result.client_id == "debug-client" + assert result.scopes == [] + assert result.expires_at is None + assert result.claims == {"token": "any-token"} + + async def test_verify_token_rejects_empty(self): + """Test that empty tokens are rejected even with default verifier.""" + verifier = DebugTokenVerifier() + + # Empty string + assert await verifier.verify_token("") is None + + # Whitespace only + assert await verifier.verify_token(" ") is None + + async def test_verify_token_sync_callable_success(self): + """Test token verification with custom sync callable that passes.""" + verifier = DebugTokenVerifier( + validate=lambda t: t.startswith("valid-"), + client_id="test-client", + scopes=["read"], + ) + + result = await verifier.verify_token("valid-token-123") + + assert result is not None + assert result.token == "valid-token-123" + assert result.client_id == "test-client" + assert result.scopes == ["read"] + assert result.expires_at is None + assert result.claims == {"token": "valid-token-123"} + + async def test_verify_token_sync_callable_failure(self): + """Test token verification with custom sync callable that fails.""" + verifier = DebugTokenVerifier(validate=lambda t: t.startswith("valid-")) + + result = await verifier.verify_token("invalid-token") + + assert result is None + + async def test_verify_token_async_callable_success(self): + """Test token verification with custom async callable that passes.""" + + async def async_validator(token: str) -> bool: + # Simulate async operation (e.g., database check) + return token in {"token1", "token2", "token3"} + + verifier = DebugTokenVerifier( + validate=async_validator, + client_id="async-client", + scopes=["admin"], + ) + + result = await verifier.verify_token("token2") + + assert result is not None + assert result.token == "token2" + assert result.client_id == "async-client" + assert result.scopes == ["admin"] + + async def test_verify_token_async_callable_failure(self): + """Test token verification with custom async callable that fails.""" + + async def async_validator(token: str) -> bool: + return token in {"token1", "token2", "token3"} + + verifier = DebugTokenVerifier(validate=async_validator) + + result = await verifier.verify_token("token99") + + assert result is None + + async def test_verify_token_callable_exception(self): + """Test that exceptions in validate callable are handled gracefully.""" + + def failing_validator(token: str) -> bool: + raise ValueError("Something went wrong") + + verifier = DebugTokenVerifier(validate=failing_validator) + + result = await verifier.verify_token("any-token") + + assert result is None + + async def test_verify_token_async_callable_exception(self): + """Test that exceptions in async validate callable are handled gracefully.""" + + async def failing_async_validator(token: str) -> bool: + raise ValueError("Async validation failed") + + verifier = DebugTokenVerifier(validate=failing_async_validator) + + result = await verifier.verify_token("any-token") + + assert result is None + + async def test_verify_token_whitelist_pattern(self): + """Test using verifier with a whitelist of allowed tokens.""" + allowed_tokens = {"secret-token-1", "secret-token-2", "admin-token"} + + verifier = DebugTokenVerifier(validate=lambda t: t in allowed_tokens) + + # Allowed tokens + assert await verifier.verify_token("secret-token-1") is not None + assert await verifier.verify_token("admin-token") is not None + + # Disallowed tokens + assert await verifier.verify_token("unknown-token") is None + assert await verifier.verify_token("hacker-token") is None + + async def test_verify_token_pattern_matching(self): + """Test using verifier with regex-like pattern matching.""" + + pattern = re.compile(r"^[A-Z]{3}-\d{4}-[a-z]{2}$") + + verifier = DebugTokenVerifier( + validate=lambda t: bool(pattern.match(t)), + client_id="pattern-client", + ) + + # Valid patterns + result = await verifier.verify_token("ABC-1234-xy") + assert result is not None + assert result.client_id == "pattern-client" + + # Invalid patterns + assert await verifier.verify_token("abc-1234-xy") is None # Wrong case + assert await verifier.verify_token("ABC-123-xy") is None # Wrong digits + assert await verifier.verify_token("ABC-1234-xyz") is None # Too many chars diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 773d365e9..6f79bacb4 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -763,6 +763,29 @@ class TestBearerToken: access_token3 = await provider.load_access_token(token3) assert access_token3 is None + @pytest.mark.parametrize( + ("iss", "expected"), + [ + ("https://test.example.com", True), + ("https://other-issuer.example.com", True), + ("https://wrong-issuer.example.com", False), + ], + ) + async def test_provider_with_multiple_expected_issuers( + self, rsa_key_pair: RSAKeyPair, iss: str, expected: bool + ): + """Provider accepts any issuer from the configured list.""" + provider = JWTVerifier( + public_key=rsa_key_pair.public_key, + issuer=["https://test.example.com", "https://other-issuer.example.com"], + audience="https://api.example.com", + ) + token = rsa_key_pair.create_token( + subject="test-user", issuer=iss, audience="https://api.example.com" + ) + access_token = await provider.load_access_token(token) + assert (access_token is not None) is expected + async def test_scope_extraction_string( self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier ): diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index e3bc09d2d..c4c7140e1 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -1309,3 +1309,103 @@ class TestTokenHandlerErrorTransformation: # Should pass through unchanged assert response.status_code == 400 assert b'"error":"invalid_grant"' in response.body + + +class TestErrorPageRendering: + """Test error page rendering for OAuth callback errors.""" + + def test_create_error_html_basic(self): + """Test basic error page generation.""" + from fastmcp.server.auth.oauth_proxy import create_error_html + + html = create_error_html( + error_title="Test Error", + error_message="This is a test error message", + ) + + # Verify it's valid HTML + assert "" in html + assert "Test Error" in html + assert "This is a test error message" in html + assert 'class="info-box error"' in html + + def test_create_error_html_with_details(self): + """Test error page with error details.""" + from fastmcp.server.auth.oauth_proxy import create_error_html + + html = create_error_html( + error_title="OAuth Error", + error_message="Authentication failed", + error_details={ + "Error Code": "invalid_scope", + "Description": "Requested scope does not exist", + }, + ) + + # Verify error details are included + assert "Error Details" in html + assert "Error Code" in html + assert "invalid_scope" in html + assert "Description" in html + assert "Requested scope does not exist" in html + + def test_create_error_html_escapes_user_input(self): + """Test that error page properly escapes HTML in user input.""" + from fastmcp.server.auth.oauth_proxy import create_error_html + + html = create_error_html( + error_title="Error ", + error_message="Message with HTML tags", + error_details={"Key" not in html + assert "<script>" in html + assert "HTML" not in html + assert "<b>HTML</b>" in html + + async def test_callback_error_returns_html_page(self): + """Test that OAuth callback errors return styled HTML instead of data: URLs.""" + from unittest.mock import Mock + + from starlette.requests import Request + from starlette.responses import HTMLResponse + + from fastmcp.server.auth.oauth_proxy import OAuthProxy + from fastmcp.server.auth.providers.jwt import JWTVerifier + + # Create a minimal OAuth proxy + provider = OAuthProxy( + upstream_authorization_endpoint="https://idp.example.com/authorize", + upstream_token_endpoint="https://idp.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=JWTVerifier( + jwks_uri="https://idp.example.com/.well-known/jwks.json", + issuer="https://idp.example.com", + audience="test-client", + ), + base_url="http://localhost:8000", + jwt_signing_key="test-signing-key", + ) + + # Mock a request with an error from the IdP + mock_request = Mock(spec=Request) + mock_request.query_params = { + "error": "invalid_scope", + "error_description": "The application asked for scope 'read' that doesn't exist", + "state": "test-state", + } + + # Call the callback handler + response = await provider._handle_idp_callback(mock_request) + + # Verify we get an HTMLResponse, not a RedirectResponse + assert isinstance(response, HTMLResponse) + assert response.status_code == 400 + + # Verify the response contains the error message + assert b"invalid_scope" in response.body + assert b"doesn't exist" in response.body # HTML-escaped apostrophe + assert b"OAuth Error" in response.body diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index 7608d8b6c..319751bc9 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -8,6 +8,7 @@ from httpx import Response from pydantic import AnyHttpUrl from fastmcp.server.auth.oidc_proxy import OIDCConfiguration, OIDCProxy +from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier TEST_ISSUER = "https://example.com" @@ -649,3 +650,136 @@ class TestOIDCProxyInitialization: client_secret=TEST_CLIENT_SECRET, base_url=None, # type: ignore ) + + def test_custom_token_verifier_initialization(self, valid_oidc_configuration_dict): + """Test initialization with custom token verifier.""" + 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 + + # Create custom verifier for opaque tokens + custom_verifier = IntrospectionTokenVerifier( + introspection_url="https://example.com/oauth/introspect", + client_id="introspection-client", + client_secret="introspection-secret", + required_scopes=["custom", "scopes"], + ) + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + token_verifier=custom_verifier, + jwt_signing_key="test-secret", + ) + + validate_proxy(mock_get, proxy, oidc_config) + + # Verify the custom verifier is used + assert proxy._token_validator is custom_verifier + assert isinstance(proxy._token_validator, IntrospectionTokenVerifier) + + # Verify required_scopes are properly loaded from the custom verifier + assert proxy.required_scopes == ["custom", "scopes"] + + def test_custom_token_verifier_with_algorithm_raises_error( + self, valid_oidc_configuration_dict + ): + """Test that providing algorithm with custom verifier raises error.""" + 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 + + custom_verifier = IntrospectionTokenVerifier( + introspection_url="https://example.com/oauth/introspect", + client_id="introspection-client", + client_secret="introspection-secret", + ) + + with pytest.raises( + ValueError, + match="Cannot specify 'algorithm' when providing a custom token_verifier", + ): + OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + token_verifier=custom_verifier, + algorithm="RS256", # This should cause an error + jwt_signing_key="test-secret", + ) + + def test_custom_token_verifier_with_required_scopes_raises_error( + self, valid_oidc_configuration_dict + ): + """Test that providing required_scopes with custom verifier raises error.""" + 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 + + custom_verifier = IntrospectionTokenVerifier( + introspection_url="https://example.com/oauth/introspect", + client_id="introspection-client", + client_secret="introspection-secret", + ) + + with pytest.raises( + ValueError, + match="Cannot specify 'required_scopes' when providing a custom token_verifier", + ): + OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + token_verifier=custom_verifier, + required_scopes=["read", "write"], # This should cause an error + jwt_signing_key="test-secret", + ) + + def test_custom_token_verifier_with_audience_allowed( + self, valid_oidc_configuration_dict + ): + """Test that providing audience with custom verifier is allowed (for OAuth flow).""" + 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 + + custom_verifier = IntrospectionTokenVerifier( + introspection_url="https://example.com/oauth/introspect", + client_id="introspection-client", + client_secret="introspection-secret", + ) + + # This should NOT raise an error - audience is for OAuth flow + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + token_verifier=custom_verifier, + audience="test-audience", # Should be allowed for OAuth flow + jwt_signing_key="test-secret", + ) + + validate_proxy(mock_get, proxy, oidc_config) + assert proxy._extra_authorize_params == {"audience": "test-audience"} + assert proxy._extra_token_params == {"audience": "test-audience"} diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index d0cac215e..a21b53a98 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -636,6 +636,7 @@ async def test_canonical_multi_client_with_transforms(tmp_path: Path): assert "test_1_transformed_add" not in tools_by_name +@pytest.mark.flaky(retries=3) async def test_multi_client_transform_with_filtering(tmp_path: Path): """ Tests that tag-based filtering works when using a transforming MCPConfig. diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index d03305c65..239a6600d 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -887,7 +887,7 @@ class TestIconExtraction: return "icon" info = await inspect_fastmcp(mcp) - json_bytes = await format_fastmcp_info(info) + json_bytes = format_fastmcp_info(info) import json @@ -915,7 +915,7 @@ class TestIconExtraction: return "none" info = await inspect_fastmcp(mcp) - json_bytes = await format_fastmcp_info(info) + json_bytes = format_fastmcp_info(info) import json @@ -945,7 +945,7 @@ class TestFormatFunctions: return {"result": x * 2} info = await inspect_fastmcp(mcp) - json_bytes = await format_fastmcp_info(info) + json_bytes = format_fastmcp_info(info) # Verify it's valid JSON import json @@ -1104,7 +1104,7 @@ class TestFormatFunctions: assert "result" in info.tools[0].output_schema["properties"] # Verify it's included in FastMCP format - json_bytes = await format_fastmcp_info(info) + json_bytes = format_fastmcp_info(info) import json data = json.loads(json_bytes) diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 926848b53..0758a1dbe 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -177,22 +177,23 @@ class TestImage: ): Image(path="test.png", data=b"test") - def test_get_mime_type_from_path(self, tmp_path): + @pytest.mark.parametrize( + "extension,mime_type", + [ + (".png", "image/png"), + (".jpg", "image/jpeg"), + (".jpeg", "image/jpeg"), + (".gif", "image/gif"), + (".webp", "image/webp"), + (".unknown", "application/octet-stream"), + ], + ) + def test_get_mime_type_from_path(self, tmp_path, extension, mime_type): """Test MIME type detection from file extension.""" - extensions = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".unknown": "application/octet-stream", - } - - for ext, mime in extensions.items(): - path = tmp_path / f"test{ext}" - path.write_bytes(b"fake image data") - img = Image(path=path) - assert img._mime_type == mime + path = tmp_path / f"test{extension}" + path.write_bytes(b"fake image data") + img = Image(path=path) + assert img._mime_type == mime_type def test_to_image_content(self, tmp_path, monkeypatch): """Test conversion to ImageContent.""" @@ -227,6 +228,27 @@ class TestImage: with pytest.raises(ValueError, match="No image data available"): img.to_image_content() + @pytest.mark.parametrize( + "mime_type,fname,expected_mime", + [ + (None, "test.png", "image/png"), + ("image/jpeg", "test.unknown", "image/jpeg"), + ], + ) + def test_to_data_uri(self, tmp_path, mime_type, fname, expected_mime): + """Test conversion to data URI.""" + img_path = tmp_path / fname + test_data = b"fake image data" + img_path.write_bytes(test_data) + + img = Image(path=img_path) + data_uri = img.to_data_uri(mime_type=mime_type) + + expected_data_uri = ( + f"data:{expected_mime};base64,{base64.b64encode(test_data).decode()}" + ) + assert data_uri == expected_data_uri + class TestAudio: def test_audio_initialization_with_path(self): diff --git a/uv.lock b/uv.lock index 3c06bb91d..2d6c95d23 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -50,14 +50,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.1" +version = "1.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/a1/d8d1c6f8bc922c0b87ae0d933a8ed57be1bef6970894ed79c2852a153cd3/authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd", size = 159988, upload-time = "2025-07-20T07:38:42.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/58/cc6a08053f822f98f334d38a27687b69c6655fb05cd74a7a5e70a2aeed95/authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e", size = 239299, upload-time = "2025-07-20T07:38:39.259Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, ] [[package]] @@ -173,66 +173,91 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -559,16 +584,16 @@ dependencies = [ { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "httpx" }, + { name = "jsonschema-path" }, { name = "mcp" }, - { name = "openapi-core" }, { name = "openapi-pydantic" }, { name = "platformdirs" }, { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, - { name = "pytest-asyncio" }, { name = "python-dotenv" }, { name = "rich" }, + { name = "uvicorn" }, { name = "websockets" }, ] @@ -591,6 +616,7 @@ dev = [ { name = "pyinstrument" }, { name = "pyperclip" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-flakefinder" }, @@ -605,21 +631,21 @@ dev = [ [package.metadata] requires-dist = [ - { name = "authlib", specifier = ">=1.5.2" }, + { name = "authlib", specifier = ">=1.6.5" }, { name = "cyclopts", specifier = ">=3.0.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "jsonschema-path", specifier = ">=0.3.4" }, { name = "mcp", specifier = ">=1.17.0,<2.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, - { name = "openapi-core", specifier = ">=0.19.5" }, { 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.6,<0.3.0" }, + { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.2.8,<0.3.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pyperclip", specifier = ">=1.9.0" }, - { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, + { name = "uvicorn", specifier = ">=0.35" }, { name = "websockets", specifier = ">=15.0.1" }, ] provides-extras = ["openai"] @@ -637,6 +663,7 @@ dev = [ { name = "pyinstrument", specifier = ">=5.0.2" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-env", specifier = ">=1.1.5" }, { name = "pytest-flakefinder" }, @@ -697,11 +724,11 @@ wheels = [ [[package]] name = "httpx-sse" -version = "0.4.1" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, ] [[package]] @@ -826,15 +853,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 = "isodate" -version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, -] - [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1024,25 +1042,6 @@ 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 = "lazy-object-proxy" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/1f56571ed82fb324f293661690635cf42c41deb8a70a6c9e6edc3e9bb3c8/lazy_object_proxy-1.11.0.tar.gz", hash = "sha256:18874411864c9fbbbaa47f9fc1dd7aea754c86cfde21278ef427639d1dd78e9c", size = 44736, upload-time = "2025-04-16T16:53:48.482Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/c8/457f1555f066f5bacc44337141294153dc993b5e9132272ab54a64ee98a2/lazy_object_proxy-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:132bc8a34f2f2d662a851acfd1b93df769992ed1b81e2b1fda7db3e73b0d5a18", size = 28045, upload-time = "2025-04-16T16:53:32.314Z" }, - { url = "https://files.pythonhosted.org/packages/18/33/3260b4f8de6f0942008479fee6950b2b40af11fc37dba23aa3672b0ce8a6/lazy_object_proxy-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:01261a3afd8621a1accb5682df2593dc7ec7d21d38f411011a5712dcd418fbed", size = 28441, upload-time = "2025-04-16T16:53:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/51/f6/eb645ca1ff7408bb69e9b1fe692cce1d74394efdbb40d6207096c0cd8381/lazy_object_proxy-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:090935756cc041e191f22f4f9c7fd4fe9a454717067adf5b1bbd2ce3046b556e", size = 28047, upload-time = "2025-04-16T16:53:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/13/9c/aabbe1e8b99b8b0edb846b49a517edd636355ac97364419d9ba05b8fa19f/lazy_object_proxy-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:76ec715017f06410f57df442c1a8d66e6b5f7035077785b129817f5ae58810a4", size = 28440, upload-time = "2025-04-16T16:53:36.113Z" }, - { url = "https://files.pythonhosted.org/packages/4d/24/dae4759469e9cd318fef145f7cfac7318261b47b23a4701aa477b0c3b42c/lazy_object_proxy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a9f39098e93a63618a79eef2889ae3cf0605f676cd4797fdfd49fcd7ddc318b", size = 28142, upload-time = "2025-04-16T16:53:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/de/0c/645a881f5f27952a02f24584d96f9f326748be06ded2cee25f8f8d1cd196/lazy_object_proxy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ee13f67f4fcd044ef27bfccb1c93d39c100046fec1fad6e9a1fcdfd17492aeb3", size = 28380, upload-time = "2025-04-16T16:53:39.07Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0f/6e004f928f7ff5abae2b8e1f68835a3870252f886e006267702e1efc5c7b/lazy_object_proxy-1.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4c84eafd8dd15ea16f7d580758bc5c2ce1f752faec877bb2b1f9f827c329cd", size = 28149, upload-time = "2025-04-16T16:53:40.135Z" }, - { url = "https://files.pythonhosted.org/packages/63/cb/b8363110e32cc1fd82dc91296315f775d37a39df1c1cfa976ec1803dac89/lazy_object_proxy-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:d2503427bda552d3aefcac92f81d9e7ca631e680a2268cbe62cd6a58de6409b7", size = 28389, upload-time = "2025-04-16T16:53:43.612Z" }, - { url = "https://files.pythonhosted.org/packages/7b/89/68c50fcfd81e11480cd8ee7f654c9bd790a9053b9a0efe9983d46106f6a9/lazy_object_proxy-1.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0613116156801ab3fccb9e2b05ed83b08ea08c2517fdc6c6bc0d4697a1a376e3", size = 28777, upload-time = "2025-04-16T16:53:41.371Z" }, - { url = "https://files.pythonhosted.org/packages/39/d0/7e967689e24de8ea6368ec33295f9abc94b9f3f0cd4571bfe148dc432190/lazy_object_proxy-1.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bb03c507d96b65f617a6337dedd604399d35face2cdf01526b913fb50c4cb6e8", size = 29598, upload-time = "2025-04-16T16:53:42.513Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1e/fb441c07b6662ec1fc92b249225ba6e6e5221b05623cb0131d082f782edc/lazy_object_proxy-1.11.0-py3-none-any.whl", hash = "sha256:a56a5093d433341ff7da0e89f9b486031ccd222ec8e52ec84d0ec1cdc819674b", size = 16635, upload-time = "2025-04-16T16:53:47.198Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1055,64 +1054,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, -] - [[package]] name = "matplotlib-inline" version = "0.1.7" @@ -1176,7 +1117,7 @@ wheels = [ [[package]] name = "openai" -version = "1.102.0" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1188,29 +1129,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/55/da5598ed5c6bdd9939633854049cddc5cbac0da938dfcfcb3c6b119c16c0/openai-1.102.0.tar.gz", hash = "sha256:2e0153bcd64a6523071e90211cbfca1f2bbc5ceedd0993ba932a5869f93b7fc9", size = 519027, upload-time = "2025-08-26T20:50:29.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/44/303deb97be7c1c9b53118b52825cbd1557aeeff510f3a52566b1fa66f6a2/openai-2.6.1.tar.gz", hash = "sha256:27ae704d190615fca0c0fc2b796a38f8b5879645a3a52c9c453b23f97141bb49", size = 593043, upload-time = "2025-10-24T13:29:52.79Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/0d/c9e7016d82c53c5b5e23e2bad36daebb8921ed44f69c0a985c6529a35106/openai-1.102.0-py3-none-any.whl", hash = "sha256:d751a7e95e222b5325306362ad02a7aa96e1fab3ed05b5888ce1c7ca63451345", size = 812015, upload-time = "2025-08-26T20:50:27.219Z" }, -] - -[[package]] -name = "openapi-core" -version = "0.19.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "isodate" }, - { name = "jsonschema" }, - { name = "jsonschema-path" }, - { name = "more-itertools" }, - { name = "openapi-schema-validator" }, - { name = "openapi-spec-validator" }, - { name = "parse" }, - { name = "typing-extensions" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/35/1acaa5f2fcc6e54eded34a2ec74b479439c4e469fc4e8d0e803fda0234db/openapi_core-0.19.5.tar.gz", hash = "sha256:421e753da56c391704454e66afe4803a290108590ac8fa6f4a4487f4ec11f2d3", size = 103264, upload-time = "2025-03-20T20:17:28.193Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/6f/83ead0e2e30a90445ee4fc0135f43741aebc30cca5b43f20968b603e30b6/openapi_core-0.19.5-py3-none-any.whl", hash = "sha256:ef7210e83a59394f46ce282639d8d26ad6fc8094aa904c9c16eb1bac8908911f", size = 106595, upload-time = "2025-03-20T20:17:26.77Z" }, + { url = "https://files.pythonhosted.org/packages/15/0e/331df43df633e6105ff9cf45e0ce57762bd126a45ac16b25a43f6738d8a2/openai-2.6.1-py3-none-any.whl", hash = "sha256:904e4b5254a8416746a2f05649594fa41b19d799843cd134dac86167e094edef", size = 1005551, upload-time = "2025-10-24T13:29:50.973Z" }, ] [[package]] @@ -1225,35 +1146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] -[[package]] -name = "openapi-schema-validator" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-specifications" }, - { name = "rfc3339-validator" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, -] - -[[package]] -name = "openapi-spec-validator" -version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-path" }, - { name = "lazy-object-proxy" }, - { name = "openapi-schema-validator" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1263,15 +1155,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "parse" -version = "1.20.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" }, -] - [[package]] name = "parso" version = "0.8.4" @@ -1899,7 +1782,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.4" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1907,21 +1790,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, -] - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -2123,15 +1994,6 @@ 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 = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -2391,18 +2253,6 @@ wheels = [ { 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 = "werkzeug" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7175d764718d5183caf8d0867a4f0190d5d4a45cea49/werkzeug-3.1.1.tar.gz", hash = "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4", size = 806453, upload-time = "2024-11-01T16:40:45.462Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, -] - [[package]] name = "zipp" version = "3.23.0"