From 1e5776f69c562db3e3dc9ed9983cc33a5c557bcd Mon Sep 17 00:00:00 2001 From: William Easton Date: Fri, 24 Oct 2025 18:24:02 -0500 Subject: [PATCH 01/34] Add list_resources, list_prompts, and get_prompt methods to Context (#2249) * Add list_resources, list_prompts, and get_prompt methods to Context - Add Context.list_resources() to list all available resources - Add Context.list_prompts() to list all available prompts - Add Context.get_prompt() to get a specific prompt with arguments - Update ToolInjectionMiddleware to use new Context methods instead of creating temporary Client instances - Remove unused Client and FastMCPTransport imports from tool_injection.py This improves API consistency by allowing middleware/tools to use Context methods directly without needing to create temporary Client instances. Fixes #2245 Co-authored-by: William Easton * Update docs --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: William Easton Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- docs/servers/context.mdx | 31 +++++++++++++-- src/fastmcp/server/context.py | 39 +++++++++++++++++++ .../server/middleware/tool_injection.py | 14 ++----- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index cb4fe52ad..c33f1812c 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -14,7 +14,8 @@ The `Context` object provides a clean interface to access MCP features within yo - **Logging**: Send debug, info, warning, and error messages back to the client - **Progress Reporting**: Update the client on the progress of long-running operations -- **Resource Access**: Read data from resources registered with the server +- **Resource Access**: List and read data from resources registered with the server +- **Prompt Access**: List and retrieve prompts registered with the server - **LLM Sampling**: Request the client's LLM to generate text based on provided messages - **User Elicitation**: Request structured input from users during tool execution - **State Management**: Store and share data between middleware and the handler within a single request @@ -177,16 +178,40 @@ See [Progress Reporting](/servers/progress) for detailed patterns and examples. ### Resource Access -Read data from resources registered with your FastMCP server, allowing access to files, configuration, or dynamic content. +List and read data from resources registered with your FastMCP server, allowing access to files, configuration, or dynamic content. ```python +# List available resources +resources = await ctx.list_resources() + +# Read a specific resource content_list = await ctx.read_resource("resource://config") content = content_list[0].content ``` -**Method signature:** +**Method signatures:** +- **`ctx.list_resources() -> list[MCPResource]`**: Returns list of all available resources - **`ctx.read_resource(uri: str | AnyUrl) -> list[ReadResourceContents]`**: Returns a list of resource content parts +### Prompt Access + + + +List and retrieve prompts registered with your FastMCP server, allowing tools and middleware to discover and use available prompts programmatically. + +```python +# List available prompts +prompts = await ctx.list_prompts() + +# Get a specific prompt with arguments +result = await ctx.get_prompt("analyze_data", {"dataset": "users"}) +messages = result.messages +``` + +**Method signatures:** +- **`ctx.list_prompts() -> list[MCPPrompt]`**: Returns list of all available prompts +- **`ctx.get_prompt(name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult`**: Get a specific prompt with optional arguments + ### State Management diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index a0fcfc1c2..1c565db55 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -22,6 +22,7 @@ from mcp.types import ( AudioContent, ClientCapabilities, CreateMessageResult, + GetPromptResult, ImageContent, IncludeContext, ModelHint, @@ -32,6 +33,8 @@ from mcp.types import ( TextContent, ) from mcp.types import CreateMessageRequestParams as SamplingParams +from mcp.types import Prompt as MCPPrompt +from mcp.types import Resource as MCPResource from pydantic.networks import AnyUrl from starlette.requests import Request from typing_extensions import TypeVar @@ -215,6 +218,42 @@ class Context: related_request_id=self.request_id, ) + async def list_resources(self) -> list[MCPResource]: + """List all available resources from the server. + + 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]: + """List all available prompts from the server. + + 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( + 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 + """ + 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]: """Read a resource by URI. diff --git a/src/fastmcp/server/middleware/tool_injection.py b/src/fastmcp/server/middleware/tool_injection.py index 8df5d18a4..7914c5eca 100644 --- a/src/fastmcp/server/middleware/tool_injection.py +++ b/src/fastmcp/server/middleware/tool_injection.py @@ -10,8 +10,6 @@ from mcp.types import Prompt from pydantic import AnyUrl from typing_extensions import override -from fastmcp.client.client import Client -from fastmcp.client.transports import FastMCPTransport from fastmcp.server.context import Context from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult @@ -55,9 +53,7 @@ class ToolInjectionMiddleware(Middleware): async def list_prompts(context: Context) -> list[Prompt]: """List prompts available on the server.""" - - async with Client[FastMCPTransport](context.fastmcp) as client: - return await client.list_prompts() + return await context.list_prompts() list_prompts_tool = Tool.from_function( @@ -73,9 +69,7 @@ async def get_prompt( ] = None, ) -> mcp.types.GetPromptResult: """Render a prompt available on the server.""" - - async with Client[FastMCPTransport](context.fastmcp) as client: - return await client.get_prompt(name=name, arguments=arguments) + return await context.get_prompt(name=name, arguments=arguments) get_prompt_tool = Tool.from_function( @@ -93,9 +87,7 @@ class PromptToolMiddleware(ToolInjectionMiddleware): async def list_resources(context: Context) -> list[mcp.types.Resource]: """List resources available on the server.""" - - async with Client[FastMCPTransport](context.fastmcp) as client: - return await client.list_resources() + return await context.list_resources() list_resources_tool = Tool.from_function( From 380835593c27ff8bd359020e354eeed8f8fa1b22 Mon Sep 17 00:00:00 2001 From: William Easton Date: Fri, 24 Oct 2025 18:25:15 -0500 Subject: [PATCH 02/34] Async FileResource and DirectoryResource (#2241) * Improve DirectoryResource exception logging and async implementation - Add exception logging before raising ResourceError in read() method - Convert list_files() to async-native using anyio.Path - Update read() to await async list_files() and use async is_file() check - Remove synchronous thread wrapper in favor of native async I/O Co-authored-by: William Easton * Clean-up DirectoryResource * Update src/fastmcp/resources/types.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: William Easton Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/resources/types.py | 58 +++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 26 deletions(-) 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 From b57a39c69ca0476ead01fc3bcd0b209d21755151 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 24 Oct 2025 19:32:45 -0400 Subject: [PATCH 03/34] Remove redundant None checks from Context methods (#2251) The fastmcp property already raises RuntimeError if None, making these checks unreachable. --- src/fastmcp/server/context.py | 8 -------- uv.lock | 6 +++--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 1c565db55..b5dbc5533 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -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( diff --git a/uv.lock b/uv.lock index 3c06bb91d..01c073600 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'", @@ -566,7 +566,6 @@ dependencies = [ { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, - { name = "pytest-asyncio" }, { name = "python-dotenv" }, { name = "rich" }, { name = "websockets" }, @@ -591,6 +590,7 @@ dev = [ { name = "pyinstrument" }, { name = "pyperclip" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-flakefinder" }, @@ -617,7 +617,6 @@ requires-dist = [ { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.2.6,<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 = "websockets", specifier = ">=15.0.1" }, @@ -637,6 +636,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" }, From 5896daf6a1802d1546827f644c82a768df49e051 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 25 Oct 2025 08:52:21 -0400 Subject: [PATCH 04/34] Stage 2.13.0 updates (#2252) * Add 2.13.0 updates * Small tweaks --- docs/changelog.mdx | 34 ++++++++++++++++++++++++++++++++++ docs/updates.mdx | 18 ++++++++++++++++++ 2 files changed, 52 insertions(+) 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/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. + + + Date: Sat, 25 Oct 2025 08:52:43 -0400 Subject: [PATCH 05/34] chore: Update SDK documentation (#2214) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/docs.json | 40 ++++--- docs/python-sdk/fastmcp-cli-cli.mdx | 2 +- docs/python-sdk/fastmcp-resources-types.mdx | 18 +-- .../fastmcp-server-auth-jwt_issuer.mdx | 105 ++--------------- .../fastmcp-server-auth-middleware.mdx | 26 +++++ .../fastmcp-server-auth-oauth_proxy.mdx | 50 ++++---- .../fastmcp-server-auth-oidc_proxy.mdx | 4 +- .../fastmcp-server-auth-providers-auth0.mdx | 2 +- .../fastmcp-server-auth-providers-aws.mdx | 8 +- .../fastmcp-server-auth-providers-azure.mdx | 22 +++- .../fastmcp-server-auth-providers-github.mdx | 6 +- .../fastmcp-server-auth-providers-google.mdx | 6 +- .../fastmcp-server-auth-providers-workos.mdx | 12 +- docs/python-sdk/fastmcp-server-context.mdx | 96 +++++++++++----- ...stmcp-server-middleware-tool_injection.mdx | 91 +++++++++++++++ docs/python-sdk/fastmcp-server-server.mdx | 108 +++++++++--------- docs/python-sdk/fastmcp-settings.mdx | 22 ++-- .../python-sdk/fastmcp-tools-tool_manager.mdx | 20 ++-- docs/python-sdk/fastmcp-utilities-cli.mdx | 2 +- 19 files changed, 366 insertions(+), 274 deletions(-) create mode 100644 docs/python-sdk/fastmcp-server-auth-middleware.mdx create mode 100644 docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx 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/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 From e74918a5445e94749b4369add6fb88225c129701 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 10:16:54 -0400 Subject: [PATCH 06/34] Add CI test job for lowest-direct dependency resolution (#2261) * Initial plan * Add test job for lowest-direct dependency resolution Co-authored-by: strawgate <6384545+strawgate@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: strawgate <6384545+strawgate@users.noreply.github.com> --- .github/workflows/run-tests.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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 From 9d4c378e1b65fa104397d6ebd5069aa52dd53484 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 26 Oct 2025 09:20:31 -0500 Subject: [PATCH 07/34] Add "High Value" Ruff Rules (#2255) * Safe Fixes from ruff * Fix remaining issues * lint/check * Fix mysterious ty check errors * small cleanup * pr fixes --- .../src/atproto_mcp/_atproto/__init__.py | 8 +-- examples/get_file.py | 6 ++ examples/memory.py | 12 ++-- pyproject.toml | 24 ++++++- src/fastmcp/__init__.py | 6 +- src/fastmcp/cli/cli.py | 4 +- src/fastmcp/client/__init__.py | 18 ++--- src/fastmcp/client/auth/oauth.py | 13 ++-- src/fastmcp/client/client.py | 20 +++--- src/fastmcp/client/sampling.py | 2 +- src/fastmcp/client/transports.py | 70 +++++++++---------- .../contrib/component_manager/__init__.py | 2 +- .../component_manager/component_manager.py | 4 +- src/fastmcp/contrib/mcp_mixin/__init__.py | 4 +- .../experimental/sampling/handlers/openai.py | 4 +- .../experimental/server/openapi/__init__.py | 17 ++--- .../experimental/server/openapi/components.py | 18 +++-- .../experimental/server/openapi/routing.py | 4 +- .../utilities/openapi/__init__.py | 25 +++---- .../utilities/openapi/director.py | 2 +- .../openapi/json_schema_converter.py | 4 +- .../experimental/utilities/openapi/models.py | 6 +- .../experimental/utilities/openapi/parser.py | 8 +-- .../experimental/utilities/openapi/schemas.py | 4 +- src/fastmcp/mcp_config.py | 5 +- src/fastmcp/prompts/__init__.py | 2 +- src/fastmcp/prompts/prompt.py | 22 +++--- src/fastmcp/resources/__init__.py | 12 ++-- src/fastmcp/resources/resource.py | 4 +- src/fastmcp/resources/resource_manager.py | 2 +- src/fastmcp/server/__init__.py | 2 +- src/fastmcp/server/auth/__init__.py | 12 ++-- src/fastmcp/server/auth/auth.py | 4 +- src/fastmcp/server/auth/oidc_proxy.py | 4 +- src/fastmcp/server/auth/providers/azure.py | 6 +- src/fastmcp/server/auth/providers/bearer.py | 2 +- .../server/auth/providers/in_memory.py | 4 +- .../server/auth/providers/introspection.py | 4 +- src/fastmcp/server/auth/providers/jwt.py | 35 +++++----- src/fastmcp/server/auth/providers/supabase.py | 2 +- src/fastmcp/server/auth/providers/workos.py | 4 +- src/fastmcp/server/context.py | 18 +++-- src/fastmcp/server/dependencies.py | 13 ++-- src/fastmcp/server/elicitation.py | 2 +- src/fastmcp/server/http.py | 5 +- src/fastmcp/server/middleware/__init__.py | 2 +- src/fastmcp/server/middleware/caching.py | 2 +- .../server/middleware/error_handling.py | 16 ++--- src/fastmcp/server/middleware/middleware.py | 2 +- src/fastmcp/server/openapi.py | 16 +++-- src/fastmcp/server/proxy.py | 9 +-- src/fastmcp/server/server.py | 56 +++++++-------- src/fastmcp/tools/__init__.py | 2 +- src/fastmcp/tools/tool.py | 24 +++---- src/fastmcp/tools/tool_transform.py | 12 ++-- src/fastmcp/utilities/cli.py | 11 ++- src/fastmcp/utilities/inspect.py | 4 +- src/fastmcp/utilities/json_schema_type.py | 8 +-- src/fastmcp/utilities/logging.py | 32 ++++----- .../utilities/mcp_server_config/__init__.py | 6 +- .../mcp_server_config/v1/environments/base.py | 3 +- .../mcp_server_config/v1/sources/base.py | 1 - src/fastmcp/utilities/openapi.py | 18 ++--- src/fastmcp/utilities/tests.py | 6 +- tests/utilities/test_inspect.py | 8 +-- 65 files changed, 347 insertions(+), 340 deletions(-) diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py index cf63cec63..ae9b7660e 100644 --- a/examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py +++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py @@ -7,14 +7,14 @@ from ._read import fetch_notifications, fetch_timeline, search_for_posts from ._social import follow_user_by_handle, like_post_by_uri, repost_by_uri __all__ = [ - "get_client", - "get_profile_info", "create_post", "create_thread", - "fetch_timeline", - "search_for_posts", "fetch_notifications", + "fetch_timeline", "follow_user_by_handle", + "get_client", + "get_profile_info", "like_post_by_uri", "repost_by_uri", + "search_for_posts", ] diff --git a/examples/get_file.py b/examples/get_file.py index 0b00ac47d..b86ca9223 100644 --- a/examples/get_file.py +++ b/examples/get_file.py @@ -1,3 +1,9 @@ +# /// script +# dependencies = ["aiohttp", "fastmcp"] +# /// + +# uv pip install aiohttp fastmcp + import aiohttp from fastmcp.server import FastMCP diff --git a/examples/memory.py b/examples/memory.py index eb3e4b00a..c5a3488a4 100644 --- a/examples/memory.py +++ b/examples/memory.py @@ -19,7 +19,7 @@ from typing import Annotated, Any, Self import asyncpg import numpy as np from openai import AsyncOpenAI -from pgvector.asyncpg import register_vector # Import register_vector +from pgvector.asyncpg import register_vector from pydantic import BaseModel, Field from pydantic_ai import Agent @@ -149,7 +149,9 @@ class MemoryNode(BaseModel): ) self.importance += other.importance self.access_count += other.access_count - self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] + self.embedding = [ + (a + b) / 2 for a, b in zip(self.embedding, other.embedding, strict=True) + ] self.summary = await do_ai( self.content, "Summarize the following text concisely.", str, deps ) @@ -281,9 +283,9 @@ async def display_memory_tree(deps: Deps) -> 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..182b649ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,12 +137,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..3acd877e1 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, ) 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..d4e4f8eb4 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 @@ -195,7 +193,8 @@ class OAuth(OAuthClientProvider): warn( message="Using in-memory token storage is not recommended for production use -- " - + "tokens will be lost on server restart." + + "tokens will be lost on server restart.", + stacklevel=2, ) self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( @@ -272,8 +271,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..a7214a8d1 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -235,7 +235,7 @@ 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(): + for template_key in templates: if match_uri_template(uri_str, template_key): return True 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..287410ea2 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -10,14 +10,14 @@ from .oauth_proxy import OAuthProxy __all__ = [ - "AuthProvider", - "OAuthProvider", - "TokenVerifier", - "JWTVerifier", - "StaticTokenVerifier", - "RemoteAuthProvider", "AccessToken", + "AuthProvider", + "JWTVerifier", + "OAuthProvider", "OAuthProxy", + "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/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 063f4a3ad..4e216f556 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) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 2217d0aa8..e7af79551 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -113,12 +113,12 @@ 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, 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/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..74ca55bd2 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -184,13 +184,13 @@ 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 | 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. @@ -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]: """ @@ -400,14 +400,13 @@ 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: - self.logger.debug( - "Token validation failed: issuer mismatch for client %s", - client_id, - ) - self.logger.info("Bearer token rejected for client %s", client_id) - return None + if self.issuer and claims.get("iss") != self.issuer: + self.logger.debug( + "Token validation failed: issuer mismatch for client %s", + client_id, + ) + self.logger.info("Bearer token rejected for client %s", client_id) + return None # Validate audience if configured if self.audience: 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 b5dbc5533..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 @@ -342,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 @@ -595,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/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) From ba47db9b8cafb694e5a11fde337e2a69444ba55c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:41:22 -0400 Subject: [PATCH 08/34] Fix Azure scope validation (#2269) * Update docs for required scopes * add scopes * Fix Azure scope validation Azure returns unprefixed scopes in JWT tokens but requires prefixed scopes in authorization requests. The previous implementation incorrectly validated tokens against prefixed scopes, causing "invalid_token" errors. Simplified AzureProvider to use standard JWTVerifier with unprefixed scopes for validation. Scopes are only prefixed when building the Azure authorization URL via _build_upstream_authorize_url() override. Closes #2263 --- docs/integrations/azure.mdx | 14 +- examples/auth/azure_oauth/server.py | 11 +- src/fastmcp/server/auth/providers/azure.py | 69 ++++++---- tests/server/auth/providers/test_azure.py | 141 +++++++++++++++------ 4 files changed, 168 insertions(+), 67 deletions(-) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 307bba209..f91af5a2f 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -122,7 +122,7 @@ 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 @@ -159,6 +159,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 @@ -296,8 +300,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. + diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py index 2d5062612..d214389aa 100644 --- a/examples/auth/azure_oauth/server.py +++ b/examples/auth/azure_oauth/server.py @@ -7,6 +7,8 @@ Required environment variables: - AZURE_CLIENT_SECRET: Your Azure client secret - AZURE_TENANT_ID: Tenant ID Options: "organizations" (work/school), "consumers" (personal), or specific tenant ID +- AZURE_REQUIRED_SCOPES: At least one scope required (e.g., "read" or "read,write") + These must match scope names created under "Expose an API" in your Azure App registration To run: python server.py @@ -18,11 +20,14 @@ from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider auth = AzureProvider( - client_id=os.getenv("AZURE_CLIENT_ID") or "", - client_secret=os.getenv("AZURE_CLIENT_SECRET") or "", - tenant_id=os.getenv("AZURE_TENANT_ID") + client_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID") or "", + client_secret=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET") or "", + tenant_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_TENANT_ID") or "", # Required for single-tenant apps - get from Azure Portal base_url="http://localhost:8000", + required_scopes=["read"], + # required_scopes is automatically loaded from FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES + # At least one scope is required - use unprefixed scope names from your Azure App (e.g., ["read", "write"]) # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index e7af79551..163585046 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 @@ -202,32 +202,34 @@ 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" jwks_uri = ( f"https://login.microsoftonline.com/{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 @@ -298,19 +300,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/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 95d0eb754..b4b7428de 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,21 @@ 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 From 8bd3a308c9a5a581b0eca2db0aec3b1dddc141d8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:49:40 -0400 Subject: [PATCH 09/34] Update Azure sidebar title to include Entra ID (#2266) * Update Azure sidebar title to include Entra ID Co-authored-by: Jeremiah Lowin * Update Azure title to emphasize Microsoft Entra ID Co-authored-by: Jeremiah Lowin * Update Azure title to emphasize Azure over Entra ID Co-authored-by: Jeremiah Lowin --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- docs/integrations/azure.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index f91af5a2f..48a4f7c99 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 From f5bdf8f6d3e9c5d9b841ee21d7e6a27c6b10ba8d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:04:54 -0400 Subject: [PATCH 10/34] Improve OAuth client token storage security documentation (#2270) * Update docs for required scopes * add scopes * Fix Azure scope validation Azure returns unprefixed scopes in JWT tokens but requires prefixed scopes in authorization requests. The previous implementation incorrectly validated tokens against prefixed scopes, causing "invalid_token" errors. Simplified AzureProvider to use standard JWTVerifier with unprefixed scopes for validation. Scopes are only prefixed when building the Azure authorization URL via _build_upstream_authorize_url() override. Closes #2263 * Improve OAuth client token storage security documentation Updated warning message and documentation to address security concerns around storing OAuth credentials for multiple MCP servers. --- docs/clients/auth/oauth.mdx | 125 +++++++++++++++++++++++++++---- src/fastmcp/client/auth/oauth.py | 5 +- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 56e02e0b4..47293aae5 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,7 +82,7 @@ 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. @@ -96,23 +96,116 @@ If the access token expires, the client will automatically use the refresh token ### 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. + -### Managing Cache + +**Security Consideration**: MCP clients can accumulate OAuth credentials for many different servers over time. Unlike single-service CLI tools (like `gh` or `gcloud`), a compromised token store could expose access to multiple services. Use encrypted storage for production use. + -To clear the tokens for a specific server, instantiate a `FileTokenStorage` instance and call the `clear` method: +By default, tokens are stored in memory and lost when your application restarts. For persistent storage, provide an `AsyncKeyValue`-compatible storage backend to the `token_storage` parameter. + +#### In-Memory Storage (Default) ```python -from fastmcp.client.auth.oauth import FileTokenStorage +from fastmcp import Client +from fastmcp.client.auth import OAuth -storage = FileTokenStorage(server_url="https://fastmcp.cloud/mcp") +# Default: tokens stored in memory, lost on restart +oauth = OAuth(mcp_url="https://fastmcp.cloud/mcp") + +async with Client("https://fastmcp.cloud/mcp", auth=oauth) as client: + await client.ping() +``` + +#### Encrypted Disk Storage (Recommended) + +For production use where your client connects to multiple MCP servers, use encrypted storage to protect accumulated credentials: + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth +from key_value.aio.stores.file import FileStore +from key_value.aio.wrappers.encryption import FernetEncryptionWrapper +from cryptography.fernet import Fernet +import os + +# Generate encryption key (store securely, e.g., in environment variable) +# On first run: Fernet.generate_key() -> save to secure location +encryption_key = os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"] + +# Create encrypted file storage +encrypted_storage = FernetEncryptionWrapper( + key_value=FileStore(base_path="~/.fastmcp/oauth-tokens"), + fernet=Fernet(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() +``` + + +The `FernetEncryptionWrapper` provides AES-128-CBC encryption with HMAC-SHA256 authentication. Your encryption key should be at least 32 bytes and stored securely (environment variables, system keychain, or secrets manager). + + +#### Plaintext Disk Storage (Development Only) + + +**Not recommended**: Plaintext storage accumulates credentials for multiple MCP servers without encryption. Only use for local development on secure machines. + + +```python +from key_value.aio.stores.file import FileStore + +# Development only - plaintext storage +oauth = OAuth( + mcp_url="https://fastmcp.cloud/mcp", + token_storage=FileStore(base_path="~/.fastmcp/oauth-tokens") +) +``` + +### Custom Storage Backends + +You can use any `AsyncKeyValue`-compatible storage backend. See the [key-value library](https://github.com/jlowin/key-value) for available options including Redis, DynamoDB, and more. + +For multi-server deployments, wrap your storage in `FernetEncryptionWrapper`: + +```python +from key_value.aio.stores.redis import RedisStore +from key_value.aio.wrappers.encryption import FernetEncryptionWrapper +from cryptography.fernet import Fernet +import os + +encrypted_storage = FernetEncryptionWrapper( + key_value=RedisStore(host="redis.example.com", port=6379), + fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"]) +) + +oauth = OAuth( + mcp_url="https://fastmcp.cloud/mcp", + token_storage=encrypted_storage +) +``` + +### Managing Stored Tokens + +To clear tokens for a specific server: + +```python +# Clear tokens for one server +await oauth.token_storage_adapter.clear() +``` + +To clear all tokens across all storage: + +```python +# Clear all tokens in the storage backend +from key_value.aio.stores.file import FileStore + +storage = FileStore(base_path="~/.fastmcp/oauth-tokens") await storage.clear() ``` - -To clear *all* tokens for all servers, call the `clear_all` method on the `FileTokenStorage` class: - -```python -from fastmcp.client.auth.oauth import FileTokenStorage - -FileTokenStorage.clear_all() -``` diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index d4e4f8eb4..f454febec 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -192,8 +192,9 @@ 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, ) From 9a7c04873cee4822744478a3d072c947c61a6ea7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:00:49 -0400 Subject: [PATCH 11/34] Add note about docs version (#2271) --- docs/getting-started/welcome.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From 5ceafe425c6b9301c6672e41efb8537ff6c0469f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:07:54 -0400 Subject: [PATCH 12/34] Fix OAuth token storage documentation (#2272) Correct imports (DiskStore not FileStore) and simplify structure. --- docs/clients/auth/oauth.mdx | 105 ++++-------------------------------- 1 file changed, 11 insertions(+), 94 deletions(-) diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 47293aae5..090afbd30 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -92,96 +92,27 @@ If the access token expires, the client will automatically use the refresh token -## Token Management - -### Token Storage +## Token Storage +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. + -**Security Consideration**: MCP clients can accumulate OAuth credentials for many different servers over time. Unlike single-service CLI tools (like `gh` or `gcloud`), a compromised token store could expose access to multiple services. Use encrypted storage for production use. +**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. -By default, tokens are stored in memory and lost when your application restarts. For persistent storage, provide an `AsyncKeyValue`-compatible storage backend to the `token_storage` parameter. - -#### In-Memory Storage (Default) - ```python from fastmcp import Client from fastmcp.client.auth import OAuth - -# Default: tokens stored in memory, lost on restart -oauth = OAuth(mcp_url="https://fastmcp.cloud/mcp") - -async with Client("https://fastmcp.cloud/mcp", auth=oauth) as client: - await client.ping() -``` - -#### Encrypted Disk Storage (Recommended) - -For production use where your client connects to multiple MCP servers, use encrypted storage to protect accumulated credentials: - -```python -from fastmcp import Client -from fastmcp.client.auth import OAuth -from key_value.aio.stores.file import FileStore +from key_value.aio.stores.disk import DiskStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet import os -# Generate encryption key (store securely, e.g., in environment variable) -# On first run: Fernet.generate_key() -> save to secure location -encryption_key = os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"] - -# Create encrypted file storage +# Create encrypted disk storage encrypted_storage = FernetEncryptionWrapper( - key_value=FileStore(base_path="~/.fastmcp/oauth-tokens"), - fernet=Fernet(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() -``` - - -The `FernetEncryptionWrapper` provides AES-128-CBC encryption with HMAC-SHA256 authentication. Your encryption key should be at least 32 bytes and stored securely (environment variables, system keychain, or secrets manager). - - -#### Plaintext Disk Storage (Development Only) - - -**Not recommended**: Plaintext storage accumulates credentials for multiple MCP servers without encryption. Only use for local development on secure machines. - - -```python -from key_value.aio.stores.file import FileStore - -# Development only - plaintext storage -oauth = OAuth( - mcp_url="https://fastmcp.cloud/mcp", - token_storage=FileStore(base_path="~/.fastmcp/oauth-tokens") -) -``` - -### Custom Storage Backends - -You can use any `AsyncKeyValue`-compatible storage backend. See the [key-value library](https://github.com/jlowin/key-value) for available options including Redis, DynamoDB, and more. - -For multi-server deployments, wrap your storage in `FernetEncryptionWrapper`: - -```python -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet -import os - -encrypted_storage = FernetEncryptionWrapper( - key_value=RedisStore(host="redis.example.com", port=6379), + key_value=DiskStore(directory="~/.fastmcp/oauth-tokens"), fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"]) ) @@ -189,23 +120,9 @@ 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() ``` -### Managing Stored Tokens - -To clear tokens for a specific server: - -```python -# Clear tokens for one server -await oauth.token_storage_adapter.clear() -``` - -To clear all tokens across all storage: - -```python -# Clear all tokens in the storage backend -from key_value.aio.stores.file import FileStore - -storage = FileStore(base_path="~/.fastmcp/oauth-tokens") -await storage.clear() -``` +You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/jlowin/key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption. From 8a48146aadab598eebc4f521dfc5765b54f656fa Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:08:05 -0400 Subject: [PATCH 13/34] Cleanly render oauth errors from proxy (#2268) --- src/fastmcp/server/auth/oauth_proxy.py | 136 +++++++++++++++++++++---- tests/server/auth/test_oauth_proxy.py | 100 ++++++++++++++++++ 2 files changed, 217 insertions(+), 19 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 50dee0462..2d7dd6472 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -375,6 +375,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 +1659,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 +1676,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 +1760,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 +1811,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/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 From 11277f6e2141ddc0de528e1d85224315ad092cbe Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Mon, 27 Oct 2025 07:35:10 -0400 Subject: [PATCH 14/34] fix(docs): correct the key_value repo link --- docs/clients/auth/oauth.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 090afbd30..8656ff679 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -83,9 +83,9 @@ Upon approval, the OAuth server redirects the user's browser to the local callba 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. @@ -125,4 +125,4 @@ async with Client("https://fastmcp.cloud/mcp", auth=oauth) as client: await client.ping() ``` -You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/jlowin/key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption. +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. From 6d600e36db82cc9fe82eeb44868359114aa4e81a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:36:47 -0400 Subject: [PATCH 15/34] Remove trailing slashes from MCP endpoint URLs in docs (#2277) --- docs/deployment/http.mdx | 6 +++--- docs/deployment/running-server.mdx | 2 +- docs/integrations/auth0.mdx | 2 +- docs/integrations/authkit.mdx | 2 +- docs/integrations/aws-cognito.mdx | 2 +- docs/integrations/azure.mdx | 2 +- docs/integrations/descope.mdx | 2 +- docs/integrations/fastapi.mdx | 2 +- docs/integrations/github.mdx | 2 +- docs/integrations/google.mdx | 2 +- docs/tutorials/rest-api.mdx | 2 +- 11 files changed, 13 insertions(+), 13 deletions(-) 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/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 48a4f7c99..1c6730f87 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -185,7 +185,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!") 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/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() From 15dbe7ecf0349a31bcb24834e237d44c0599feee Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:38:20 -0400 Subject: [PATCH 16/34] Add custom token verifier support to OIDCProxy (#2279) * Add custom token verifier support to OIDCProxy OIDCProxy now accepts an optional token_verifier parameter to support non-JWT token formats like opaque tokens from providers such as Clerk. When provided, the custom verifier is used instead of creating a default JWTVerifier. Parameters that only apply to JWTVerifier creation (algorithm, required_scopes) raise clear errors when specified alongside a custom verifier. Parameters with other purposes (audience for OAuth flow, timeout_seconds for config fetch) remain allowed. The custom verifier's required_scopes are automatically loaded and advertised through OAuth discovery endpoints. * Document custom token verifier support in OIDC proxy --- docs/servers/auth/oidc-proxy.mdx | 14 ++- src/fastmcp/server/auth/oidc_proxy.py | 35 +++++-- tests/server/auth/test_oidc_proxy.py | 134 ++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) 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/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 4e216f556..d9a3df510 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -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/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"} From b24d77145717e62183534eb18f45d9cc5f30a143 Mon Sep 17 00:00:00 2001 From: mhassaninmsft <97306532+mhassaninmsft@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:13:40 -0400 Subject: [PATCH 17/34] Supporting Multiple Issuers For JWTVerifier Oauth Workflow (#2233) * multiple issuers * Update tests/server/auth/test_jwt_provider.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix static checks --------- Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/fastmcp/server/auth/providers/jwt.py | 30 +++++++++++++++++------- tests/server/auth/test_jwt_provider.py | 23 ++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 74ca55bd2..b03463ee8 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 @@ -186,7 +186,7 @@ class JWTVerifier(TokenVerifier): *, public_key: str | NotSetT | None = NotSet, jwks_uri: str | NotSetT | None = NotSet, - issuer: 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, @@ -400,13 +400,25 @@ 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 and claims.get("iss") != self.issuer: - self.logger.debug( - "Token validation failed: issuer mismatch for client %s", - client_id, - ) - self.logger.info("Bearer token rejected for client %s", client_id) - return None + if 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, + ) + self.logger.info("Bearer token rejected for client %s", client_id) + return None # Validate audience if configured if self.audience: 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 ): From 4ea896c246a4810f2d9c6bce431ca9f46e290371 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:26:25 -0400 Subject: [PATCH 18/34] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`e?= =?UTF-8?q?nhancement/support-jwt-multiple-issuers`=20(#2282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📝 Add docstrings to `enhancement/support-jwt-multiple-issuers` Docstrings generation was requested by @jlowin. * https://github.com/jlowin/fastmcp/pull/2233#issuecomment-3453446122 The following files were modified: * `src/fastmcp/server/auth/providers/jwt.py` * Fix formatting issues in JWT provider docstrings Co-authored-by: Jeremiah Lowin --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/server/auth/providers/jwt.py | 32 ++++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index b03463ee8..1d6f4baa4 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -193,19 +193,19 @@ class JWTVerifier(TokenVerifier): 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( { @@ -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) From 318da83f6b05f24eb5536b0edd61e68191399200 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:30:03 -0400 Subject: [PATCH 19/34] Fix py-key-value-aio minimum version to 0.2.8 (#2288) * Fix py-key-value-aio minimum version to 0.2.8 FernetEncryptionWrapper was introduced in 0.2.8, not 0.2.6. Fixes #2284 * Update lockfile --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 182b649ec..de57f7fef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "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", "websockets>=15.0.1", ] diff --git a/uv.lock b/uv.lock index 01c073600..c757e7d3f 100644 --- a/uv.lock +++ b/uv.lock @@ -614,7 +614,7 @@ requires-dist = [ { 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 = "python-dotenv", specifier = ">=1.1.0" }, From 887e72b39b1688f4972df20cf7aa71170598d4cb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:46:52 -0400 Subject: [PATCH 20/34] Replace openapi-core with jsonschema-path (#2291) --- pyproject.toml | 18 +-- uv.lock | 340 ++++++++++++++----------------------------------- 2 files changed, 103 insertions(+), 255 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de57f7fef..449b02ed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,9 @@ dependencies = [ "authlib>=1.5.2", "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", - "openapi-core>=0.19.5", "py-key-value-aio[disk,keyring,memory]>=0.2.8,<0.3.0", "websockets>=15.0.1", + "jsonschema-path>=0.3.4", ] requires-python = ">=3.10" @@ -141,16 +141,16 @@ fixable = ["ALL"] ignore = [ "COM812", "PLR0913", # Too many arguments, MCP Servers have a lot of arguments, OKAY?! - "SIM102", # Dont require combining if statements + "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 + "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 + "UP", # flake8-unused-imports: Catches unused imports ] @@ -158,9 +158,9 @@ extend-select = [ "__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 +"!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 diff --git a/uv.lock b/uv.lock index c757e7d3f..8a152882c 100644 --- a/uv.lock +++ b/uv.lock @@ -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,8 +584,8 @@ 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"] }, @@ -609,9 +634,9 @@ requires-dist = [ { 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.8,<0.3.0" }, @@ -697,11 +722,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 +851,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 +1040,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 +1052,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 +1115,7 @@ wheels = [ [[package]] name = "openai" -version = "1.102.0" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1188,29 +1127,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 +1144,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 +1153,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 +1780,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.4" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1907,21 +1788,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 +1992,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 +2251,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" From a2f710fa9dcf733b18b9cf90d675b9f3d11cbbed Mon Sep 17 00:00:00 2001 From: "jake@prefect.io" Date: Tue, 28 Oct 2025 10:14:53 -0400 Subject: [PATCH 21/34] add exc_info to fastmcp run --- src/fastmcp/cli/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 3acd877e1..3b128ed60 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -504,6 +504,7 @@ async def run( except subprocess.CalledProcessError as e: logger.error( f"Failed to run: {e}", + exc_info=True, extra={ "server_spec": server_spec, "error": str(e), @@ -528,6 +529,7 @@ async def run( except Exception as e: logger.error( f"Failed to run: {e}", + exc_info=True, extra={ "server_spec": server_spec, "error": str(e), From 4262468b44e62ccd2799cd4ecd7c1ac63fc9b1aa Mon Sep 17 00:00:00 2001 From: "jake@prefect.io" Date: Tue, 28 Oct 2025 10:18:21 -0400 Subject: [PATCH 22/34] use logger.exeception --- src/fastmcp/cli/cli.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 3b128ed60..17d7e1c99 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -502,9 +502,8 @@ 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}", - exc_info=True, extra={ "server_spec": server_spec, "error": str(e), @@ -527,9 +526,8 @@ async def run( skip_source=skip_source, ) except Exception as e: - logger.error( + logger.exception( f"Failed to run: {e}", - exc_info=True, extra={ "server_spec": server_spec, "error": str(e), @@ -768,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) From 463b3369419af1f67698f8a1f27d1248240347fd Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:04:56 -0700 Subject: [PATCH 23/34] Fix Chrome CSP blocking OAuth consent form with custom protocol redirects (#2305) * Fix Chrome CSP blocking OAuth consent form with custom protocol redirects * Fix Chrome CSP blocking OAuth consent form with custom protocol redirects Dynamically include custom protocol schemes in CSP form-action directive when redirect URIs use custom protocols like cursor:// --- src/fastmcp/server/auth/oauth_proxy.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 2d7dd6472..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, From 1ca53b4134a9047a5872ebdf71f83af34107a180 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:06:21 -0700 Subject: [PATCH 24/34] Add base_authority parameter to AzureProvider for Azure Government support (#2306) --- docs/integrations/azure.mdx | 10 ++ src/fastmcp/server/auth/providers/azure.py | 31 ++++++- tests/server/auth/providers/test_azure.py | 102 +++++++++++++++++++++ 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 1c6730f87..90dcfd1bc 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -128,6 +128,7 @@ auth_provider = AzureProvider( # 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) @@ -315,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/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 163585046..4c7cb8359 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -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) ``` """ @@ -123,6 +135,7 @@ class AzureProvider(OAuthProxy): 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 } @@ -218,9 +234,10 @@ class AzureProvider(OAuthProxy): tenant_id_final = settings.tenant_id # 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 @@ -239,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 @@ -262,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( diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index b4b7428de..168384eb0 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -385,3 +385,105 @@ class TestAzureProvider: ) 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 From e2d317eeb6845dcccf71edc2d1f6f18a00038288 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:28:26 -0700 Subject: [PATCH 25/34] Add OIDCProxy to auth module exports (#2308) Fixes #2298 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/server/auth/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index 287410ea2..7e35220d5 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -7,6 +7,7 @@ from .auth import ( ) from .providers.jwt import JWTVerifier, StaticTokenVerifier from .oauth_proxy import OAuthProxy +from .oidc_proxy import OIDCProxy __all__ = [ @@ -15,6 +16,7 @@ __all__ = [ "JWTVerifier", "OAuthProvider", "OAuthProxy", + "OIDCProxy", "RemoteAuthProvider", "StaticTokenVerifier", "TokenVerifier", From 237f0decd293a922edc60344c3c38317e89efcea Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:43:31 +0000 Subject: [PATCH 26/34] Add maturity warnings for py-key-value backends Add warning notes to documentation directing users to review py-key-value documentation for backend maturity and limitations before production use. Co-authored-by: William Easton --- docs/clients/auth/oauth.mdx | 4 ++++ docs/servers/storage-backends.mdx | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 8656ff679..9403d3063 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -126,3 +126,7 @@ async with Client("https://fastmcp.cloud/mcp", auth=oauth) as client: ``` 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. + + +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/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 From 87adacfc8baaba150a2f1304c58cde2b4f6600c1 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:22:55 -0700 Subject: [PATCH 27/34] Require uvicorn>=0.35 for websockets-sansio support (#2307) Fixes #2299 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 449b02ed2..991203674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.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", ] diff --git a/uv.lock b/uv.lock index 8a152882c..c7889db05 100644 --- a/uv.lock +++ b/uv.lock @@ -593,6 +593,7 @@ dependencies = [ { name = "pyperclip" }, { name = "python-dotenv" }, { name = "rich" }, + { name = "uvicorn" }, { name = "websockets" }, ] @@ -644,6 +645,7 @@ requires-dist = [ { name = "pyperclip", specifier = ">=1.9.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"] From de58bb0e6ca2fc8a80db1b94283874cb74dff737 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:38:01 -0700 Subject: [PATCH 28/34] Add DebugTokenVerifier with custom sync/async validation (#2296) * Add DebugTokenVerifier with custom sync/async validation * move import --- docs/servers/auth/token-verification.mdx | 61 ++++++++ src/fastmcp/server/auth/__init__.py | 2 + src/fastmcp/server/auth/providers/debug.py | 114 ++++++++++++++ tests/server/auth/test_debug_verifier.py | 169 +++++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 src/fastmcp/server/auth/providers/debug.py create mode 100644 tests/server/auth/test_debug_verifier.py 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/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index 7e35220d5..e33ad022a 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -5,6 +5,7 @@ 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 @@ -13,6 +14,7 @@ from .oidc_proxy import OIDCProxy __all__ = [ "AccessToken", "AuthProvider", + "DebugTokenVerifier", "JWTVerifier", "OAuthProvider", "OAuthProxy", 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/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 From 443c44c507b04714022fe7012a7c1b1a6d8f49d9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:38:15 -0700 Subject: [PATCH 29/34] Configure Marvin to auto-create PRs and label issues (#2319) * Update marvin.yml * Configure Marvin to auto-create PRs and label issues * Make Marvin instructions more assertive with MUST --- .github/workflows/marvin.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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." } From 08c49e62e8cf2aa674997197ce5b5a16b0eaa49a Mon Sep 17 00:00:00 2001 From: Josh Thomas Date: Sat, 1 Nov 2025 10:27:37 -0500 Subject: [PATCH 30/34] Fix query-only resource templates not matching URIs without query strings (#2323) * Fix query-only resource templates not matching URIs without query strings * apply the same fix to `has_resource` --- src/fastmcp/resources/resource_manager.py | 6 +- tests/resources/test_resource_manager.py | 106 ++++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index a7214a8d1..7367fb3e9 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -236,7 +236,7 @@ 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: - if match_uri_template(uri_str, template_key): + 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/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.""" From 321f40404689f048fd51d2864b56017d12a4a628 Mon Sep 17 00:00:00 2001 From: Harshith Thota Date: Sat, 1 Nov 2025 21:10:08 +0530 Subject: [PATCH 31/34] Added to_data_uri method for Image class. (#2227) * Added to_data_uri method and path_to_data_uri classmethod for Image class. * Removed path_to_data_uri classmethod and modified _get_mime_type to use mimetypes.guess_type function instead of hardcoded dictionary. * Register image/webp with mimetypes before guess_type to support WEBP mimetype detection on Python 3.10. * Improved branch coverage for Image.to_data_uri. * Added Image._to_data_uri example in the docs. --- docs/servers/icons.mdx | 10 +++++++ src/fastmcp/utilities/types.py | 43 ++++++++++++++++++---------- tests/utilities/test_types.py | 52 ++++++++++++++++++++++++---------- 3 files changed, 75 insertions(+), 30 deletions(-) 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/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/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): From dab125e0690bfe2ce43dd2e7f6a60109c7fc89bf Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 1 Nov 2025 11:53:51 -0700 Subject: [PATCH 32/34] Remove test warnings (#2331) --- pyproject.toml | 5 +++++ tests/client/test_sse.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 991203674..fec290757 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,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", 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)]) From c9ec1459e1d9c42ada06e000414739c8762f099c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 1 Nov 2025 13:52:08 -0700 Subject: [PATCH 33/34] Mark flaky Windows test for retry (#2344) * Mark flaky Windows test for retry test_multi_client_transform_with_filtering occasionally times out on Windows CI during exception formatting in linecache.checkcache(). Add @pytest.mark.flaky with 3 retries. * Remove unnecessary delay from flaky marker --- tests/test_mcp_config.py | 1 + 1 file changed, 1 insertion(+) 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. From c8ddbff488f5cfc681b2282ca271087c37de9ec7 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 2 Nov 2025 08:46:55 -0800 Subject: [PATCH 34/34] Security: Update authlib to 1.6.5 (CVE-2025-61920) (#2347) Updates authlib from 1.6.1 to 1.6.5 to address CVE-2025-61920, which fixes a denial of service vulnerability in JOSE implementation that accepts unbounded JWS/JWT header and signature segments. --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fec290757..ac89b01a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ 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", "py-key-value-aio[disk,keyring,memory]>=0.2.8,<0.3.0", diff --git a/uv.lock b/uv.lock index c7889db05..2d6c95d23 100644 --- a/uv.lock +++ b/uv.lock @@ -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]] @@ -631,7 +631,7 @@ 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" },