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