Merge branch 'main' into feature/exclude-args-support

This commit is contained in:
Jeremiah Lowin 2025-06-01 16:51:30 -04:00 committed by GitHub
commit 1f3ddd104b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 3499 additions and 1366 deletions

View file

@ -35,6 +35,7 @@ The `FastMCP` constructor accepts several arguments:
* `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality.
* `lifespan`: (Optional) An async context manager function for server startup and shutdown logic.
* `tags`: (Optional) A set of strings to tag the server itself.
* `tools`: (Optional) A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator.
* `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration
## Components

View file

@ -7,11 +7,12 @@ dependencies = [
"python-dotenv>=1.1.0",
"exceptiongroup>=1.2.2",
"httpx>=0.28.1",
"mcp>=1.9.0,<2.0.0",
"mcp>=1.9.2,<2.0.0",
"openapi-pydantic>=0.5.1",
"rich>=13.9.4",
"typer>=0.15.2",
"websockets>=14.0",
"authlib>=1.5.2",
]
requires-python = ">=3.10"
readme = "README.md"
@ -44,12 +45,14 @@ dev = [
"ipython>=8.12.3",
"pdbpp>=0.10.3",
"pre-commit",
"pyinstrument>=5.0.2",
"pyright>=1.1.389",
"pytest>=8.3.3",
"pytest-asyncio>=0.23.5",
"pytest-cov>=6.1.1",
"pytest-env>=1.1.5",
"pytest-flakefinder",
"pytest-httpx>=0.35.0",
"pytest-report>=0.2.1",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.6.1",

View file

@ -11,6 +11,7 @@ from .transports import (
FastMCPTransport,
StreamableHttpTransport,
)
from .auth import OAuth
__all__ = [
"Client",
@ -24,4 +25,5 @@ __all__ = [
"NpxStdioTransport",
"FastMCPTransport",
"StreamableHttpTransport",
"OAuth",
]

403
src/fastmcp/client/auth.py Normal file
View file

@ -0,0 +1,403 @@
from __future__ import annotations
import asyncio
import json
import webbrowser
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urljoin, urlparse
import anyio
import httpx
from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
from mcp.client.auth import TokenStorage
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthClientMetadata,
)
from mcp.shared.auth import (
OAuthMetadata as _MCPServerOAuthMetadata,
)
from mcp.shared.auth import (
OAuthToken as OAuthToken,
)
from pydantic import AnyHttpUrl, ValidationError
from fastmcp.client.oauth_callback import (
create_oauth_callback_server,
)
from fastmcp.settings import settings as fastmcp_global_settings
from fastmcp.utilities.http import find_available_port
from fastmcp.utilities.logging import get_logger
__all__ = ["OAuth"]
logger = get_logger(__name__)
def default_cache_dir() -> Path:
return fastmcp_global_settings.home / "oauth-mcp-client-cache"
# Flexible OAuth models for real-world compatibility
class ServerOAuthMetadata(_MCPServerOAuthMetadata):
"""
More flexible OAuth metadata model that accepts broader ranges of values
than the restrictive MCP standard model.
This handles real-world OAuth servers like PayPal that may support
additional methods not in the MCP specification.
"""
# Allow any code challenge methods, not just S256
code_challenge_methods_supported: list[str] | None = None
# Allow any token endpoint auth methods
token_endpoint_auth_methods_supported: list[str] | None = None
# Allow any grant types
grant_types_supported: list[str] | None = None
# Allow any response types
response_types_supported: list[str] = ["code"]
# Allow any response modes
response_modes_supported: list[str] | None = None
class OAuthClientProvider(_MCPOAuthClientProvider):
"""
OAuth client provider with more flexible OAuth metadata discovery.
This subclass handles real-world OAuth servers that may not conform
strictly to the MCP OAuth specification but are still valid OAuth 2.0 servers.
"""
async def _discover_oauth_metadata(
self, server_url: str
) -> ServerOAuthMetadata | None:
"""
Discover OAuth metadata with flexible validation.
This is nearly identical to the parent implementation but uses
ServerOAuthMetadata instead of the restrictive MCP OAuthMetadata.
"""
# Extract base URL per MCP spec
auth_base_url = self._get_authorization_base_url(server_url)
url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
from mcp.types import LATEST_PROTOCOL_VERSION
headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION}
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers)
if response.status_code == 404:
return None
response.raise_for_status()
metadata_json = response.json()
logger.debug(f"OAuth metadata discovered: {metadata_json}")
return ServerOAuthMetadata.model_validate(metadata_json)
except Exception:
# Retry without MCP header for CORS compatibility
try:
response = await client.get(url)
if response.status_code == 404:
return None
response.raise_for_status()
metadata_json = response.json()
logger.debug(
f"OAuth metadata discovered (no MCP header): {metadata_json}"
)
return ServerOAuthMetadata.model_validate(metadata_json)
except Exception:
logger.exception("Failed to discover OAuth metadata")
return None
class FileTokenStorage(TokenStorage):
"""
File-based token storage implementation for OAuth credentials and tokens.
Implements the mcp.client.auth.TokenStorage protocol.
Each instance is tied to a specific server URL for proper token isolation.
"""
def __init__(self, server_url: str, cache_dir: Path | None = None):
"""Initialize storage for a specific server URL."""
self.server_url = server_url
self.cache_dir = cache_dir or default_cache_dir()
self.cache_dir.mkdir(exist_ok=True, parents=True)
@staticmethod
def get_base_url(url: str) -> str:
"""Extract the base URL (scheme + host) from a URL."""
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}"
def get_cache_key(self) -> str:
"""Generate a safe filesystem key from the server's base URL."""
base_url = self.get_base_url(self.server_url)
return (
base_url.replace("://", "_")
.replace(".", "_")
.replace("/", "_")
.replace(":", "_")
)
def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
"""Get the file path for the specified cache file type."""
key = self.get_cache_key()
return self.cache_dir / f"{key}_{file_type}.json"
async def get_tokens(self) -> OAuthToken | None:
"""Load tokens from file storage."""
path = self._get_file_path("tokens")
try:
tokens = OAuthToken.model_validate_json(path.read_text())
# now = datetime.datetime.now(datetime.timezone.utc)
# if tokens.expires_at is not None and tokens.expires_at <= now:
# logger.debug(f"Token expired for {self.get_base_url(self.server_url)}")
# return None
return tokens
except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
logger.debug(
f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
)
return None
async def set_tokens(self, tokens: OAuthToken) -> None:
"""Save tokens to file storage."""
path = self._get_file_path("tokens")
path.write_text(tokens.model_dump_json(indent=2))
logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
async def get_client_info(self) -> OAuthClientInformationFull | None:
"""Load client information from file storage."""
path = self._get_file_path("client_info")
try:
client_info = OAuthClientInformationFull.model_validate_json(
path.read_text()
)
# Check if we have corresponding valid tokens
# If no tokens exist, the OAuth flow was incomplete and we should
# force a fresh client registration
tokens = await self.get_tokens()
if tokens is None:
logger.debug(
f"No tokens found for client info at {self.get_base_url(self.server_url)}. "
"OAuth flow may have been incomplete. Clearing client info to force fresh registration."
)
# Clear the incomplete client info
client_info_path = self._get_file_path("client_info")
client_info_path.unlink(missing_ok=True)
return None
return client_info
except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
logger.debug(
f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
)
return None
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
"""Save client information to file storage."""
path = self._get_file_path("client_info")
path.write_text(client_info.model_dump_json(indent=2))
logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
def clear(self) -> None:
"""Clear all cached data for this server."""
file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
for file_type in file_types:
path = self._get_file_path(file_type)
path.unlink(missing_ok=True)
logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
@classmethod
def clear_all(cls, cache_dir: Path | None = None) -> None:
"""Clear all cached data for all servers."""
cache_dir = cache_dir or default_cache_dir()
if not cache_dir.exists():
return
file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
for file_type in file_types:
for file in cache_dir.glob(f"*_{file_type}.json"):
file.unlink(missing_ok=True)
logger.info("Cleared all OAuth client cache data.")
async def discover_oauth_metadata(
server_base_url: str, httpx_kwargs: dict[str, Any] | None = None
) -> _MCPServerOAuthMetadata | None:
"""
Discover OAuth metadata from the server using RFC 8414 well-known endpoint.
Args:
server_base_url: Base URL of the OAuth server (e.g., "https://example.com")
httpx_kwargs: Additional kwargs for httpx client
Returns:
OAuth metadata if found, None otherwise
"""
well_known_url = urljoin(server_base_url, "/.well-known/oauth-authorization-server")
logger.debug(f"Discovering OAuth metadata from: {well_known_url}")
async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
try:
response = await client.get(well_known_url, timeout=10.0)
if response.status_code == 200:
logger.debug("Successfully discovered OAuth metadata")
return _MCPServerOAuthMetadata.model_validate(response.json())
elif response.status_code == 404:
logger.debug(
"OAuth metadata not found (404) - server may not require auth"
)
return None
else:
logger.warning(f"OAuth metadata request failed: {response.status_code}")
return None
except (httpx.RequestError, json.JSONDecodeError, ValidationError) as e:
logger.debug(f"OAuth metadata discovery failed: {e}")
return None
async def check_if_auth_required(
mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
) -> bool:
"""
Check if the MCP endpoint requires authentication by making a test request.
Returns:
True if auth appears to be required, False otherwise
"""
async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
try:
# Try a simple request to the endpoint
response = await client.get(mcp_url, timeout=5.0)
# If we get 401/403, auth is likely required
if response.status_code in (401, 403):
return True
# Check for WWW-Authenticate header
if "WWW-Authenticate" in response.headers:
return True
# If we get a successful response, auth may not be required
return False
except httpx.RequestError:
# If we can't connect, assume auth might be required
return True
def OAuth(
mcp_url: str,
scopes: str | list[str] | None = None,
client_name: str = "FastMCP Client",
token_storage_cache_dir: Path | None = None,
additional_client_metadata: dict[str, Any] | None = None,
) -> _MCPOAuthClientProvider:
"""
Create an OAuthClientProvider for an MCP server.
This is intended to be provided to the `auth` parameter of an
httpx.AsyncClient (or appropriate FastMCP client/transport instance)
Args:
mcp_url: Full URL to the MCP endpoint (e.g.,
"http://host/mcp/sse")
scopes: OAuth scopes to request. Can be a
space-separated string or a list of strings.
client_name: Name for this client during registration
token_storage_cache_dir: Directory for FileTokenStorage
additional_client_metadata: Extra fields for OAuthClientMetadata
Returns:
OAuthClientProvider
"""
parsed_url = urlparse(mcp_url)
server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
# Setup OAuth client
redirect_port = find_available_port()
redirect_uri = f"http://127.0.0.1:{redirect_port}/callback"
if isinstance(scopes, list):
scopes = " ".join(scopes)
client_metadata = OAuthClientMetadata(
client_name=client_name,
redirect_uris=[AnyHttpUrl(redirect_uri)],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="client_secret_post",
scope=scopes,
**(additional_client_metadata or {}),
)
# Create server-specific token storage
storage = FileTokenStorage(
server_url=server_base_url, cache_dir=token_storage_cache_dir
)
# Define OAuth handlers
async def redirect_handler(authorization_url: str) -> None:
"""Open browser for authorization."""
logger.info(f"OAuth authorization URL: {authorization_url}")
webbrowser.open(authorization_url)
async def callback_handler() -> tuple[str, str | None]:
"""Handle OAuth callback and return (auth_code, state)."""
# Create a future to capture the OAuth response
response_future = asyncio.get_running_loop().create_future()
# Create server with the future
server = create_oauth_callback_server(
port=redirect_port,
server_url=server_base_url,
response_future=response_future,
)
# Run server until response is received with timeout logic
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
logger.info(
f"🎧 OAuth callback server started on http://127.0.0.1:{redirect_port}"
)
TIMEOUT = 300.0 # 5 minute timeout
try:
with anyio.fail_after(TIMEOUT):
auth_code, state = await response_future
return auth_code, state
except TimeoutError:
raise TimeoutError(f"OAuth callback timed out after {TIMEOUT} seconds")
finally:
server.should_exit = True
await asyncio.sleep(0.1) # Allow server to shutdown gracefully
tg.cancel_scope.cancel()
# Create OAuth provider
oauth_provider = OAuthClientProvider(
server_url=server_base_url,
client_metadata=client_metadata,
storage=storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)
return oauth_provider
class BearerAuth(httpx.Auth):
def __init__(self, token: str):
self.token = token
def auth_flow(self, request):
request.headers["Authorization"] = f"Bearer {self.token}"
yield request

View file

@ -1,9 +1,11 @@
import asyncio
import datetime
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import Any, Generic, cast, overload
from typing import Any, Generic, Literal, cast, overload
import anyio
import httpx
import mcp.types
from exceptiongroup import catch
from mcp import ClientSession
@ -43,6 +45,7 @@ from .transports import (
__all__ = [
"Client",
"SessionKwargs",
"RootsHandler",
"RootsList",
"LogHandler",
@ -142,11 +145,11 @@ class Client(Generic[ClientTransportT]):
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
init_timeout: datetime.timedelta | float | int | None = None,
auth: httpx.Auth | Literal["oauth"] | str | None = None,
):
self.transport = cast(ClientTransportT, infer_transport(transport))
self._session: ClientSession | None = None
self._exit_stack: AsyncExitStack | None = None
self._nesting_counter: int = 0
if auth is not None:
self.transport._set_auth(auth)
self._initialize_result: mcp.types.InitializeResult | None = None
if log_handler is None:
@ -187,6 +190,15 @@ class Client(Generic[ClientTransportT]):
sampling_handler
)
# session context management
self._session: ClientSession | None = None
self._exit_stack: AsyncExitStack | None = None
self._nesting_counter: int = 0
self._context_lock = anyio.Lock()
self._session_task: asyncio.Task | None = None
self._ready_event = anyio.Event()
self._stop_event = anyio.Event()
@property
def session(self) -> ClientSession:
"""Get the current active session. Raises RuntimeError if not connected."""
@ -237,40 +249,76 @@ class Client(Generic[ClientTransportT]):
except TimeoutError:
raise RuntimeError("Failed to initialize server session")
finally:
self._exit_stack = None
self._session = None
self._initialize_result = None
async def __aenter__(self):
if self._nesting_counter == 0:
# Create exit stack to manage both context managers
stack = AsyncExitStack()
await stack.__aenter__()
await stack.enter_async_context(self._context_manager())
self._exit_stack = stack
self._nesting_counter += 1
await self._connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
self._nesting_counter -= 1
await self._disconnect()
if self._nesting_counter == 0:
# Exit the stack which will handle cleaning up the session
if self._exit_stack is not None:
try:
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
finally:
self._exit_stack = None
async def _connect(self):
# ensure only one session is running at a time to avoid race conditions
async with self._context_lock:
need_to_start = self._session_task is None or self._session_task.done()
if need_to_start:
self._stop_event = anyio.Event()
self._ready_event = anyio.Event()
self._session_task = asyncio.create_task(self._session_runner())
await self._ready_event.wait()
self._nesting_counter += 1
return self
async def close(self):
await self.transport.close()
async def _disconnect(self, force: bool = False):
# ensure only one session is running at a time to avoid race conditions
async with self._context_lock:
# if we are forcing a disconnect, reset the nesting counter
if force:
self._nesting_counter = 0
# otherwise decrement to check if we are done nesting
else:
self._nesting_counter = max(0, self._nesting_counter - 1)
# if we are still nested, return
if self._nesting_counter > 0:
return
# stop the active seesion
if self._session_task is None:
return
self._stop_event.set()
runner_task = self._session_task
self._session_task = None
# wait for the session to finish
if runner_task:
await runner_task
# Reset for future reconnects
self._stop_event = anyio.Event()
self._ready_event = anyio.Event()
self._session = None
self._initialize_result = None
async def _session_runner(self):
async with AsyncExitStack() as stack:
try:
await stack.enter_async_context(self._context_manager())
# Session/context is now ready
self._ready_event.set()
# Wait until disconnect/stop is requested
await self._stop_event.wait()
finally:
# On exit, ensure ready event is set (idempotent)
self._ready_event.set()
async def close(self):
await self._disconnect(force=True)
await self.transport.close()
# --- MCP Client Methods ---
async def ping(self) -> bool:

View file

@ -0,0 +1,310 @@
"""
OAuth callback server for handling authorization code flows.
This module provides a reusable callback server that can handle OAuth redirects
and display styled responses to users.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import HTMLResponse
from starlette.routing import Route
from uvicorn import Config, Server
from fastmcp.utilities.http import find_available_port
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
def create_callback_html(
message: str,
is_success: bool = True,
title: str = "FastMCP OAuth",
server_url: str | None = None,
) -> str:
"""Create a styled HTML response for OAuth callbacks."""
status_emoji = "" if is_success else ""
status_color = "#10b981" if is_success else "#ef4444" # emerald-500 / red-500
# Add server info for success cases
server_info = ""
if is_success and server_url:
server_info = f"""
<div class="server-info">
Connected to: <strong>{server_url}</strong>
</div>
"""
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 25%, #16213e 50%, #0f0f23 100%);
color: #e2e8f0;
overflow: hidden;
}}
body::before {{
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: -1;
}}
.container {{
background: rgba(30, 41, 59, 0.9);
backdrop-filter: blur(10px);
border: 1px solid rgba(71, 85, 105, 0.3);
padding: 3rem 2rem;
border-radius: 1rem;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
text-align: center;
max-width: 500px;
margin: 1rem;
position: relative;
}}
.container::before {{
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.5), transparent);
}}
.status-icon {{
font-size: 4rem;
margin-bottom: 1rem;
display: block;
filter: drop-shadow(0 0 20px currentColor);
}}
.message {{
font-size: 1.25rem;
line-height: 1.6;
color: {status_color};
margin-bottom: 1.5rem;
font-weight: 600;
text-shadow: 0 0 10px rgba({
"16, 185, 129" if is_success else "239, 68, 68"
}, 0.3);
}}
.server-info {{
background: rgba(6, 182, 212, 0.1);
border: 1px solid rgba(6, 182, 212, 0.3);
border-radius: 0.75rem;
padding: 1rem;
margin: 1rem 0;
font-size: 0.9rem;
color: #67e8f9;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
text-shadow: 0 0 10px rgba(103, 232, 249, 0.3);
}}
.server-info strong {{
color: #22d3ee;
font-weight: 700;
}}
.subtitle {{
font-size: 1rem;
color: #94a3b8;
margin-top: 1rem;
}}
.close-instruction {{
background: rgba(51, 65, 85, 0.8);
border: 1px solid rgba(71, 85, 105, 0.4);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 1.5rem;
font-size: 0.9rem;
color: #cbd5e1;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
}}
@keyframes glow {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.7; }}
}}
.status-icon {{
animation: glow 2s ease-in-out infinite;
}}
</style>
</head>
<body>
<div class="container">
<span class="status-icon">{status_emoji}</span>
<div class="message">{message}</div>
{server_info}
<div class="close-instruction">
You can safely close this tab now.
</div>
</div>
</body>
</html>
"""
@dataclass
class CallbackResponse:
code: str | None = None
state: str | None = None
error: str | None = None
error_description: str | None = None
@classmethod
def from_dict(cls, data: dict[str, str]) -> CallbackResponse:
return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
def to_dict(self) -> dict[str, str]:
return {k: v for k, v in self.__dict__.items() if v is not None}
def create_oauth_callback_server(
port: int,
callback_path: str = "/callback",
server_url: str | None = None,
response_future: asyncio.Future | None = None,
) -> Server:
"""
Create an OAuth callback server.
Args:
port: The port to run the server on
callback_path: The path to listen for OAuth redirects on
server_url: Optional server URL to display in success messages
response_future: Optional future to resolve when OAuth callback is received
Returns:
Configured uvicorn Server instance (not yet running)
"""
async def callback_handler(request: Request):
"""Handle OAuth callback requests with proper HTML responses."""
query_params = dict(request.query_params)
callback_response = CallbackResponse.from_dict(query_params)
if callback_response.error:
error_desc = callback_response.error_description or "Unknown error"
# Resolve future with exception if provided
if response_future and not response_future.done():
response_future.set_exception(
RuntimeError(
f"OAuth error: {callback_response.error} - {error_desc}"
)
)
return HTMLResponse(
create_callback_html(
f"FastMCP OAuth Error: {callback_response.error}<br>{error_desc}",
is_success=False,
),
status_code=400,
)
if not callback_response.code:
# Resolve future with exception if provided
if response_future and not response_future.done():
response_future.set_exception(
RuntimeError("OAuth callback missing authorization code")
)
return HTMLResponse(
create_callback_html(
"FastMCP OAuth Error: No authorization code received",
is_success=False,
),
status_code=400,
)
# Success case
if response_future and not response_future.done():
response_future.set_result(
(callback_response.code, callback_response.state)
)
return HTMLResponse(
create_callback_html("FastMCP OAuth login complete!", server_url=server_url)
)
app = Starlette(routes=[Route(callback_path, callback_handler)])
return Server(
Config(
app=app,
host="127.0.0.1",
port=port,
lifespan="off",
log_level="warning",
)
)
if __name__ == "__main__":
"""Run a test server when executed directly."""
import webbrowser
import uvicorn
port = find_available_port()
print("🎭 OAuth Callback Test Server")
print("📍 Test URLs:")
print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz")
print(
f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied"
)
print(f" Missing: http://localhost:{port}/callback")
print("🛑 Press Ctrl+C to stop")
print()
# Create test server without future (just for testing HTML responses)
server = create_oauth_callback_server(
port=port, server_url="https://fastmcp-test-server.example.com"
)
# Open browser to success example
webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz")
# Run with uvicorn directly
uvicorn.run(
server.config.app,
host="127.0.0.1",
port=port,
log_level="warning",
access_log=False,
)

View file

@ -6,10 +6,20 @@ import os
import shutil
import sys
import warnings
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload
from typing import (
TYPE_CHECKING,
Any,
Literal,
TypedDict,
TypeVar,
cast,
overload,
)
import anyio
import httpx
from mcp import ClientSession, StdioServerParameters
from mcp.client.session import (
ListRootsFnT,
@ -26,7 +36,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
from pydantic import AnyUrl
from typing_extensions import Unpack
from fastmcp.server import FastMCP as FastMCPServer
from fastmcp.client.auth import OAuth
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.utilities.logging import get_logger
@ -40,6 +50,20 @@ logger = get_logger(__name__)
# TypeVar for preserving specific ClientTransport subclass types
ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
__all__ = [
"ClientTransport",
"SSETransport",
"StreamableHttpTransport",
"StdioTransport",
"PythonStdioTransport",
"FastMCPStdioTransport",
"NodeStdioTransport",
"UvxStdioTransport",
"NpxStdioTransport",
"FastMCPTransport",
"infer_transport",
]
class SessionKwargs(TypedDict, total=False):
"""Keyword arguments for the MCP ClientSession constructor."""
@ -92,6 +116,10 @@ class ClientTransport(abc.ABC):
"""Close the transport."""
pass
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
if auth is not None:
raise ValueError("This transport does not support auth")
class WSTransport(ClientTransport):
"""Transport implementation that connects to an MCP server via WebSockets."""
@ -131,7 +159,9 @@ class SSETransport(ClientTransport):
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
auth: httpx.Auth | Literal["oauth"] | str | None = None,
sse_read_timeout: datetime.timedelta | float | int | None = None,
httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
):
if isinstance(url, AnyUrl):
url = str(url)
@ -139,11 +169,21 @@ class SSETransport(ClientTransport):
raise ValueError("Invalid HTTP/S URL provided for SSE.")
self.url = url
self.headers = headers or {}
self._set_auth(auth)
self.httpx_client_factory = httpx_client_factory
if isinstance(sse_read_timeout, int | float):
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
self.sse_read_timeout = sse_read_timeout
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
if auth == "oauth":
auth = OAuth(self.url)
elif isinstance(auth, str):
self.headers["Authorization"] = auth
auth = None
self.auth = auth
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
@ -165,7 +205,10 @@ class SSETransport(ClientTransport):
)
client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
async with sse_client(self.url, **client_kwargs) as transport:
if self.httpx_client_factory is not None:
client_kwargs["httpx_client_factory"] = self.httpx_client_factory
async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
@ -183,7 +226,9 @@ class StreamableHttpTransport(ClientTransport):
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
auth: httpx.Auth | Literal["oauth"] | str | None = None,
sse_read_timeout: datetime.timedelta | float | int | None = None,
httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
):
if isinstance(url, AnyUrl):
url = str(url)
@ -191,11 +236,21 @@ class StreamableHttpTransport(ClientTransport):
raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
self.url = url
self.headers = headers or {}
self._set_auth(auth)
self.httpx_client_factory = httpx_client_factory
if isinstance(sse_read_timeout, int | float):
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
self.sse_read_timeout = sse_read_timeout
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
if auth == "oauth":
auth = OAuth(self.url)
elif isinstance(auth, str):
self.headers["Authorization"] = auth
auth = None
self.auth = auth
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
@ -214,7 +269,14 @@ class StreamableHttpTransport(ClientTransport):
if session_kwargs.get("read_timeout_seconds", None) is not None:
client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
async with streamablehttp_client(self.url, **client_kwargs) as transport:
if self.httpx_client_factory is not None:
client_kwargs["httpx_client_factory"] = self.httpx_client_factory
async with streamablehttp_client(
self.url,
auth=self.auth,
**client_kwargs,
) as transport:
read_stream, write_stream, _ = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
@ -264,8 +326,8 @@ class StdioTransport(ClientTransport):
self._session: ClientSession | None = None
self._connect_task: asyncio.Task | None = None
self._ready_event = asyncio.Event()
self._stop_event = asyncio.Event()
self._ready_event = anyio.Event()
self._stop_event = anyio.Event()
@contextlib.asynccontextmanager
async def connect_session(
@ -328,8 +390,8 @@ class StdioTransport(ClientTransport):
# reset variables and events for potential future reconnects
self._connect_task = None
self._stop_event = asyncio.Event()
self._ready_event = asyncio.Event()
self._stop_event = anyio.Event()
self._ready_event = anyio.Event()
async def close(self):
await self.disconnect()
@ -592,7 +654,7 @@ class FastMCPTransport(ClientTransport):
tests or scenarios where client and server run in the same runtime.
"""
def __init__(self, mcp: FastMCPServer | FastMCP1Server):
def __init__(self, mcp: FastMCP | FastMCP1Server):
"""Initialize a FastMCPTransport from a FastMCP server instance."""
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
@ -706,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ...
@overload
def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ...
def infer_transport(transport: FastMCP) -> FastMCPTransport: ...
@overload
@ -741,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor
def infer_transport(
transport: ClientTransport
| FastMCPServer
| FastMCP
| FastMCP1Server
| AnyUrl
| Path
@ -758,7 +820,7 @@ def infer_transport(
The function supports these input types:
- ClientTransport: Used directly without modification
- FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
- FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
@ -796,7 +858,7 @@ def infer_transport(
return transport
# the transport is a FastMCP server (2.x or 1.0)
elif isinstance(transport, FastMCPServer | FastMCP1Server):
elif isinstance(transport, FastMCP | FastMCP1Server):
inferred_transport = FastMCPTransport(mcp=transport)
# the transport is a path to a script

View file

@ -1 +0,0 @@
Patched low-level objects. When possible, we prefer the official SDK, but we patch bugs here if necessary.

View file

@ -0,0 +1,45 @@
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
OAuthAuthorizationServerProvider,
RefreshToken,
)
from mcp.server.auth.settings import (
ClientRegistrationOptions,
RevocationOptions,
)
from pydantic import AnyHttpUrl
class OAuthProvider(
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
):
def __init__(
self,
issuer_url: AnyHttpUrl | str,
service_documentation_url: AnyHttpUrl | str | None = None,
client_registration_options: ClientRegistrationOptions | None = None,
revocation_options: RevocationOptions | None = None,
required_scopes: list[str] | None = None,
):
"""
Initialize the OAuth provider.
Args:
issuer_url: The URL of the OAuth issuer.
service_documentation_url: The URL of the service documentation.
client_registration_options: The client registration options.
revocation_options: The revocation options.
required_scopes: Scopes that are required for all requests.
"""
super().__init__()
if isinstance(issuer_url, str):
issuer_url = AnyHttpUrl(issuer_url)
if isinstance(service_documentation_url, str):
service_documentation_url = AnyHttpUrl(service_documentation_url)
self.issuer_url = issuer_url
self.service_documentation_url = service_documentation_url
self.client_registration_options = client_registration_options
self.revocation_options = revocation_options
self.required_scopes = required_scopes

View file

@ -0,0 +1,377 @@
import time
from dataclasses import dataclass
from typing import Any, TypedDict
import httpx
from authlib.jose import JsonWebKey, JsonWebToken
from authlib.jose.errors import JoseError
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
RefreshToken,
)
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthToken,
)
from pydantic import SecretStr
from fastmcp.server.auth.auth import (
ClientRegistrationOptions,
OAuthProvider,
RevocationOptions,
)
class JWKData(TypedDict, total=False):
"""JSON Web Key data structure."""
kty: str # Key type (e.g., "RSA") - required
kid: str # Key ID (optional but recommended)
use: str # Usage (e.g., "sig")
alg: str # Algorithm (e.g., "RS256")
n: str # Modulus (for RSA keys)
e: str # Exponent (for RSA keys)
x5c: list[str] # X.509 certificate chain (for JWKs)
x5t: str # X.509 certificate thumbprint (for JWKs)
class JWKSData(TypedDict):
"""JSON Web Key Set data structure."""
keys: list[JWKData]
@dataclass(frozen=True, kw_only=True, repr=False)
class RSAKeyPair:
private_key: SecretStr
public_key: str
@classmethod
def generate(cls) -> "RSAKeyPair":
"""
Generate an RSA key pair for testing.
Returns:
tuple: (private_key_pem, public_key_pem)
"""
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Get public key
public_key = private_key.public_key()
# Serialize private key to PEM format
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
# Serialize public key to PEM format
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
return cls(
private_key=SecretStr(private_pem),
public_key=public_pem,
)
def create_token(
self,
subject: str = "fastmcp-user",
issuer: str = "https://fastmcp.example.com",
audience: str | None = None,
scopes: list[str] | None = None,
expires_in_seconds: int = 3600,
additional_claims: dict[str, Any] | None = None,
kid: str | None = None,
) -> str:
"""
Generate a test JWT token for testing purposes.
Args:
private_key_pem: RSA private key in PEM format
subject: Subject claim (usually user ID)
issuer: Issuer claim
audience: Audience claim (optional)
scopes: List of scopes to include
expires_in_seconds: Token expiration time in seconds
additional_claims: Any additional claims to include
kid: Key ID for JWKS lookup (optional)
Returns:
Signed JWT token string
"""
jwt = JsonWebToken(["RS256"])
now = int(time.time())
# Build payload
payload = {
"iss": issuer,
"sub": subject,
"iat": now,
"exp": now + expires_in_seconds,
}
if audience:
payload["aud"] = audience
if scopes:
payload["scope"] = " ".join(scopes)
if additional_claims:
payload.update(additional_claims)
# Create header
header = {"alg": "RS256"}
if kid:
header["kid"] = kid
# Sign and return token
token_bytes = jwt.encode(
header,
payload,
key=self.private_key.get_secret_value(),
)
return token_bytes.decode("utf-8")
class BearerAuthProvider(OAuthProvider):
"""
Simple JWT Bearer Token validator for hosted MCP servers.
Uses RS256 asymmetric encryption. Supports either static public key
or JWKS URI for key rotation.
Note that this provider DOES NOT permit client registration or revocation, or any OAuth flows.
It is intended to be used with a control plane that manages clients and tokens.
"""
def __init__(
self,
public_key: str | None = None,
jwks_uri: str | None = None,
issuer: str | None = None,
audience: str | None = None,
required_scopes: list[str] | None = None,
):
"""
Initialize the provider. Either public_key or jwks_uri must be provided.
Args:
public_key: RSA public key in PEM format (for static key)
jwks_uri: URI to fetch keys from (for key rotation)
issuer: Expected issuer claim (optional)
audience: Expected audience claim (optional)
required_scopes: List of required scopes for access (optional)
"""
if not (public_key or jwks_uri):
raise ValueError("Either public_key or jwks_uri must be provided")
if public_key and jwks_uri:
raise ValueError("Provide either public_key or jwks_uri, not both")
super().__init__(
issuer_url=issuer or "https://fastmcp.example.com",
client_registration_options=ClientRegistrationOptions(enabled=False),
revocation_options=RevocationOptions(enabled=False),
required_scopes=required_scopes,
)
self.issuer = issuer
self.audience = audience
self.public_key = public_key
self.jwks_uri = jwks_uri
self.jwt = JsonWebToken(["RS256"])
# Simple JWKS cache
self._jwks_cache: dict[str, str] = {}
self._jwks_cache_time: float = 0
self._cache_ttl = 3600 # 1 hour
async def _get_verification_key(self, token: str) -> str:
"""Get the verification key for the token."""
if self.public_key:
return self.public_key
# Extract kid from token header for JWKS lookup
try:
import base64
import json
header_b64 = token.split(".")[0]
header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
header = json.loads(base64.urlsafe_b64decode(header_b64))
kid = header.get("kid")
return await self._get_jwks_key(kid)
except Exception as e:
raise ValueError(f"Failed to extract key ID from token: {e}")
async def _get_jwks_key(self, kid: str | None) -> str:
"""Fetch key from JWKS with simple caching."""
if not self.jwks_uri:
raise ValueError("JWKS URI not configured")
current_time = time.time()
# Check cache first
if current_time - self._jwks_cache_time < self._cache_ttl:
if kid and kid in self._jwks_cache:
return self._jwks_cache[kid]
elif not kid and len(self._jwks_cache) == 1:
# If no kid but only one key cached, use it
return next(iter(self._jwks_cache.values()))
# Fetch JWKS
try:
async with httpx.AsyncClient() as client:
response = await client.get(self.jwks_uri)
response.raise_for_status()
jwks_data = response.json()
# Cache all keys
self._jwks_cache = {}
for key_data in jwks_data.get("keys", []):
key_kid = key_data.get("kid")
jwk = JsonWebKey.import_key(key_data)
public_key = jwk.get_public_key() # type: ignore
if key_kid:
self._jwks_cache[key_kid] = public_key
else:
# Key without kid - use a default identifier
self._jwks_cache["_default"] = public_key
self._jwks_cache_time = current_time
# Select the appropriate key
if kid:
if kid not in self._jwks_cache:
raise ValueError(f"Key ID '{kid}' not found in JWKS")
return self._jwks_cache[kid]
else:
# No kid in token - only allow if there's exactly one key
if len(self._jwks_cache) == 1:
return next(iter(self._jwks_cache.values()))
elif len(self._jwks_cache) > 1:
raise ValueError(
"Multiple keys in JWKS but no key ID (kid) in token"
)
else:
raise ValueError("No keys found in JWKS")
except Exception as e:
raise ValueError(f"Failed to fetch JWKS: {e}")
async def load_access_token(self, token: str) -> AccessToken | None:
"""
Validates the provided JWT bearer token.
Args:
token: The JWT token string to validate
Returns:
AccessToken object if valid, None if invalid or expired
"""
try:
# Get verification key (static or from JWKS)
verification_key = await self._get_verification_key(token)
# Decode and verify the JWT token
claims = self.jwt.decode(token, verification_key)
# Validate expiration
exp = claims.get("exp")
if exp and exp < time.time():
return None
# 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:
return None
# Validate audience if configured
if self.audience:
aud = claims.get("aud")
if isinstance(aud, list):
if self.audience not in aud:
return None
elif aud != self.audience:
return None
# Extract claims - prefer client_id over sub for OAuth application identification
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
scopes = self._extract_scopes(claims)
return AccessToken(
token=token,
client_id=str(client_id),
scopes=scopes,
expires_at=int(exp) if exp else None,
)
except JoseError:
return None
except Exception:
return None
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
"""Extract scopes from JWT claims."""
scope_claim = claims.get("scope", "")
if isinstance(scope_claim, str):
return scope_claim.split()
elif isinstance(scope_claim, list):
return scope_claim
return []
# --- Unused OAuth server methods ---
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
raise NotImplementedError("Client management not supported")
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
raise NotImplementedError("Client registration not supported")
async def authorize(
self, client: OAuthClientInformationFull, params: AuthorizationParams
) -> str:
raise NotImplementedError("Authorization flow not supported")
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
raise NotImplementedError("Authorization code flow not supported")
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
raise NotImplementedError("Authorization code exchange not supported")
async def load_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: str
) -> RefreshToken | None:
raise NotImplementedError("Refresh token flow not supported")
async def exchange_refresh_token(
self,
client: OAuthClientInformationFull,
refresh_token: RefreshToken,
scopes: list[str],
) -> OAuthToken:
raise NotImplementedError("Refresh token exchange not supported")
async def revoke_token(
self,
token: AccessToken | RefreshToken,
) -> None:
raise NotImplementedError("Token revocation not supported")

View file

@ -0,0 +1,62 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
# Sentinel object to indicate that a setting is not set
class _NotSet:
pass
class EnvBearerAuthProviderSettings(BaseSettings):
"""Settings for the BearerAuthProvider."""
model_config = SettingsConfigDict(
env_prefix="FASTMCP_AUTH_BEARER_",
env_file=".env",
extra="ignore",
)
public_key: str | None = None
jwks_uri: str | None = None
issuer: str | None = None
audience: str | None = None
required_scopes: list[str] | None = None
class EnvBearerAuthProvider(BearerAuthProvider):
"""
A BearerAuthProvider that loads settings from environment variables. Any
providing setting will always take precedence over the environment
variables.
"""
def __init__(
self,
public_key: str | None | type[_NotSet] = _NotSet,
jwks_uri: str | None | type[_NotSet] = _NotSet,
issuer: str | None | type[_NotSet] = _NotSet,
audience: str | None | type[_NotSet] = _NotSet,
required_scopes: list[str] | None | type[_NotSet] = _NotSet,
):
"""
Initialize the provider.
Args:
public_key: RSA public key in PEM format (for static key)
jwks_uri: URI to fetch keys from (for key rotation)
issuer: Expected issuer claim (optional)
audience: Expected audience claim (optional)
required_scopes: List of required scopes for access (optional)
"""
kwargs = {
"public_key": public_key,
"jwks_uri": jwks_uri,
"issuer": issuer,
"audience": audience,
"required_scopes": required_scopes,
}
settings = EnvBearerAuthProviderSettings(
**{k: v for k, v in kwargs.items() if v is not _NotSet}
)
super().__init__(**settings.model_dump())

View file

@ -0,0 +1,330 @@
"""
This is a simple in-memory OAuth provider for testing purposes.
It simulates the OAuth 2.0 flow locally without external calls.
"""
import secrets
import time
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
AuthorizeError,
RefreshToken,
TokenError,
construct_redirect_uri,
)
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthToken,
)
from pydantic import AnyHttpUrl
from fastmcp.server.auth.auth import (
ClientRegistrationOptions,
OAuthProvider,
RevocationOptions,
)
# Default expiration times (in seconds)
DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry
class InMemoryOAuthProvider(OAuthProvider):
"""
An in-memory OAuth provider for testing purposes.
It simulates the OAuth 2.0 flow locally without external calls.
"""
def __init__(
self,
issuer_url: AnyHttpUrl | str | None = None,
service_documentation_url: AnyHttpUrl | str | None = None,
client_registration_options: ClientRegistrationOptions | None = None,
revocation_options: RevocationOptions | None = None,
required_scopes: list[str] | None = None,
):
super().__init__(
issuer_url=issuer_url or "http://fastmcp.example.com",
service_documentation_url=service_documentation_url,
client_registration_options=client_registration_options,
revocation_options=revocation_options,
required_scopes=required_scopes,
)
self.clients: dict[str, OAuthClientInformationFull] = {}
self.auth_codes: dict[str, AuthorizationCode] = {}
self.access_tokens: dict[str, AccessToken] = {}
self.refresh_tokens: dict[str, RefreshToken] = {}
# For revoking associated tokens
self._access_to_refresh_map: dict[
str, str
] = {} # access_token_str -> refresh_token_str
self._refresh_to_access_map: dict[
str, str
] = {} # refresh_token_str -> access_token_str
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
if client_info.client_id in self.clients:
# As per RFC 7591, if client_id is already known, it's an update.
# For this simple provider, we'll treat it as re-registration.
# A real provider might handle updates or raise errors for conflicts.
pass
self.clients[client_info.client_id] = client_info
async def authorize(
self, client: OAuthClientInformationFull, params: AuthorizationParams
) -> str:
"""
Simulates user authorization and generates an authorization code.
Returns a redirect URI with the code and state.
"""
if client.client_id not in self.clients:
raise AuthorizeError(
error="unauthorized_client",
error_description=f"Client '{client.client_id}' not registered.",
)
# Validate redirect_uri (already validated by AuthorizationHandler, but good practice)
try:
# OAuthClientInformationFull should have a method like validate_redirect_uri
# For this test provider, we assume it's valid if it matches one in client_info
# The AuthorizationHandler already does robust validation using client.validate_redirect_uri
if params.redirect_uri not in client.redirect_uris:
# This check might be too simplistic if redirect_uris can be patterns
# or if params.redirect_uri is None and client has a default.
# However, the AuthorizationHandler handles the primary validation.
pass # Let's assume AuthorizationHandler did its job.
except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
raise AuthorizeError(
error="invalid_request", error_description="Invalid redirect_uri."
)
auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
# Ensure scopes are a list
scopes_list = params.scopes if params.scopes is not None else []
if client.scope: # Filter params.scopes against client's registered scopes
client_allowed_scopes = set(client.scope.split())
scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
auth_code = AuthorizationCode(
code=auth_code_value,
client_id=client.client_id,
redirect_uri=params.redirect_uri,
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
scopes=scopes_list,
expires_at=expires_at,
code_challenge=params.code_challenge,
# code_challenge_method is assumed S256 by the framework
)
self.auth_codes[auth_code_value] = auth_code
return construct_redirect_uri(
str(params.redirect_uri), code=auth_code_value, state=params.state
)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
auth_code_obj = self.auth_codes.get(authorization_code)
if auth_code_obj:
if auth_code_obj.client_id != client.client_id:
return None # Belongs to a different client
if auth_code_obj.expires_at < time.time():
del self.auth_codes[authorization_code] # Expired
return None
return auth_code_obj
return None
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
# Authorization code should have been validated (existence, expiry, client_id match)
# by the TokenHandler calling load_authorization_code before this.
# We might want to re-verify or simply trust it's valid.
if authorization_code.code not in self.auth_codes:
raise TokenError(
"invalid_grant", "Authorization code not found or already used."
)
# Consume the auth code
del self.auth_codes[authorization_code.code]
access_token_value = f"test_access_token_{secrets.token_hex(32)}"
refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
# Refresh token expiry
refresh_token_expires_at = None
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
refresh_token_expires_at = int(
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
)
self.access_tokens[access_token_value] = AccessToken(
token=access_token_value,
client_id=client.client_id,
scopes=authorization_code.scopes,
expires_at=access_token_expires_at,
)
self.refresh_tokens[refresh_token_value] = RefreshToken(
token=refresh_token_value,
client_id=client.client_id,
scopes=authorization_code.scopes, # Refresh token inherits scopes
expires_at=refresh_token_expires_at,
)
self._access_to_refresh_map[access_token_value] = refresh_token_value
self._refresh_to_access_map[refresh_token_value] = access_token_value
return OAuthToken(
access_token=access_token_value,
token_type="bearer",
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token=refresh_token_value,
scope=" ".join(authorization_code.scopes),
)
async def load_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: str
) -> RefreshToken | None:
token_obj = self.refresh_tokens.get(refresh_token)
if token_obj:
if token_obj.client_id != client.client_id:
return None # Belongs to different client
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
self._revoke_internal(
refresh_token_str=token_obj.token
) # Clean up expired
return None
return token_obj
return None
async def exchange_refresh_token(
self,
client: OAuthClientInformationFull,
refresh_token: RefreshToken, # This is the RefreshToken object, already loaded
scopes: list[str], # Requested scopes for the new access token
) -> OAuthToken:
# Validate scopes: requested scopes must be a subset of original scopes
original_scopes = set(refresh_token.scopes)
requested_scopes = set(scopes)
if not requested_scopes.issubset(original_scopes):
raise TokenError(
"invalid_scope",
"Requested scopes exceed those authorized by the refresh token.",
)
# Invalidate old refresh token and its associated access token (rotation)
self._revoke_internal(refresh_token_str=refresh_token.token)
# Issue new tokens
new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
# Refresh token expiry
refresh_token_expires_at = None
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
refresh_token_expires_at = int(
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
)
self.access_tokens[new_access_token_value] = AccessToken(
token=new_access_token_value,
client_id=client.client_id,
scopes=scopes, # Use newly requested (and validated) scopes
expires_at=access_token_expires_at,
)
self.refresh_tokens[new_refresh_token_value] = RefreshToken(
token=new_refresh_token_value,
client_id=client.client_id,
scopes=scopes, # New refresh token also gets these scopes
expires_at=refresh_token_expires_at,
)
self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
return OAuthToken(
access_token=new_access_token_value,
token_type="bearer",
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token=new_refresh_token_value,
scope=" ".join(scopes),
)
async def load_access_token(self, token: str) -> AccessToken | None:
token_obj = self.access_tokens.get(token)
if token_obj:
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
self._revoke_internal(
access_token_str=token_obj.token
) # Clean up expired
return None
return token_obj
return None
def _revoke_internal(
self, access_token_str: str | None = None, refresh_token_str: str | None = None
):
"""Internal helper to remove tokens and their associations."""
removed_access_token = None
removed_refresh_token = None
if access_token_str:
if access_token_str in self.access_tokens:
del self.access_tokens[access_token_str]
removed_access_token = access_token_str
# Get associated refresh token
associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
if associated_refresh:
if associated_refresh in self.refresh_tokens:
del self.refresh_tokens[associated_refresh]
removed_refresh_token = associated_refresh
self._refresh_to_access_map.pop(associated_refresh, None)
if refresh_token_str:
if refresh_token_str in self.refresh_tokens:
del self.refresh_tokens[refresh_token_str]
removed_refresh_token = refresh_token_str
# Get associated access token
associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
if associated_access:
if associated_access in self.access_tokens:
del self.access_tokens[associated_access]
removed_access_token = associated_access
self._access_to_refresh_map.pop(associated_access, None)
# Clean up any dangling references if one part of the pair was already gone
if removed_access_token and removed_access_token in self._access_to_refresh_map:
del self._access_to_refresh_map[removed_access_token]
if (
removed_refresh_token
and removed_refresh_token in self._refresh_to_access_map
):
del self._refresh_to_access_map[removed_refresh_token]
async def revoke_token(
self,
token: AccessToken | RefreshToken,
) -> None:
"""Revokes an access or refresh token and its counterpart."""
if isinstance(token, AccessToken):
self._revoke_internal(access_token_str=token.token)
elif isinstance(token, RefreshToken):
self._revoke_internal(refresh_token_str=token.token)
# If token is not found or already revoked, _revoke_internal does nothing, which is correct.

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, ParamSpec, TypeVar
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from starlette.requests import Request
if TYPE_CHECKING:
@ -10,6 +12,14 @@ if TYPE_CHECKING:
P = ParamSpec("P")
R = TypeVar("R")
__all__ = [
"get_context",
"get_http_request",
"get_http_headers",
"get_access_token",
"AccessToken",
]
# --- Context ---

View file

@ -10,14 +10,7 @@ from mcp.server.auth.middleware.bearer_auth import (
BearerAuthBackend,
RequireAuthMiddleware,
)
from mcp.server.auth.provider import (
AccessTokenT,
AuthorizationCodeT,
OAuthAuthorizationServerProvider,
RefreshTokenT,
)
from mcp.server.auth.routes import create_auth_routes
from mcp.server.auth.settings import AuthSettings
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@ -29,6 +22,7 @@ from starlette.responses import Response
from starlette.routing import BaseRoute, Mount, Route
from starlette.types import Lifespan, Receive, Scope, Send
from fastmcp.server.auth.auth import OAuthProvider
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@ -75,17 +69,12 @@ class RequestContextMiddleware:
def setup_auth_middleware_and_routes(
auth_server_provider: OAuthAuthorizationServerProvider[
AuthorizationCodeT, RefreshTokenT, AccessTokenT
]
| None,
auth_settings: AuthSettings | None,
auth: OAuthProvider,
) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
"""Set up authentication middleware and routes if auth is enabled.
Args:
auth_server_provider: The OAuth authorization server provider
auth_settings: The auth settings
auth: The OAuthProvider authorization server provider
Returns:
Tuple of (middleware, auth_routes, required_scopes)
@ -94,31 +83,25 @@ def setup_auth_middleware_and_routes(
auth_routes: list[BaseRoute] = []
required_scopes: list[str] = []
if auth_server_provider:
if not auth_settings:
raise ValueError(
"auth_settings must be provided when auth_server_provider is specified"
)
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(provider=auth),
),
Middleware(AuthContextMiddleware),
]
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(provider=auth_server_provider),
),
Middleware(AuthContextMiddleware),
]
required_scopes = auth.required_scopes or []
required_scopes = auth_settings.required_scopes or []
auth_routes.extend(
create_auth_routes(
provider=auth_server_provider,
issuer_url=auth_settings.issuer_url,
service_documentation_url=auth_settings.service_documentation_url,
client_registration_options=auth_settings.client_registration_options,
revocation_options=auth_settings.revocation_options,
)
auth_routes.extend(
create_auth_routes(
provider=auth,
issuer_url=auth.issuer_url,
service_documentation_url=auth.service_documentation_url,
client_registration_options=auth.client_registration_options,
revocation_options=auth.revocation_options,
)
)
return middleware, auth_routes, required_scopes
@ -155,11 +138,7 @@ def create_sse_app(
server: FastMCP[LifespanResultT],
message_path: str,
sse_path: str,
auth_server_provider: OAuthAuthorizationServerProvider[
AuthorizationCodeT, RefreshTokenT, AccessTokenT
]
| None = None,
auth_settings: AuthSettings | None = None,
auth: OAuthProvider | None = None,
debug: bool = False,
routes: list[BaseRoute] | None = None,
middleware: list[Middleware] | None = None,
@ -170,8 +149,7 @@ def create_sse_app(
server: The FastMCP server instance
message_path: Path for SSE messages
sse_path: Path for SSE connections
auth_server_provider: Optional auth provider
auth_settings: Optional auth settings
auth: Optional auth provider
debug: Whether to enable debug mode
routes: Optional list of custom routes
middleware: Optional list of middleware
@ -196,15 +174,15 @@ def create_sse_app(
return Response()
# Get auth middleware and routes
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
auth_server_provider, auth_settings
)
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)
# Add SSE routes with or without auth
if auth_server_provider:
if auth:
auth_middleware, auth_routes, required_scopes = (
setup_auth_middleware_and_routes(auth)
)
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
server_routes.append(
Route(
@ -264,11 +242,7 @@ def create_streamable_http_app(
server: FastMCP[LifespanResultT],
streamable_http_path: str,
event_store: None = None,
auth_server_provider: OAuthAuthorizationServerProvider[
AuthorizationCodeT, RefreshTokenT, AccessTokenT
]
| None = None,
auth_settings: AuthSettings | None = None,
auth: OAuthProvider | None = None,
json_response: bool = False,
stateless_http: bool = False,
debug: bool = False,
@ -281,8 +255,7 @@ def create_streamable_http_app(
server: The FastMCP server instance
streamable_http_path: Path for StreamableHTTP connections
event_store: Optional event store for session management
auth_server_provider: Optional auth provider
auth_settings: Optional auth settings
auth: Optional auth provider
json_response: Whether to use JSON response format
stateless_http: Whether to use stateless mode (new transport per request)
debug: Whether to enable debug mode
@ -331,16 +304,15 @@ def create_streamable_http_app(
# Re-raise other RuntimeErrors if they don't match the specific message
raise
# Get auth middleware and routes
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
auth_server_provider, auth_settings
)
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)
# Add StreamableHTTP routes with or without auth
if auth_server_provider:
if auth:
auth_middleware, auth_routes, required_scopes = (
setup_auth_middleware_and_routes(auth)
)
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
server_routes.append(
Mount(

View file

@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
import anyio
import httpx
import uvicorn
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
from mcp.server.lowlevel.server import Server as MCPServer
@ -48,6 +47,8 @@ from fastmcp.prompts import Prompt, PromptManager
from fastmcp.prompts.prompt import PromptResult
from fastmcp.resources import Resource, ResourceManager
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.auth import OAuthProvider
from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
from fastmcp.server.http import (
StarletteWithLifespan,
create_sse_app,
@ -110,8 +111,7 @@ class FastMCP(Generic[LifespanResultT]):
self,
name: str | None = None,
instructions: str | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
| None = None,
auth: OAuthProvider | None = None,
lifespan: (
Callable[
[FastMCP[LifespanResultT]],
@ -128,6 +128,7 @@ class FastMCP(Generic[LifespanResultT]):
on_duplicate_prompts: DuplicateBehavior | None = None,
resource_prefix_format: Literal["protocol", "path"] | None = None,
mask_error_details: bool | None = None,
tools: list[Tool | Callable[..., Any]] | None = None,
**settings: Any,
):
if settings:
@ -186,13 +187,16 @@ class FastMCP(Generic[LifespanResultT]):
lifespan=_lifespan_wrapper(self, lifespan),
)
if (self.settings.auth is not None) != (auth_server_provider is not None):
# TODO: after we support separate authorization servers (see
raise ValueError(
"settings.auth must be specified if and only if auth_server_provider "
"is specified"
)
self._auth_server_provider = auth_server_provider
if auth is None and self.settings.default_auth_provider == "bearer_env":
auth = EnvBearerAuthProvider()
self.auth = auth
if tools:
for tool in tools:
if isinstance(tool, Tool):
self._tool_manager.add_tool(tool)
else:
self.add_tool(tool)
# Set up MCP protocol handlers
self._setup_handlers()
@ -907,8 +911,7 @@ class FastMCP(Generic[LifespanResultT]):
server=self,
message_path=message_path or self.settings.message_path,
sse_path=path or self.settings.sse_path,
auth_server_provider=self._auth_server_provider,
auth_settings=self.settings.auth,
auth=self.auth,
debug=self.settings.debug,
middleware=middleware,
)
@ -955,8 +958,7 @@ class FastMCP(Generic[LifespanResultT]):
server=self,
streamable_http_path=path or self.settings.streamable_http_path,
event_store=None,
auth_server_provider=self._auth_server_provider,
auth_settings=self.settings.auth,
auth=self.auth,
json_response=self.settings.json_response,
stateless_http=self.settings.stateless_http,
debug=self.settings.debug,
@ -967,8 +969,7 @@ class FastMCP(Generic[LifespanResultT]):
server=self,
message_path=self.settings.message_path,
sse_path=path or self.settings.sse_path,
auth_server_provider=self._auth_server_provider,
auth_settings=self.settings.auth,
auth=self.auth,
debug=self.settings.debug,
middleware=middleware,
)

View file

@ -1,16 +1,16 @@
from __future__ import annotations as _annotations
import inspect
from typing import TYPE_CHECKING, Annotated, Literal
from pathlib import Path
from typing import Annotated, Literal
from mcp.server.auth.settings import AuthSettings
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
)
from typing_extensions import Self
if TYPE_CHECKING:
pass
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
@ -27,6 +27,8 @@ class Settings(BaseSettings):
nested_model_default_partial_update=True,
)
home: Path = Path.home() / ".fastmcp"
test_mode: bool = False
log_level: LOG_LEVEL = "INFO"
enable_rich_tracebacks: Annotated[
@ -171,13 +173,30 @@ class ServerSettings(BaseSettings):
# cache settings (for checking mounted servers)
cache_expiration_seconds: float = 0
auth: AuthSettings | None = None
# StreamableHTTP settings
json_response: bool = False
stateless_http: bool = (
False # If True, uses true stateless mode (new transport per request)
)
# Auth settings
default_auth_provider: Annotated[
Literal["bearer_env"] | None,
Field(
description=inspect.cleandoc(
"""
Configure the authentication provider. This setting is intended only to
be used for remote confirugation of providers that fully support
environment variable configuration.
If None, no automatic configuration will take place.
This setting is *always* overriden by any auth provider passed to the
FastMCP constructor.
"""
),
),
] = None
settings = Settings()

View file

@ -0,0 +1,8 @@
import socket
def find_available_port() -> int:
"""Find an available port by letting the OS assign one."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]

View file

@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal
import uvicorn
from fastmcp.settings import settings
from fastmcp.utilities.http import find_available_port
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
@ -71,30 +72,38 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
@contextmanager
def run_server_in_process(
server_fn: Callable[..., None], *args
server_fn: Callable[..., None],
*args,
provide_host_and_port: bool = True,
**kwargs,
) -> Generator[str, None, None]:
"""
Context manager that runs a Starlette app in a separate process and returns the
server URL. When the context manager is exited, the server process is killed.
Context manager that runs a FastMCP server in a separate process and
returns the server URL. When the context manager is exited, the server process is killed.
Args:
app: The Starlette app to run.
server_fn: The function that runs a FastMCP server. FastMCP servers are
not pickleable, so we need a function that creates and runs one.
*args: Arguments to pass to the server function.
provide_host_and_port: Whether to provide the host and port to the server function as kwargs.
**kwargs: Keyword arguments to pass to the server function.
Returns:
The server URL.
"""
host = "127.0.0.1"
with socket.socket() as s:
s.bind((host, 0))
port = s.getsockname()[1]
port = find_available_port()
if provide_host_and_port:
kwargs |= {"host": host, "port": port}
proc = multiprocessing.Process(
target=server_fn, args=(host, port, *args), daemon=True
target=server_fn, args=args, kwargs=kwargs, daemon=True
)
proc.start()
# Wait for server to be running
max_attempts = 100
max_attempts = 10
attempt = 0
while attempt < max_attempts and proc.is_alive():
try:
@ -102,7 +111,10 @@ def run_server_in_process(
s.connect((host, port))
break
except ConnectionRefusedError:
time.sleep(0.01)
if attempt < 3:
time.sleep(0.01)
else:
time.sleep(0.1)
attempt += 1
else:
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")

View file

@ -0,0 +1,635 @@
from collections.abc import Generator
from typing import Any
import httpx
import pytest
from pytest_httpx import HTTPXMock
from fastmcp import Client, FastMCP
from fastmcp.client.auth import BearerAuth
from fastmcp.server.auth.providers.bearer import (
BearerAuthProvider,
JWKData,
JWKSData,
RSAKeyPair,
)
from fastmcp.utilities.tests import run_server_in_process
@pytest.fixture(scope="module")
def rsa_key_pair() -> RSAKeyPair:
return RSAKeyPair.generate()
@pytest.fixture(scope="module")
def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
return rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
@pytest.fixture
def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
return BearerAuthProvider(
public_key=rsa_key_pair.public_key,
issuer="https://test.example.com",
audience="https://api.example.com",
)
def run_mcp_server(
public_key: str,
host: str,
port: int,
auth_kwargs: dict[str, Any] | None = None,
run_kwargs: dict[str, Any] | None = None,
) -> None:
mcp = FastMCP(
auth=BearerAuthProvider(
public_key=public_key,
**auth_kwargs or {},
)
)
@mcp.tool()
def add(a: int, b: int) -> int:
return a + b
mcp.run(host=host, port=port, **run_kwargs or {})
@pytest.fixture(scope="module")
def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
with run_server_in_process(
run_mcp_server,
public_key=rsa_key_pair.public_key,
run_kwargs=dict(transport="streamable-http"),
) as url:
yield f"{url}/mcp"
class TestRSAKeyPair:
def test_generate_key_pair(self):
"""Test RSA key pair generation."""
key_pair = RSAKeyPair.generate()
assert key_pair.private_key is not None
assert key_pair.public_key is not None
# Check that keys are in PEM format
private_pem = key_pair.private_key.get_secret_value()
public_pem = key_pair.public_key
assert "-----BEGIN PRIVATE KEY-----" in private_pem
assert "-----END PRIVATE KEY-----" in private_pem
assert "-----BEGIN PUBLIC KEY-----" in public_pem
assert "-----END PUBLIC KEY-----" in public_pem
def test_create_basic_token(self, rsa_key_pair: RSAKeyPair):
"""Test basic token creation."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
)
assert isinstance(token, str)
assert len(token.split(".")) == 3 # JWT has 3 parts
def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair):
"""Test token creation with scopes."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
scopes=["read", "write"],
)
assert isinstance(token, str)
# We'll validate the scopes in the BearerToken tests
class TestBearerTokenJWKS:
"""Tests for JWKS URI functionality."""
@pytest.fixture
def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
"""Provider configured with JWKS URI."""
return BearerAuthProvider(
jwks_uri="https://test.example.com/.well-known/jwks.json",
issuer="https://test.example.com",
audience="https://api.example.com",
)
@pytest.fixture
def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData:
"""Create mock JWKS data from RSA key pair."""
from authlib.jose import JsonWebKey
# Create JWK from the RSA public key
jwk = JsonWebKey.import_key(rsa_key_pair.public_key) # type: ignore
jwk_data: JWKData = jwk.as_dict() # type: ignore
jwk_data["kid"] = "test-key-1"
jwk_data["alg"] = "RS256"
return {"keys": [jwk_data]}
async def test_jwks_token_validation(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: BearerAuthProvider,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
):
"""Test token validation using JWKS URI."""
httpx_mock.add_response(
url="https://test.example.com/.well-known/jwks.json",
json=mock_jwks_data,
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is not None
assert access_token.client_id == "test-user"
async def test_jwks_token_validation_with_invalid_key(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: BearerAuthProvider,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
):
httpx_mock.add_response(
url="https://test.example.com/.well-known/jwks.json",
json=mock_jwks_data,
)
token = RSAKeyPair.generate().create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is None
async def test_jwks_token_validation_with_kid(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: BearerAuthProvider,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
httpx_mock.add_response(
url="https://test.example.com/.well-known/jwks.json",
json=mock_jwks_data,
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
kid="test-key-1",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is not None
assert access_token.client_id == "test-user"
async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: BearerAuthProvider,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
httpx_mock.add_response(
url="https://test.example.com/.well-known/jwks.json",
json=mock_jwks_data,
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is not None
assert access_token.client_id == "test-user"
async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: BearerAuthProvider,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
httpx_mock.add_response(
url="https://test.example.com/.well-known/jwks.json",
json=mock_jwks_data,
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is not None
assert access_token.client_id == "test-user"
async def test_jwks_token_validation_with_kid_mismatch(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: BearerAuthProvider,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
httpx_mock.add_response(
url="https://test.example.com/.well-known/jwks.json",
json=mock_jwks_data,
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
kid="test-key-2",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is None
async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: BearerAuthProvider,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
):
mock_jwks_data["keys"] = [
{
"kid": "test-key-1",
"alg": "RS256",
},
{
"kid": "test-key-2",
"alg": "RS256",
},
]
httpx_mock.add_response(
url="https://test.example.com/.well-known/jwks.json",
json=mock_jwks_data,
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
access_token = await jwks_provider.load_access_token(token)
assert access_token is None
class TestBearerToken:
def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
"""Test provider initialization with public key."""
provider = BearerAuthProvider(
public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
)
assert provider.issuer == "https://test.example.com"
assert provider.public_key is not None
assert provider.jwks_uri is None
def test_initialization_with_jwks_uri(self):
"""Test provider initialization with JWKS URI."""
provider = BearerAuthProvider(
jwks_uri="https://test.example.com/.well-known/jwks.json",
issuer="https://test.example.com",
)
assert provider.issuer == "https://test.example.com"
assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
assert provider.public_key is None
def test_initialization_requires_key_or_uri(self):
"""Test that either public_key or jwks_uri is required."""
with pytest.raises(
ValueError, match="Either public_key or jwks_uri must be provided"
):
BearerAuthProvider(issuer="https://test.example.com")
def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
"""Test that both public_key and jwks_uri cannot be provided."""
with pytest.raises(
ValueError, match="Provide either public_key or jwks_uri, not both"
):
BearerAuthProvider(
public_key=rsa_key_pair.public_key,
jwks_uri="https://test.example.com/.well-known/jwks.json",
issuer="https://test.example.com",
)
async def test_valid_token_validation(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test validation of a valid token."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
scopes=["read", "write"],
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is not None
assert access_token.client_id == "test-user"
assert "read" in access_token.scopes
assert "write" in access_token.scopes
assert access_token.expires_at is not None
async def test_expired_token_rejection(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test rejection of expired tokens."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
expires_in_seconds=-3600, # Expired 1 hour ago
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is None
async def test_invalid_issuer_rejection(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test rejection of tokens with invalid issuer."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://evil.example.com", # Wrong issuer
audience="https://api.example.com",
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is None
async def test_invalid_audience_rejection(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test rejection of tokens with invalid audience."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://wrong-api.example.com", # Wrong audience
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is None
async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
"""Test that issuer validation is skipped when provider has no issuer configured."""
provider = BearerAuthProvider(
public_key=rsa_key_pair.public_key,
issuer=None, # No issuer validation
)
token = rsa_key_pair.create_token(
subject="test-user", issuer="https://any.example.com"
)
access_token = await provider.load_access_token(token)
assert access_token is not None
async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
"""Test that audience validation is skipped when provider has no audience configured."""
provider = BearerAuthProvider(
public_key=rsa_key_pair.public_key,
issuer="https://test.example.com",
audience=None, # No audience validation
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://any-api.example.com",
)
access_token = await provider.load_access_token(token)
assert access_token is not None
async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
"""Test validation with multiple audiences in token."""
provider = BearerAuthProvider(
public_key=rsa_key_pair.public_key,
issuer="https://test.example.com",
audience="https://api.example.com",
)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
additional_claims={
"aud": ["https://api.example.com", "https://other-api.example.com"]
},
)
access_token = await provider.load_access_token(token)
assert access_token is not None
async def test_scope_extraction_string(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test scope extraction from space-separated string."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
scopes=["read", "write", "admin"],
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is not None
assert set(access_token.scopes) == {"read", "write", "admin"}
async def test_scope_extraction_list(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test scope extraction from list format."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
additional_claims={"scope": ["read", "write"]}, # List format
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is not None
assert set(access_token.scopes) == {"read", "write"}
async def test_no_scopes(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test token with no scopes."""
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
# No scopes
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is not None
assert access_token.scopes == []
async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider):
"""Test rejection of malformed tokens."""
malformed_tokens = [
"not.a.jwt",
"too.many.parts.here.invalid",
"invalid-token",
"",
"header.body", # Missing signature
]
for token in malformed_tokens:
access_token = await bearer_provider.load_access_token(token)
assert access_token is None
async def test_invalid_signature_rejection(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test rejection of tokens with invalid signatures."""
# Create a token with a different key pair
other_key_pair = RSAKeyPair.generate()
token = other_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is None
async def test_client_id_fallback(
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
):
"""Test client_id extraction with fallback logic."""
# Test with explicit client_id claim
token = rsa_key_pair.create_token(
subject="user123",
issuer="https://test.example.com",
audience="https://api.example.com",
additional_claims={"client_id": "app456"},
)
access_token = await bearer_provider.load_access_token(token)
assert access_token is not None
assert access_token.client_id == "app456" # Should prefer client_id over sub
class TestFastMCPBearerAuth:
def test_bearer_auth(self):
mcp = FastMCP(
auth=BearerAuthProvider(issuer="https://test.example.com", public_key="abc")
)
assert isinstance(mcp.auth, BearerAuthProvider)
async def test_unauthorized_access(self, mcp_server_url: str):
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
async def test_authorized_access(self, mcp_server_url: str, bearer_token):
async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
tools = await client.list_tools() # noqa: F841
assert tools
async def test_invalid_token_raises_401(self, mcp_server_url: str):
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
tools = await client.list_tools() # noqa: F841
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
expires_in_seconds=-3600,
)
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools() # noqa: F841
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
async def test_token_with_bad_signature(self, mcp_server_url: str):
rsa_key_pair = RSAKeyPair.generate()
token = rsa_key_pair.create_token()
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools() # noqa: F841
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
async def test_token_with_insufficient_scopes(
self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
):
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
scopes=["read"],
)
with run_server_in_process(
run_mcp_server,
public_key=rsa_key_pair.public_key,
auth_kwargs=dict(required_scopes=["read", "write"]),
run_kwargs=dict(transport="streamable-http"),
) as url:
mcp_server_url = f"{url}/mcp"
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools() # noqa: F841
assert exc_info.value.response.status_code == 403
assert "tools" not in locals()
async def test_token_with_sufficient_scopes(
self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
):
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
scopes=["read", "write"],
)
with run_server_in_process(
run_mcp_server,
public_key=rsa_key_pair.public_key,
auth_kwargs=dict(required_scopes=["read", "write"]),
run_kwargs=dict(transport="streamable-http"),
) as url:
mcp_server_url = f"{url}/mcp"
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools()
assert tools

View file

@ -0,0 +1,82 @@
import pytest
from pydantic import AnyHttpUrl, ValidationError
from fastmcp import FastMCP
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
def test_load_bearer_env_from_env_var(monkeypatch):
mcp = FastMCP()
assert mcp.auth is None
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
mcp_with_auth = FastMCP()
assert isinstance(mcp_with_auth.auth, EnvBearerAuthProvider)
def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatch):
mcp = FastMCP()
assert mcp.auth is None
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
with pytest.raises(
ValueError, match="Either public_key or jwks_uri must be provided"
):
FastMCP()
def test_configure_bearer_env_from_env_var(monkeypatch):
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_ISSUER", "http://test-issuer")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_AUDIENCE", "test-audience")
monkeypatch.setenv(
"FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", '["test-scope1", "test-scope2"]'
)
mcp = FastMCP()
assert isinstance(mcp.auth, EnvBearerAuthProvider)
assert mcp.auth.public_key == "test-public-key"
assert mcp.auth.issuer_url == AnyHttpUrl("http://test-issuer")
assert mcp.auth.audience == "test-audience"
assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"]
def test_list_of_scopes_must_be_a_list(monkeypatch):
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1")
with pytest.raises(ValidationError, match="Input should be a valid list"):
FastMCP()
def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch):
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
mcp = FastMCP()
assert isinstance(mcp.auth, EnvBearerAuthProvider)
assert mcp.auth.jwks_uri == "test-jwks-uri"
def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"):
FastMCP()
def test_provided_auth_takes_precedence_over_env_vars(monkeypatch):
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2"))
assert isinstance(mcp.auth, BearerAuthProvider)
assert not isinstance(mcp.auth, EnvBearerAuthProvider)
assert mcp.auth.public_key == "test-public-key-2"

View file

@ -0,0 +1,265 @@
from collections.abc import Generator
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
import fastmcp.client.auth # Import module, not the function directly
from fastmcp.client import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.auth import ClientRegistrationOptions
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_in_process
def fastmcp_server(issuer_url: str):
"""Create a FastMCP server with OAuth authentication."""
server = FastMCP(
"TestServer",
auth=InMemoryOAuthProvider(
issuer_url=issuer_url,
client_registration_options=ClientRegistrationOptions(enabled=True),
),
)
@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@server.resource("resource://test")
def get_test_resource() -> str:
"""Get a test resource."""
return "Hello from authenticated resource!"
return server
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs)
@pytest.fixture(scope="module")
def streamable_http_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="streamable-http") as url:
yield f"{url}/mcp"
@pytest.fixture()
def client_unauthorized(streamable_http_server: str) -> Client:
return Client(transport=StreamableHttpTransport(streamable_http_server))
class HeadlessOAuthProvider(httpx.Auth):
"""
OAuth provider that bypasses browser interaction for testing.
This simulates the complete OAuth flow programmatically by:
1. Discovering OAuth metadata from the server
2. Registering a client
3. Getting an authorization code (simulates user approval)
4. Exchanging it for an access token
5. Adding Bearer token to all requests
This enables testing OAuth-protected FastMCP servers without
requiring browser interaction or external OAuth providers.
"""
def __init__(self, mcp_url: str):
self.mcp_url = mcp_url
parsed_url = urlparse(mcp_url)
self.server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
self._access_token = None
async def async_auth_flow(self, request):
"""httpx.Auth interface - add Bearer token to requests."""
if not self._access_token:
await self._obtain_token()
if self._access_token:
request.headers["Authorization"] = f"Bearer {self._access_token}"
yield request
async def _obtain_token(self):
"""Get a valid access token by simulating the OAuth flow."""
import base64
import hashlib
import secrets
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyHttpUrl
# Generate PKCE challenge/verifier
code_verifier = (
base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
)
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.decode()
.rstrip("=")
)
# Create HTTP client to talk to the server
async with httpx.AsyncClient() as http_client:
# 1. Discover OAuth metadata
metadata_url = (
f"{self.server_base_url}/.well-known/oauth-authorization-server"
)
response = await http_client.get(metadata_url)
response.raise_for_status()
metadata = response.json()
# 2. Register a client
client_info = OAuthClientInformationFull(
client_id="test_client_headless",
client_secret="test_secret_headless",
redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")],
)
register_response = await http_client.post(
metadata["registration_endpoint"],
json=client_info.model_dump(mode="json"),
)
register_response.raise_for_status()
registered_client = register_response.json()
# 3. Get authorization code (simulate user approval)
auth_params = {
"response_type": "code",
"client_id": registered_client["client_id"],
"redirect_uri": "http://localhost:8080/callback",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"state": "test_state_headless",
}
auth_response = await http_client.get(
metadata["authorization_endpoint"],
params=auth_params,
follow_redirects=False,
)
# Extract auth code from redirect
if auth_response.status_code == 302:
redirect_url = auth_response.headers["location"]
parsed = urlparse(redirect_url)
query_params = parse_qs(parsed.query)
if "error" in query_params:
error = query_params["error"][0]
error_desc = query_params.get(
"error_description", ["Unknown error"]
)[0]
raise RuntimeError(
f"OAuth authorization failed: {error} - {error_desc}"
)
auth_code = query_params["code"][0]
# 4. Exchange auth code for access token
token_data = {
"grant_type": "authorization_code",
"client_id": registered_client["client_id"],
"client_secret": registered_client["client_secret"],
"code": auth_code,
"redirect_uri": "http://localhost:8080/callback",
"code_verifier": code_verifier,
}
token_response = await http_client.post(
metadata["token_endpoint"], data=token_data
)
token_response.raise_for_status()
token_info = token_response.json()
self._access_token = token_info["access_token"]
else:
raise RuntimeError(f"Authorization failed: {auth_response.status_code}")
@pytest.fixture()
def client_with_headless_oauth(
streamable_http_server: str,
) -> Generator[Client, None, None]:
"""Client with headless OAuth that bypasses browser interaction."""
# Patch the OAuth function to return our headless provider
def headless_oauth(*args, **kwargs):
mcp_url = args[0] if args else kwargs.get("mcp_url", "")
if not mcp_url:
raise ValueError("mcp_url is required")
return HeadlessOAuthProvider(mcp_url)
with patch("fastmcp.client.auth.OAuth", side_effect=headless_oauth):
client = Client(
transport=StreamableHttpTransport(streamable_http_server),
auth=fastmcp.client.auth.OAuth(mcp_url=streamable_http_server),
)
yield client
async def test_unauthorized(client_unauthorized: Client):
"""Test that unauthenticated requests are rejected."""
with pytest.raises(httpx.HTTPStatusError, match="401 Unauthorized"):
async with client_unauthorized:
pass
async def test_ping(client_with_headless_oauth: Client):
"""Test that we can ping the server."""
async with client_with_headless_oauth:
assert await client_with_headless_oauth.ping()
async def test_list_tools(client_with_headless_oauth: Client):
"""Test that we can list tools."""
async with client_with_headless_oauth:
tools = await client_with_headless_oauth.list_tools()
tool_names = [tool.name for tool in tools]
assert "add" in tool_names
async def test_call_tool(client_with_headless_oauth: Client):
"""Test that we can call a tool."""
async with client_with_headless_oauth:
result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
assert result[0].text == "8" # type: ignore[attr-defined]
async def test_list_resources(client_with_headless_oauth: Client):
"""Test that we can list resources."""
async with client_with_headless_oauth:
resources = await client_with_headless_oauth.list_resources()
resource_uris = [str(resource.uri) for resource in resources]
assert "resource://test" in resource_uris
async def test_read_resource(client_with_headless_oauth: Client):
"""Test that we can read a resource."""
async with client_with_headless_oauth:
resource = await client_with_headless_oauth.read_resource("resource://test")
assert resource[0].text == "Hello from authenticated resource!" # type: ignore[attr-defined]
async def test_oauth_server_metadata_discovery(streamable_http_server: str):
"""Test that we can discover OAuth metadata from the running server."""
parsed_url = urlparse(streamable_http_server)
server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
async with httpx.AsyncClient() as client:
# Test OAuth discovery endpoint
metadata_url = f"{server_base_url}/.well-known/oauth-authorization-server"
response = await client.get(metadata_url)
assert response.status_code == 200
metadata = response.json()
assert "authorization_endpoint" in metadata
assert "token_endpoint" in metadata
assert "registration_endpoint" in metadata
# The endpoints should be properly formed URLs
assert metadata["authorization_endpoint"].startswith(server_base_url)
assert metadata["token_endpoint"].startswith(server_base_url)

0
tests/cli/__init__.py Normal file
View file

View file

@ -1 +0,0 @@
"""Client tests package."""

View file

@ -16,7 +16,6 @@ from fastmcp.client.transports import (
infer_transport,
)
from fastmcp.exceptions import ResourceError, ToolError
from fastmcp.prompts.prompt import TextContent
from fastmcp.server.server import FastMCP
@ -201,8 +200,7 @@ async def test_get_prompt(fastmcp_server):
result = await client.get_prompt("welcome", {"name": "Developer"})
# The result should contain our welcome message
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
assert result.description == "Example greeting prompt."
@ -214,8 +212,7 @@ async def test_get_prompt_mcp(fastmcp_server):
result = await client.get_prompt_mcp("welcome", {"name": "Developer"})
# The result should contain our welcome message
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
assert result.description == "Example greeting prompt."
@ -342,6 +339,52 @@ async def test_client_nested_context_manager(fastmcp_server):
assert client._session is None
async def test_concurrent_client_context_managers():
"""
Test that concurrent client usage doesn't cause cross-task cancel scope issues.
https://github.com/jlowin/fastmcp/pull/643
"""
# Create a simple server
server = FastMCP("Test Server")
@server.tool()
def echo(text: str) -> str:
"""Echo tool"""
return text
# Create client
client = Client(server)
# Track results
results = {}
errors = []
async def use_client(task_id: str, delay: float = 0):
"""Use the client with a small delay to ensure overlap"""
try:
async with client:
# Add a small delay to ensure contexts overlap
await asyncio.sleep(delay)
# Make an actual call to exercise the session
tools = await client.list_tools()
results[task_id] = len(tools)
except Exception as e:
errors.append((task_id, str(e)))
# Run multiple tasks concurrently
# The key is having them enter and exit the context at different times
await asyncio.gather(
use_client("task1", 0.0),
use_client("task2", 0.01), # Slight delay to ensure overlap
use_client("task3", 0.02),
return_exceptions=False,
)
assert len(errors) == 0, f"Errors occurred: {errors}"
assert len(results) == 3
assert all(count == 1 for count in results.values()) # All should see 1 tool
async def test_resource_template(fastmcp_server):
"""Test using a resource template with InMemoryClient."""
client = Client(transport=FastMCPTransport(fastmcp_server))
@ -476,9 +519,8 @@ class TestErrorHandling:
async with client:
result = await client.call_tool_mcp("error_tool", {})
assert result.isError
assert isinstance(result.content[0], TextContent)
assert "test error" in result.content[0].text
assert "abc" in result.content[0].text
assert "test error" in result.content[0].text # type: ignore[attr-defined]
assert "abc" in result.content[0].text # type: ignore[attr-defined]
async def test_general_tool_exceptions_are_masked_when_enabled(self):
mcp = FastMCP("TestServer", mask_error_details=True)
@ -492,9 +534,8 @@ class TestErrorHandling:
async with client:
result = await client.call_tool_mcp("error_tool", {})
assert result.isError
assert isinstance(result.content[0], TextContent)
assert "test error" not in result.content[0].text
assert "abc" not in result.content[0].text
assert "test error" not in result.content[0].text # type: ignore[attr-defined]
assert "abc" not in result.content[0].text # type: ignore[attr-defined]
async def test_specific_tool_errors_are_sent_to_client(self):
mcp = FastMCP("TestServer")
@ -508,9 +549,8 @@ class TestErrorHandling:
async with client:
result = await client.call_tool_mcp("custom_error_tool", {})
assert result.isError
assert isinstance(result.content[0], TextContent)
assert "test error" in result.content[0].text
assert "abc" in result.content[0].text
assert "test error" in result.content[0].text # type: ignore[attr-defined]
assert "abc" in result.content[0].text # type: ignore[attr-defined]
async def test_general_resource_exceptions_are_not_masked_by_default(self):
mcp = FastMCP("TestServer")

View file

@ -1,11 +1,8 @@
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from fastapi import FastAPI, Request
from mcp.types import TextContent, TextResourceContents
from fastmcp import Client, FastMCP
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
@ -35,75 +32,34 @@ def fastmcp_server_for_headers() -> FastMCP:
return mcp
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server_for_headers().run(host=host, port=port, **kwargs)
def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
client = Client(transport=StreamableHttpTransport(shttp_url))
app = FastMCP.as_proxy(client)
app.run(host=host, port=port, **kwargs)
class TestClientHeaders:
def run_shttp_server(self, host: str, port: int) -> None:
try:
app = fastmcp_server_for_headers().http_app(transport="streamable-http")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_sse_server(self, host: str, port: int) -> None:
try:
app = fastmcp_server_for_headers().http_app(transport="sse")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_proxy_server(self, host: str, port: int, remote_url: str) -> None:
try:
client = Client(transport=StreamableHttpTransport(remote_url))
app = FastMCP.as_proxy(client).http_app(transport="streamable-http")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
@pytest.fixture(scope="class")
def shttp_server(self) -> Generator[str, None, None]:
with run_server_in_process(self.run_shttp_server) as url:
with run_server_in_process(run_server, transport="streamable-http") as url:
yield f"{url}/mcp"
@pytest.fixture(scope="class")
def sse_server(self) -> Generator[str, None, None]:
with run_server_in_process(self.run_sse_server) as url:
with run_server_in_process(run_server, transport="sse") as url:
yield f"{url}/sse"
@pytest.fixture(scope="class")
def proxy_server(self, shttp_server: str) -> Generator[str, None, None]:
with run_server_in_process(self.run_proxy_server, shttp_server + "/mcp") as url:
with run_server_in_process(
run_proxy_server,
shttp_url=shttp_server,
transport="streamable-http",
) as url:
yield f"{url}/mcp"
async def test_client_headers_sse_resource(self, sse_server: str):
@ -111,8 +67,7 @@ class TestClientHeaders:
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
headers = json.loads(result[0].text) # type: ignore[attr-defined]
assert headers["x-test"] == "test-123"
async def test_client_headers_shttp_resource(self, shttp_server: str):
@ -122,8 +77,7 @@ class TestClientHeaders:
)
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
headers = json.loads(result[0].text) # type: ignore[attr-defined]
assert headers["x-test"] == "test-123"
async def test_client_headers_sse_resource_template(self, sse_server: str):
@ -133,8 +87,7 @@ class TestClientHeaders:
result = await client.read_resource(
"resource://get_header_by_name_headers/x-test"
)
assert isinstance(result[0], TextResourceContents)
header = json.loads(result[0].text)
header = json.loads(result[0].text) # type: ignore[attr-defined]
assert header == "test-123"
async def test_client_headers_shttp_resource_template(self, shttp_server: str):
@ -146,8 +99,7 @@ class TestClientHeaders:
result = await client.read_resource(
"resource://get_header_by_name_headers/x-test"
)
assert isinstance(result[0], TextResourceContents)
header = json.loads(result[0].text)
header = json.loads(result[0].text) # type: ignore[attr-defined]
assert header == "test-123"
async def test_client_headers_sse_tool(self, sse_server: str):
@ -155,8 +107,7 @@ class TestClientHeaders:
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
) as client:
result = await client.call_tool("post_headers_headers_post")
assert isinstance(result[0], TextContent)
headers = json.loads(result[0].text)
headers = json.loads(result[0].text) # type: ignore[attr-defined]
assert headers["x-test"] == "test-123"
async def test_client_headers_shttp_tool(self, shttp_server: str):
@ -166,8 +117,7 @@ class TestClientHeaders:
)
) as client:
result = await client.call_tool("post_headers_headers_post")
assert isinstance(result[0], TextContent)
headers = json.loads(result[0].text)
headers = json.loads(result[0].text) # type: ignore[attr-defined]
assert headers["x-test"] == "test-123"
async def test_client_overrides_server_headers(self, shttp_server: str):
@ -177,8 +127,7 @@ class TestClientHeaders:
)
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
headers = json.loads(result[0].text) # type: ignore[attr-defined]
assert headers["x-server-header"] == "test-client"
async def test_client_with_excluded_header_is_ignored(self, sse_server: str):
@ -193,8 +142,7 @@ class TestClientHeaders:
)
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
headers = json.loads(result[0].text) # type: ignore[attr-defined]
assert headers["not-host"] == "1.2.3.4"
assert headers["host"] == "fastapi"
@ -204,6 +152,5 @@ class TestClientHeaders:
"""
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
headers = json.loads(result[0].text) # type: ignore[attr-defined]
assert headers["x-server-header"] == "test-abc"

View file

@ -1,7 +1,6 @@
import json
import pytest
from mcp.types import TextContent
from fastmcp import Client, Context, FastMCP
@ -41,8 +40,7 @@ class TestClientRoots:
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
async with Client(fastmcp_server, roots=roots) as client:
result = await client.call_tool("list_roots", {})
assert isinstance(result[0], TextContent)
assert json.loads(result[0].text) == [
assert json.loads(result[0].text) == [ # type: ignore[attr-defined]
"file://x/y/z",
"file://x/y/z",
]

View file

@ -6,7 +6,6 @@ from collections.abc import Generator
import pytest
import uvicorn
from mcp import McpError
from mcp.types import TextResourceContents
from starlette.applications import Starlette
from starlette.routing import Mount
@ -64,22 +63,13 @@ def fastmcp_server():
return server
def run_server(host: str, port: int, path: str | None = None) -> None:
try:
app = fastmcp_server().http_app(transport="sse", path=path)
server = uvicorn.Server(
config=uvicorn.Config(app=app, host=host, port=port, log_level="error")
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server().run(host=host, port=port, **kwargs)
@pytest.fixture(autouse=True, scope="module")
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_server) as url:
with run_server_in_process(run_server, transport="sse") as url:
yield f"{url}/sse"
@ -96,29 +86,23 @@ async def test_http_headers(sse_server: str):
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
def run_nested_server(host: str, port: int) -> None:
try:
app = fastmcp_server().sse_app(path="/mcp/sse", message_path="/mcp/messages")
mount = Starlette(routes=[Mount("/nest-inner", app=app)])
mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)])
server = uvicorn.Server(
config=uvicorn.Config(app=mount2, host=host, port=port, log_level="error")
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
app = fastmcp_server().sse_app(path="/mcp/sse", message_path="/mcp/messages")
mount = Starlette(routes=[Mount("/nest-inner", app=app)])
mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)])
server = uvicorn.Server(
config=uvicorn.Config(app=mount2, host=host, port=port, log_level="error")
)
server.run()
async def test_run_server_on_path():
with run_server_in_process(run_server, "/help") as url:
with run_server_in_process(run_server, transport="sse", path="/help") as url:
async with Client(transport=SSETransport(f"{url}/help")) as client:
result = await client.ping()
assert result is True

View file

@ -1,7 +1,6 @@
import inspect
import pytest
from mcp.types import TextContent
from fastmcp import Client
from fastmcp.client.transports import PythonStdioTransport, StdioTransport
@ -49,13 +48,11 @@ class TestKeepAlive:
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
pid1 = int(result1[0].text) # type: ignore[attr-defined]
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
pid2 = int(result2[0].text) # type: ignore[attr-defined]
assert pid1 == pid2
@ -69,13 +66,11 @@ class TestKeepAlive:
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
pid1 = int(result1[0].text) # type: ignore[attr-defined]
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
pid2 = int(result2[0].text) # type: ignore[attr-defined]
assert pid1 != pid2
@ -85,15 +80,13 @@ class TestKeepAlive:
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
pid1 = int(result1[0].text) # type: ignore[attr-defined]
await client.close()
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
pid2 = int(result2[0].text) # type: ignore[attr-defined]
assert pid1 != pid2
@ -103,17 +96,14 @@ class TestKeepAlive:
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
pid1 = int(result1[0].text) # type: ignore[attr-defined]
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
pid2 = int(result2[0].text) # type: ignore[attr-defined]
result3 = await client.call_tool("pid")
assert isinstance(result3[0], TextContent)
pid3 = int(result3[0].text)
pid3 = int(result3[0].text) # type: ignore[attr-defined]
assert pid1 == pid2 == pid3

View file

@ -6,7 +6,6 @@ from collections.abc import Generator
import pytest
import uvicorn
from mcp import McpError
from mcp.types import TextResourceContents
from starlette.applications import Starlette
from starlette.routing import Mount
@ -64,28 +63,33 @@ def fastmcp_server():
return server
def run_server(host: str, port: int) -> None:
try:
app = fastmcp_server().http_app()
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server().run(host=host, port=port, **kwargs)
def run_nested_server(host: str, port: int) -> None:
mcp_app = fastmcp_server().http_app(path="/final/mcp")
mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
mount2 = Starlette(
routes=[Mount("/nest-outer", app=mount)],
lifespan=mcp_app.lifespan,
)
server = uvicorn.Server(
config=uvicorn.Config(
app=mount2,
host=host,
port=port,
log_level="error",
lifespan="on",
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
)
server.run()
@pytest.fixture(scope="module")
def streamable_http_server() -> Generator[str, None, None]:
with run_server_in_process(run_server) as url:
with run_server_in_process(run_server, transport="streamable-http") as url:
yield f"{url}/mcp"
@ -106,37 +110,11 @@ async def test_http_headers(streamable_http_server: str):
)
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
def run_nested_server(host: str, port: int) -> None:
try:
mcp_app = fastmcp_server().http_app(path="/final/mcp")
mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
mount2 = Starlette(
routes=[Mount("/nest-outer", app=mount)],
lifespan=mcp_app.lifespan,
)
server = uvicorn.Server(
config=uvicorn.Config(
app=mount2,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
async def test_nested_streamable_http_server_resolves_correctly():
# tests patch for
# https://github.com/modelcontextprotocol/python-sdk/pull/659

View file

@ -393,8 +393,7 @@ class TestContextHandling:
messages = await prompt.render(arguments={"x": 42})
assert len(messages) == 1
assert isinstance(messages[0].content, TextContent)
assert messages[0].content.text == "42"
assert messages[0].content.text == "42" # type: ignore[attr-defined]
async def test_context_optional(self):
"""Test that context is optional when rendering prompts."""
@ -416,8 +415,7 @@ class TestContextHandling:
)
assert len(messages) == 1
assert isinstance(messages[0].content, TextContent)
assert messages[0].content.text == "42"
assert messages[0].content.text == "42" # type: ignore[attr-defined]
async def test_annotated_context_parameter_detection(self):
"""Test that annotated context parameters are properly detected in

View file

@ -74,7 +74,6 @@ class TestFileResource:
is_binary=True,
)
content = await resource.read()
assert isinstance(content, bytes)
assert content == b"test content"
def test_relative_path_error(self):

View file

View file

@ -1,10 +1,7 @@
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from mcp.types import TextContent, TextResourceContents
from fastmcp.client import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
@ -41,53 +38,19 @@ def fastmcp_server():
return server
def run_shttp_server(host: str, port: int) -> None:
try:
app = fastmcp_server().http_app(transport="streamable-http")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_sse_server(host: str, port: int) -> None:
try:
app = fastmcp_server().http_app(transport="sse")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server().run(host=host, port=port, **kwargs)
@pytest.fixture(autouse=True, scope="module")
def shttp_server() -> Generator[str, None, None]:
with run_server_in_process(run_shttp_server) as url:
with run_server_in_process(run_server, transport="streamable-http") as url:
yield f"{url}/mcp"
@pytest.fixture(autouse=True, scope="module")
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_sse_server) as url:
with run_server_in_process(run_server, transport="sse") as url:
yield f"{url}/sse"
@ -99,8 +62,7 @@ async def test_http_headers_resource_shttp(shttp_server: str):
)
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
@ -111,8 +73,7 @@ async def test_http_headers_resource_sse(sse_server: str):
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
@ -125,8 +86,7 @@ async def test_http_headers_tool_shttp(shttp_server: str):
)
) as client:
result = await client.call_tool("get_headers_tool")
assert isinstance(result[0], TextContent)
json_result = json.loads(result[0].text)
json_result = json.loads(result[0].text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
@ -136,8 +96,7 @@ async def test_http_headers_tool_sse(sse_server: str):
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
result = await client.call_tool("get_headers_tool")
assert isinstance(result[0], TextContent)
json_result = json.loads(result[0].text)
json_result = json.loads(result[0].text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
@ -150,8 +109,7 @@ async def test_http_headers_prompt_shttp(shttp_server: str):
)
) as client:
result = await client.get_prompt("get_headers_prompt")
assert isinstance(result.messages[0].content, TextContent)
json_result = json.loads(result.messages[0].content.text)
json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
@ -162,7 +120,6 @@ async def test_http_headers_prompt_sse(sse_server: str):
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
result = await client.get_prompt("get_headers_prompt")
assert isinstance(result.messages[0].content, TextContent)
json_result = json.loads(result.messages[0].content.text)
json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined]
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"

View file

View file

@ -9,7 +9,7 @@ from dirty_equals import IsStr
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import PlainTextResponse
from httpx import ASGITransport, AsyncClient
from mcp.types import BlobResourceContents, TextContent, TextResourceContents
from mcp.types import BlobResourceContents
from pydantic import BaseModel, TypeAdapter
from pydantic.networks import AnyUrl
@ -234,11 +234,7 @@ class TestTools:
"create_user_users_post", {"name": "David", "active": False}
)
# Convert TextContent to dict for comparison
assert isinstance(tool_response, list) and len(tool_response) == 1
assert isinstance(tool_response[0], TextContent)
response_data = json.loads(tool_response[0].text)
response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
expected_user = User(id=4, name="David", active=False).model_dump()
assert response_data == expected_user
@ -249,8 +245,7 @@ class TestTools:
# Check that the user was created via MCP
async with Client(fastmcp_openapi_server) as client:
user_response = await client.read_resource("resource://get_user_users/4")
assert isinstance(user_response[0], TextResourceContents)
response_text = user_response[0].text
response_text = user_response[0].text # type: ignore[attr-defined]
user = json.loads(response_text)
assert user == expected_user
@ -266,11 +261,7 @@ class TestTools:
{"user_id": 1, "name": "XYZ"},
)
# Convert TextContent to dict for comparison
assert isinstance(tool_response, list) and len(tool_response) == 1
assert isinstance(tool_response[0], TextContent)
response_data = json.loads(tool_response[0].text)
response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
expected_data = dict(id=1, name="XYZ", active=True)
assert response_data == expected_data
@ -281,8 +272,7 @@ class TestTools:
# Check that the user was updated via MCP
async with Client(fastmcp_openapi_server) as client:
user_response = await client.read_resource("resource://get_user_users/1")
assert isinstance(user_response[0], TextResourceContents)
response_text = user_response[0].text
response_text = user_response[0].text # type: ignore[attr-defined]
user = json.loads(response_text)
assert user == expected_data
@ -305,9 +295,7 @@ class TestTools:
)
async with Client(mcp_server) as client:
tool_response = await client.call_tool("get_users_users_get", {})
assert isinstance(tool_response, list)
assert isinstance(tool_response[0], TextContent)
assert json.loads(tool_response[0].text) == [
assert json.loads(tool_response[0].text) == [ # type: ignore[attr-defined]
user.model_dump()
for user in sorted(users_db.values(), key=lambda x: x.id)
]
@ -341,8 +329,7 @@ class TestResources:
resource_response = await client.read_resource(
"resource://get_users_users_get"
)
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
response_text = resource_response[0].text # type: ignore[attr-defined]
resource = json.loads(response_text)
assert resource == json_users
response = await api_client.get("/users")
@ -369,8 +356,7 @@ class TestResources:
"""Test reading a resource that returns a string."""
async with Client(fastmcp_openapi_server) as client:
resource_response = await client.read_resource("resource://ping_ping_get")
assert isinstance(resource_response[0], TextResourceContents)
assert resource_response[0].text == "pong"
assert resource_response[0].text == "pong" # type: ignore[attr-defined]
class TestResourceTemplates:
@ -407,8 +393,7 @@ class TestResourceTemplates:
resource_response = await client.read_resource(
f"resource://get_user_users/{user_id}"
)
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
response_text = resource_response[0].text # type: ignore[attr-defined]
resource = json.loads(response_text)
assert resource == users_db[user_id].model_dump()
@ -430,8 +415,7 @@ class TestResourceTemplates:
resource_response = await client.read_resource(
f"resource://get_user_active_state_users/{is_active}/{user_id}"
)
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
response_text = resource_response[0].text # type: ignore[attr-defined]
resource = json.loads(response_text)
assert resource == users_db[user_id].model_dump()
@ -681,8 +665,7 @@ class TestOpenAPI30Compatibility:
"""Test reading a resource from an OpenAPI 3.0 server."""
async with Client(openapi_30_server) as client:
resource_response = await client.read_resource("resource://listProducts")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
response_text = resource_response[0].text # type: ignore[attr-defined]
content = json.loads(response_text)
assert len(content) == 2
assert content[0]["name"] == "Product 1"
@ -692,8 +675,7 @@ class TestOpenAPI30Compatibility:
"""Test reading a resource from template from an OpenAPI 3.0 server."""
async with Client(openapi_30_server) as client:
resource_response = await client.read_resource("resource://getProduct/p1")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
response_text = resource_response[0].text # type: ignore[attr-defined]
content = json.loads(response_text)
assert content["id"] == "p1"
assert content["name"] == "Product 1"
@ -707,8 +689,7 @@ class TestOpenAPI30Compatibility:
)
# Result should be a text content
assert len(result) == 1
assert isinstance(result[0], TextContent)
product = json.loads(result[0].text)
product = json.loads(result[0].text) # type: ignore[attr-defined]
assert product["id"] == "p3"
assert product["name"] == "New Product"
assert product["price"] == 39.99
@ -857,8 +838,7 @@ class TestOpenAPI31Compatibility:
"""Test reading a resource from an OpenAPI 3.1 server."""
async with Client(openapi_31_server) as client:
resource_response = await client.read_resource("resource://listOrders")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
response_text = resource_response[0].text # type: ignore[attr-defined]
content = json.loads(response_text)
assert len(content) == 2
assert content[0]["customer"] == "Alice"
@ -868,8 +848,7 @@ class TestOpenAPI31Compatibility:
"""Test reading a resource from template from an OpenAPI 3.1 server."""
async with Client(openapi_31_server) as client:
resource_response = await client.read_resource("resource://getOrder/o1")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
response_text = resource_response[0].text # type: ignore[attr-defined]
content = json.loads(response_text)
assert content["id"] == "o1"
assert content["customer"] == "Alice"
@ -883,8 +862,7 @@ class TestOpenAPI31Compatibility:
)
# Result should be a text content
assert len(result) == 1
assert isinstance(result[0], TextContent)
order = json.loads(result[0].text)
order = json.loads(result[0].text) # type: ignore[attr-dict]
assert order["id"] == "o3"
assert order["customer"] == "Charlie"
assert order["items"] == ["item4", "item5"]

View file

@ -1,8 +1,6 @@
import json
from urllib.parse import quote
from mcp.types import TextContent, TextResourceContents
from fastmcp.client.client import Client
from fastmcp.server.server import FastMCP
@ -223,8 +221,7 @@ async def test_call_imported_custom_named_tool():
async with Client(main_app) as client:
result = await client.call_tool("api_get_data", {"query": "test"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Data for query: test"
assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
async def test_first_level_importing_with_custom_name():
@ -278,8 +275,7 @@ async def test_call_nested_imported_tool():
result = await main_app._tool_manager.call_tool(
"service_provider_compute", {"input": 21}
)
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
assert result[0].text == "42" # type: ignore[attr-defined]
async def test_import_with_proxy_tools():
@ -302,8 +298,7 @@ async def test_import_with_proxy_tools():
await main_app.import_server("api", proxy_app)
result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Data for query: test"
assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
async def test_import_with_proxy_prompts():
@ -326,8 +321,7 @@ async def test_import_with_proxy_prompts():
await main_app.import_server("api", proxy_app)
result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Hello, World from API!"
assert result.messages[0].content.text == "Hello, World from API!" # type: ignore[attr-defined]
assert result.description == "Example greeting prompt."
@ -356,8 +350,7 @@ async def test_import_with_proxy_resources():
# Access the resource through the main app with the prefixed key
async with Client(main_app) as client:
result = await client.read_resource("config://api/settings")
assert isinstance(result[0], TextResourceContents)
content = json.loads(result[0].text)
content = json.loads(result[0].text) # type: ignore[attr-defined]
assert content["api_key"] == "12345"
assert content["base_url"] == "https://api.example.com"
@ -387,8 +380,7 @@ async def test_import_with_proxy_resource_templates():
quoted_email = quote("john@example.com", safe="")
async with Client(main_app) as client:
result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}")
assert isinstance(result[0], TextResourceContents)
content = json.loads(result[0].text)
content = json.loads(result[0].text) # type: ignore[attr-defined]
assert content["name"] == "John Doe"
assert content["email"] == "john@example.com"

View file

@ -1,396 +0,0 @@
"""Tests for lifespan functionality in both low-level and FastMCP servers."""
import os
import sys
import traceback
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import anyio
import httpx
import uvicorn
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.shared.message import SessionMessage
from mcp.types import (
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
)
from pydantic import TypeAdapter
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp import Context, FastMCP
from fastmcp.utilities.tests import run_server_in_process
async def test_lowlevel_server_lifespan():
"""Test that lifespan works in low-level server."""
@asynccontextmanager
async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]:
"""Test lifespan context that tracks startup/shutdown."""
context = {"started": False, "shutdown": False}
try:
context["started"] = True
yield context
finally:
context["shutdown"] = True
server = Server("test", lifespan=test_lifespan)
# Create memory streams for testing
send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
# Create a tool that accesses lifespan context
@server.call_tool()
async def check_lifespan(name: str, arguments: dict) -> list:
ctx = server.request_context
assert isinstance(ctx.lifespan_context, dict)
assert ctx.lifespan_context["started"]
assert not ctx.lifespan_context["shutdown"]
return [{"type": "text", "text": "true"}]
# Run server in background task
async with (
anyio.create_task_group() as tg,
send_stream1,
receive_stream1,
send_stream2,
receive_stream2,
):
async def run_server():
await server.run(
receive_stream1,
send_stream2,
InitializationOptions(
server_name="test",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
raise_exceptions=True,
)
tg.start_soon(run_server)
# Initialize the server
params = InitializeRequestParams(
protocolVersion="2024-11-05",
capabilities=ClientCapabilities(),
clientInfo=Implementation(name="test-client", version="0.1.0"),
)
await send_stream1.send(
SessionMessage(
JSONRPCMessage(
root=JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="initialize",
params=TypeAdapter(InitializeRequestParams).dump_python(params),
)
)
)
)
response = await receive_stream2.receive()
response = response.message
# Send initialized notification
await send_stream1.send(
SessionMessage(
JSONRPCMessage(
root=JSONRPCNotification(
jsonrpc="2.0",
method="notifications/initialized",
)
)
)
)
# Call the tool to verify lifespan context
await send_stream1.send(
SessionMessage(
JSONRPCMessage(
root=JSONRPCRequest(
jsonrpc="2.0",
id=2,
method="tools/call",
params={"name": "check_lifespan", "arguments": {}},
)
)
)
)
# Get response and verify
response = await receive_stream2.receive()
response = response.message
assert response.root.result["content"][0]["text"] == "true"
# Cancel server task
tg.cancel_scope.cancel()
async def test_fastmcp_server_lifespan():
"""Test that lifespan works in FastMCP server."""
@asynccontextmanager
async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]:
"""Test lifespan context that tracks startup/shutdown."""
context = {"started": False, "shutdown": False}
try:
context["started"] = True
yield context
finally:
context["shutdown"] = True
server = FastMCP("test", lifespan=test_lifespan)
# Create memory streams for testing
send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
# Add a tool that checks lifespan context
@server.tool()
def check_lifespan(ctx: Context) -> bool:
"""Tool that checks lifespan context."""
assert isinstance(ctx.request_context.lifespan_context, dict)
assert ctx.request_context.lifespan_context["started"]
assert not ctx.request_context.lifespan_context["shutdown"]
return True
# Run server in background task
async with (
anyio.create_task_group() as tg,
send_stream1,
receive_stream1,
send_stream2,
receive_stream2,
):
async def run_server():
await server._mcp_server.run(
receive_stream1,
send_stream2,
server._mcp_server.create_initialization_options(),
raise_exceptions=True,
)
tg.start_soon(run_server)
# Initialize the server
params = InitializeRequestParams(
protocolVersion="2024-11-05",
capabilities=ClientCapabilities(),
clientInfo=Implementation(name="test-client", version="0.1.0"),
)
await send_stream1.send(
SessionMessage(
JSONRPCMessage(
root=JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="initialize",
params=TypeAdapter(InitializeRequestParams).dump_python(params),
)
)
)
)
response = await receive_stream2.receive()
response = response.message
# Send initialized notification
await send_stream1.send(
SessionMessage(
JSONRPCMessage(
root=JSONRPCNotification(
jsonrpc="2.0",
method="notifications/initialized",
)
)
)
)
# Call the tool to verify lifespan context
await send_stream1.send(
SessionMessage(
JSONRPCMessage(
root=JSONRPCRequest(
jsonrpc="2.0",
id=2,
method="tools/call",
params={"name": "check_lifespan", "arguments": {}},
)
)
)
)
# Get response and verify
response = await receive_stream2.receive()
response = response.message
assert response.root.result["content"][0]["text"] == "true"
# Cancel server task
tg.cancel_scope.cancel()
def run_server_with_incorrect_lifespan_setup(
host: str, port: int, server_log_file_path: str
) -> None:
os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True)
CUSTOM_LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": False,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s',
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": False,
},
},
"handlers": {
"file_default": {
"formatter": "default",
"class": "logging.FileHandler",
"filename": server_log_file_path,
"mode": "w",
},
"file_access": {
"formatter": "access",
"class": "logging.FileHandler",
"filename": server_log_file_path,
"mode": "a",
},
},
"loggers": {
"uvicorn": { # Catches uvicorn root logs
"handlers": ["file_default"],
"level": "DEBUG",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["file_default"],
"level": "DEBUG",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["file_access"],
"level": "INFO",
"propagate": False,
},
},
"root": {
"handlers": ["file_default"],
"level": "DEBUG",
},
}
try:
mcp = FastMCP()
@mcp.tool("ping_tool", "A simple ping tool for the test server")
def ping_tool() -> str:
return "pong"
mcp_asgi_app = mcp.http_app(transport="streamable-http")
parent_app = Starlette(
routes=[Mount("/mounted_mcp", app=mcp_asgi_app)],
)
uvicorn.run(
parent_app,
host=host,
port=port,
log_config=CUSTOM_LOGGING_CONFIG,
log_level=None,
)
sys.exit(0)
except Exception as e_outer:
with open(server_log_file_path, "a") as f_fallback:
f_fallback.write(
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n"
)
f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n")
f_fallback.write(traceback.format_exc())
sys.exit(1)
async def test_missing_lifespan_logs_informative_error(tmp_path: Path):
server_log_file = tmp_path / "server.log"
with run_server_in_process(
run_server_with_incorrect_lifespan_setup, str(server_log_file)
) as server_url:
full_mcp_path = server_url + "/mounted_mcp/mcp/"
client_triggered_error = False
response_status = -1
response_body = ""
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
full_mcp_path,
json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"},
)
response_status = response.status_code
response_body = response.text
if response.status_code == 500:
client_triggered_error = True
else:
print(
f"Client received unexpected status code: {response.status_code} "
f"Response: {response_body[:500]}"
)
except httpx.RequestError as e:
print(f"Client request failed with RequestError: {e}")
client_triggered_error = True
assert client_triggered_error, (
f"Client request did not result in a 500 error or a request error. "
f"Status: {response_status}, Body: {response_body[:500]}"
)
assert server_log_file.exists(), (
f"Server log file was not created at {server_log_file}"
)
log_content = server_log_file.read_text()
print(f"--- Captured Server Log Content ({server_log_file}) ---")
print(log_content)
print("--- End Server Log Content ---")
# Core assertions for the enhanced error message
assert (
"FastMCP's StreamableHTTPSessionManager task group was not initialized"
in log_content
)
assert "lifespan=mcp_app.lifespan" in log_content
assert "gofastmcp.com/deployment/asgi" in log_content
assert "Original error: Task group is not initialized" in log_content
# Check for Uvicorn's own error logging wrapper for the request
assert "ERROR" in log_content # General check for ERROR level logs
assert "Exception in ASGI application" in log_content
# Sanity checks for server operation and logging setup
assert "Uvicorn running on" in log_content
assert (
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content
)

View file

@ -2,6 +2,7 @@ import asyncio
import logging
from unittest.mock import AsyncMock, Mock, patch
import anyio
import pytest
from fastmcp.server.server import FastMCP
@ -27,7 +28,7 @@ async def test_uvicorn_logging_default_level(
"""Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given."""
mock_server_instance = AsyncMock()
mock_uvicorn_server_constructor.return_value = mock_server_instance
serve_finished_event = asyncio.Event()
serve_finished_event = anyio.Event()
mock_server_instance.serve.side_effect = serve_finished_event.wait
test_log_level = "warning"
@ -63,7 +64,7 @@ async def test_uvicorn_logging_with_custom_log_config(
"""Tests that FastMCP passes log_config to uvicorn.Config and not log_level."""
mock_server_instance = AsyncMock()
mock_uvicorn_server_constructor.return_value = mock_server_instance
serve_finished_event = asyncio.Event()
serve_finished_event = anyio.Event()
mock_server_instance.serve.side_effect = serve_finished_event.wait
sample_log_config = {
@ -123,7 +124,7 @@ async def test_uvicorn_logging_custom_log_config_overrides_log_level_param(
"""Tests log_config precedence if log_level is also passed to run_http_async."""
mock_server_instance = AsyncMock()
mock_uvicorn_server_constructor.return_value = mock_server_instance
serve_finished_event = asyncio.Event()
serve_finished_event = anyio.Event()
mock_server_instance.serve.side_effect = serve_finished_event.wait
sample_log_config = {

View file

@ -3,8 +3,6 @@ import sys
from contextlib import asynccontextmanager
import pytest
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.types import TextContent, TextResourceContents
from fastmcp import FastMCP
from fastmcp.client import Client
@ -36,8 +34,7 @@ class TestBasicMount:
async with Client(main_app) as client:
result = await client.call_tool("sub_sub_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "This is from the sub app"
assert result[0].text == "This is from the sub app" # type: ignore[attr-defined]
async def test_mount_with_custom_separator(self):
"""Test mounting with a custom tool separator (deprecated but still supported)."""
@ -57,8 +54,7 @@ class TestBasicMount:
# Call the tool
result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Hello, World!"
assert result[0].text == "Hello, World!" # type: ignore[attr-defined]
async def test_mount_invalid_resource_prefix(self):
main_app = FastMCP("MainApp")
@ -147,12 +143,10 @@ class TestMultipleServerMount:
# Call tools from both mounted servers
result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
assert isinstance(result1[0], TextContent)
assert result1[0].text == "Weather forecast"
assert result1[0].text == "Weather forecast" # type: ignore[attr-defined]
result2 = await main_app._mcp_call_tool("news_get_headlines", {})
assert isinstance(result2[0], TextContent)
assert result2[0].text == "News headlines"
assert result2[0].text == "News headlines" # type: ignore[attr-defined]
async def test_mount_same_prefix(self):
"""Test that mounting with the same prefix replaces the previous mount."""
@ -227,8 +221,7 @@ class TestMultipleServerMount:
# Test calling a tool
result = await client.call_tool("working_working_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "Working tool"
assert result[0].text == "Working tool" # type: ignore[attr-defined]
# Test resources
resources = await client.list_resources()
@ -284,8 +277,7 @@ class TestDynamicChanges:
# Call the dynamically added tool
result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "Added after mounting"
assert result[0].text == "Added after mounting" # type: ignore[attr-defined]
async def test_removing_tool_after_mounting(self):
"""Test that tools removed from mounted servers are no longer accessible."""
@ -335,8 +327,7 @@ class TestResourcesAndTemplates:
# Check that resource can be accessed
async with Client(main_app) as client:
result = await client.read_resource("data://data/users")
assert isinstance(result[0], TextResourceContents)
assert json.loads(result[0].text) == ["user1", "user2"]
assert json.loads(result[0].text) == ["user1", "user2"] # type: ignore[attr-defined]
async def test_mount_with_resource_templates(self):
"""Test mounting a server with resource templates."""
@ -357,8 +348,7 @@ class TestResourcesAndTemplates:
# Check template instantiation
async with Client(main_app) as client:
result = await client.read_resource("users://api/123/profile")
assert isinstance(result[0], TextResourceContents)
profile = json.loads(result[0].text)
profile = json.loads(result[0].text) # type: ignore
assert profile["id"] == "123"
assert profile["name"] == "User 123"
@ -382,8 +372,7 @@ class TestResourcesAndTemplates:
# Check access to the resource
async with Client(main_app) as client:
result = await client.read_resource("data://data/config")
assert isinstance(result[0], TextResourceContents)
config = json.loads(result[0].text)
config = json.loads(result[0].text) # type: ignore[attr-defined]
assert config["version"] == "1.0"
@ -461,8 +450,7 @@ class TestProxyServer:
# Call the tool
result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Data for test"
assert result[0].text == "Data for test" # type: ignore[attr-defined]
async def test_dynamically_adding_to_proxied_server(self):
"""Test that changes to the original server are reflected in the mounted proxy."""
@ -489,8 +477,7 @@ class TestProxyServer:
# Call the tool
result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "Dynamic data"
assert result[0].text == "Dynamic data" # type: ignore[attr-defined]
async def test_proxy_server_with_resources(self):
"""Test mounting a proxy server with resources."""
@ -512,8 +499,7 @@ class TestProxyServer:
# Resource should be accessible through main app
result = await main_app._mcp_read_resource("config://proxy/settings")
assert isinstance(result[0], ReadResourceContents)
config = json.loads(result[0].content)
config = json.loads(result[0].content) # type: ignore[attr-defined]
assert config["api_key"] == "12345"
async def test_proxy_server_with_prompts(self):

View file

@ -1,8 +1,8 @@
import json
from typing import Any
import mcp.types
import pytest
from anyio import create_task_group
from dirty_equals import Contains
from mcp import McpError
@ -89,16 +89,14 @@ async def test_as_proxy_with_server(fastmcp_server):
"""FastMCP.as_proxy should accept a FastMCP instance."""
proxy = FastMCP.as_proxy(fastmcp_server)
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
assert isinstance(result[0], mcp.types.TextContent)
assert result[0].text == "Hello, Test!"
assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
async def test_as_proxy_with_transport(fastmcp_server):
"""FastMCP.as_proxy should accept a ClientTransport."""
proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
assert isinstance(result[0], mcp.types.TextContent)
assert result[0].text == "Hello, Test!"
assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
def test_as_proxy_with_url():
@ -137,9 +135,7 @@ class TestTools:
async def test_call_tool_calls_tool(self, proxy_server):
async with Client(proxy_server) as client:
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
assert isinstance(proxy_result[0], mcp.types.TextContent)
assert proxy_result[0].text == "3"
assert proxy_result[0].text == "3" # type: ignore[attr-defined]
async def test_error_tool_raises_error(self, proxy_server):
with pytest.raises(ToolError, match=""):
@ -163,8 +159,7 @@ class TestResources:
async def test_read_resource(self, proxy_server: FastMCPProxy):
async with Client(proxy_server) as client:
result = await client.read_resource("resource://wave")
assert isinstance(result[0], mcp.types.TextResourceContents)
assert result[0].text == "👋"
assert result[0].text == "👋" # type: ignore[attr-defined]
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
async with Client(fastmcp_server) as client:
@ -176,8 +171,7 @@ class TestResources:
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
async with Client(proxy_server) as client:
result = await client.read_resource("data://users")
assert isinstance(result[0], mcp.types.TextResourceContents)
assert json.loads(result[0].text) == USERS
assert json.loads(result[0].text) == USERS # type: ignore[attr-defined]
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
@ -201,8 +195,7 @@ class TestResourceTemplates:
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
async with Client(proxy_server) as client:
result = await client.read_resource(f"data://user/{id}")
assert isinstance(result[0], mcp.types.TextResourceContents)
assert json.loads(result[0].text) == USERS[id - 1]
assert json.loads(result[0].text) == USERS[id - 1] # type: ignore[attr-defined]
async def test_read_resource_template_same_as_original(
self, fastmcp_server, proxy_server
@ -238,7 +231,28 @@ class TestPrompts:
async def test_render_prompt_calls_prompt(self, proxy_server):
async with Client(proxy_server) as client:
result = await client.get_prompt("welcome", {"name": "Alice"})
assert isinstance(result.messages[0], mcp.types.PromptMessage)
assert result.messages[0].role == "user"
assert isinstance(result.messages[0].content, mcp.types.TextContent)
assert result.messages[0].content.text == "Welcome to FastMCP, Alice!"
assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" # type: ignore[attr-defined]
async def test_proxy_handles_multiple_concurrent_tasks_correctly(
proxy_server: FastMCPProxy,
):
results = {}
async def get_and_store(name, coro):
results[name] = await coro()
async with create_task_group() as tg:
tg.start_soon(get_and_store, "prompts", proxy_server.get_prompts)
tg.start_soon(get_and_store, "resources", proxy_server.get_resources)
tg.start_soon(get_and_store, "tools", proxy_server.get_tools)
assert list(results) == Contains("resources", "prompts", "tools")
assert list(results["prompts"]) == Contains("welcome")
assert [r.name for r in results["resources"].values()] == Contains(
"data://users", "resource://wave"
)
assert list(results["tools"]) == Contains(
"greet", "add", "error_tool", "tool_without_description"
)

View file

@ -2,10 +2,6 @@ from typing import Annotated
import pytest
from mcp import McpError
from mcp.types import (
TextContent,
TextResourceContents,
)
from pydantic import Field
from fastmcp import Client, FastMCP
@ -16,6 +12,7 @@ from fastmcp.server.server import (
has_resource_prefix,
remove_resource_prefix,
)
from fastmcp.tools.tool import Tool
class TestCreateServer:
@ -48,8 +45,7 @@ class TestCreateServer:
result = await client.call_tool("hello_world", {})
assert len(result) == 1
content = result[0]
assert isinstance(content, TextContent)
assert "¡Hola, 世界! 👋" == content.text
assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined]
class TestTools:
@ -98,6 +94,24 @@ class TestTools:
with pytest.raises(NotFoundError, match="Unknown tool: adder"):
await mcp._mcp_call_tool("adder", {"a": 1, "b": 2})
async def test_add_tool_at_init(self):
def f(x: int) -> int:
return x + 1
def g(x: int) -> int:
"""add two to a number"""
return x + 2
g_tool = Tool.from_function(g, name="g-tool")
mcp = FastMCP(tools=[f, g_tool])
tools = await mcp.get_tools()
assert len(tools) == 2
assert tools["f"].name == "f"
assert tools["g-tool"].name == "g-tool"
assert tools["g-tool"].description == "add two to a number"
class TestToolDecorator:
async def test_no_tools_before_decorator(self):
@ -114,8 +128,7 @@ class TestToolDecorator:
return x + y
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_tool_decorator_incorrect_usage(self):
mcp = FastMCP()
@ -134,8 +147,7 @@ class TestToolDecorator:
return x + y
result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_tool_decorator_with_description(self):
mcp = FastMCP()
@ -163,8 +175,7 @@ class TestToolDecorator:
obj = MyClass(10)
mcp.add_tool(obj.add)
result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
assert result[0].text == "12" # type: ignore[attr-defined]
async def test_tool_decorator_classmethod(self):
mcp = FastMCP()
@ -178,8 +189,7 @@ class TestToolDecorator:
mcp.add_tool(MyClass.add)
result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
assert result[0].text == "12" # type: ignore[attr-defined]
async def test_tool_decorator_staticmethod(self):
mcp = FastMCP()
@ -191,8 +201,7 @@ class TestToolDecorator:
return x + y
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_tool_decorator_async_function(self):
mcp = FastMCP()
@ -202,8 +211,7 @@ class TestToolDecorator:
return x + y
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_tool_decorator_classmethod_async_function(self):
mcp = FastMCP()
@ -217,8 +225,7 @@ class TestToolDecorator:
mcp.add_tool(MyClass.add)
result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
assert result[0].text == "12" # type: ignore[attr-defined]
async def test_tool_decorator_staticmethod_async_function(self):
mcp = FastMCP()
@ -230,8 +237,7 @@ class TestToolDecorator:
mcp.add_tool(MyClass.add)
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_tool_decorator_with_tags(self):
"""Test that the tool decorator properly sets tags."""
@ -262,8 +268,7 @@ class TestToolDecorator:
# Call the tool by its custom name
result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
assert isinstance(result[0], TextContent)
assert result[0].text == "15"
assert result[0].text == "15" # type: ignore[attr-defined]
# Original name should not be registered
assert "multiply" not in tools
@ -316,8 +321,7 @@ class TestResourceDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello, world!"
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
async def test_resource_decorator_incorrect_usage(self):
mcp = FastMCP()
@ -344,8 +348,7 @@ class TestResourceDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello, world!"
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
async def test_resource_decorator_with_description(self):
mcp = FastMCP()
@ -389,8 +392,7 @@ class TestResourceDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "My prefix: Hello, world!"
assert result[0].text == "My prefix: Hello, world!" # type: ignore[attr-defined]
async def test_resource_decorator_classmethod(self):
mcp = FastMCP()
@ -408,8 +410,7 @@ class TestResourceDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Class prefix: Hello, world!"
assert result[0].text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
async def test_resource_decorator_staticmethod(self):
mcp = FastMCP()
@ -422,8 +423,7 @@ class TestResourceDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static Hello, world!"
assert result[0].text == "Static Hello, world!" # type: ignore[attr-defined]
async def test_resource_decorator_async_function(self):
mcp = FastMCP()
@ -434,8 +434,7 @@ class TestResourceDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Async Hello, world!"
assert result[0].text == "Async Hello, world!" # type: ignore[attr-defined]
class TestTemplateDecorator:
@ -454,8 +453,7 @@ class TestTemplateDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
assert result[0].text == "Data for test" # type: ignore[attr-defined]
async def test_template_decorator_incorrect_usage(self):
mcp = FastMCP()
@ -482,8 +480,7 @@ class TestTemplateDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
assert result[0].text == "Data for test" # type: ignore[attr-defined]
async def test_template_decorator_with_description(self):
mcp = FastMCP()
@ -514,8 +511,7 @@ class TestTemplateDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "My prefix: Data for test"
assert result[0].text == "My prefix: Data for test" # type: ignore[attr-defined]
async def test_template_decorator_classmethod(self):
mcp = FastMCP()
@ -535,8 +531,7 @@ class TestTemplateDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Class prefix: Data for test"
assert result[0].text == "Class prefix: Data for test" # type: ignore[attr-defined]
async def test_template_decorator_staticmethod(self):
mcp = FastMCP()
@ -549,8 +544,7 @@ class TestTemplateDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static Data for test"
assert result[0].text == "Static Data for test" # type: ignore[attr-defined]
async def test_template_decorator_async_function(self):
mcp = FastMCP()
@ -561,8 +555,7 @@ class TestTemplateDecorator:
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Async Data for test"
assert result[0].text == "Async Data for test" # type: ignore[attr-defined]
async def test_template_decorator_with_tags(self):
"""Test that the template decorator properly sets tags."""
@ -603,8 +596,7 @@ class TestPromptDecorator:
assert prompt.name == "fn"
# Don't compare functions directly since validate_call wraps them
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_incorrect_usage(self):
mcp = FastMCP()
@ -629,8 +621,7 @@ class TestPromptDecorator:
prompt = prompts_dict["custom_name"]
assert prompt.name == "custom_name"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_with_description(self):
mcp = FastMCP()
@ -644,8 +635,7 @@ class TestPromptDecorator:
prompt = prompts_dict["fn"]
assert prompt.description == "A custom description"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_with_parameters(self):
mcp = FastMCP()
@ -668,16 +658,14 @@ class TestPromptDecorator:
result = await client.get_prompt("test_prompt", {"name": "World"})
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, World!"
assert message.content.text == "Hello, World!" # type: ignore[attr-defined]
result = await client.get_prompt(
"test_prompt", {"name": "World", "greeting": "Hi"}
)
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hi, World!"
assert message.content.text == "Hi, World!" # type: ignore[attr-defined]
async def test_prompt_decorator_instance_method(self):
mcp = FastMCP()
@ -696,8 +684,7 @@ class TestPromptDecorator:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "My prefix: Hello, world!"
assert message.content.text == "My prefix: Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_classmethod(self):
mcp = FastMCP()
@ -715,8 +702,7 @@ class TestPromptDecorator:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Class prefix: Hello, world!"
assert message.content.text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_staticmethod(self):
mcp = FastMCP()
@ -731,8 +717,7 @@ class TestPromptDecorator:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Static Hello, world!"
assert message.content.text == "Static Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_async_function(self):
mcp = FastMCP()
@ -745,8 +730,7 @@ class TestPromptDecorator:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Async Hello, world!"
assert message.content.text == "Async Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_with_tags(self):
"""Test that the prompt decorator properly sets tags."""
@ -943,20 +927,17 @@ class TestResourcePrefixMounting:
async with Client(main_server) as client:
# Regular resource
result = await client.read_resource("resource://prefix/test-resource")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource content"
assert result[0].text == "Resource content" # type: ignore[attr-defined]
# Absolute path resource
result = await client.read_resource("resource://prefix//absolute/path")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Absolute resource content"
assert result[0].text == "Absolute resource content" # type: ignore[attr-defined]
# Template resource
result = await client.read_resource(
"resource://prefix/param-value/template"
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource with param-value"
assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]
@pytest.mark.parametrize(
"uri,prefix,expected_match,expected_strip",
@ -1032,15 +1013,12 @@ class TestResourcePrefixMounting:
# Verify we can access the resources
async with Client(target_server) as client:
result = await client.read_resource("resource://imported/test-resource")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource content"
assert result[0].text == "Resource content" # type: ignore[attr-defined]
result = await client.read_resource("resource://imported//absolute/path")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Absolute resource content"
assert result[0].text == "Absolute resource content" # type: ignore[attr-defined]
result = await client.read_resource(
"resource://imported/param-value/template"
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource with param-value"
assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]

View file

@ -10,7 +10,6 @@ import pydantic_core
import pytest
from mcp import McpError
from mcp.types import (
BlobResourceContents,
ImageContent,
TextContent,
TextResourceContents,
@ -77,14 +76,12 @@ class TestTools:
async def test_call_tool(self, tool_server: FastMCP):
async with Client(tool_server) as client:
result = await client.call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_call_tool_as_client(self, tool_server: FastMCP):
async with Client(tool_server) as client:
result = await client.call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_call_tool_error(self, tool_server: FastMCP):
async with Client(tool_server) as client:
@ -113,8 +110,7 @@ class TestTools:
async def test_tool_returns_list(self, tool_server: FastMCP):
async with Client(tool_server) as client:
result = await client.call_tool("list_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == '[\n "x",\n 2\n]'
assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
class TestToolReturnTypes:
@ -127,8 +123,7 @@ class TestToolReturnTypes:
async with Client(mcp) as client:
result = await client.call_tool("string_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "Hello, world!"
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
async def test_bytes(self, tmp_path: Path):
mcp = FastMCP()
@ -139,8 +134,7 @@ class TestToolReturnTypes:
async with Client(mcp) as client:
result = await client.call_tool("bytes_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == '"Hello, world!"'
assert result[0].text == '"Hello, world!"' # type: ignore[attr-defined]
async def test_uuid(self):
mcp = FastMCP()
@ -153,8 +147,7 @@ class TestToolReturnTypes:
async with Client(mcp) as client:
result = await client.call_tool("uuid_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == pydantic_core.to_json(test_uuid).decode()
assert result[0].text == pydantic_core.to_json(test_uuid).decode() # type: ignore[attr-defined]
async def test_path(self):
mcp = FastMCP()
@ -167,8 +160,7 @@ class TestToolReturnTypes:
async with Client(mcp) as client:
result = await client.call_tool("path_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == pydantic_core.to_json(test_path).decode()
assert result[0].text == pydantic_core.to_json(test_path).decode() # type: ignore[attr-defined]
async def test_datetime(self):
mcp = FastMCP()
@ -181,8 +173,7 @@ class TestToolReturnTypes:
async with Client(mcp) as client:
result = await client.call_tool("datetime_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == pydantic_core.to_json(dt).decode()
assert result[0].text == pydantic_core.to_json(dt).decode() # type: ignore[attr-defined]
async def test_image(self, tmp_path: Path):
mcp = FastMCP()
@ -337,8 +328,7 @@ class TestToolParameters:
async with Client(mcp) as client:
# String with integer value should be coerced to int
result = await client.call_tool("add_one", {"x": "42"})
assert isinstance(result[0], TextContent)
assert result[0].text == "43"
assert result[0].text == "43" # type: ignore[attr-defined]
async def test_tool_bool_coercion(self):
"""Test string-to-bool type coercion."""
@ -351,12 +341,10 @@ class TestToolParameters:
async with Client(mcp) as client:
# String with boolean value should be coerced to bool
result = await client.call_tool("toggle", {"flag": "true"})
assert isinstance(result[0], TextContent)
assert result[0].text == "false"
assert result[0].text == "false" # type: ignore[attr-defined]
result = await client.call_tool("toggle", {"flag": "false"})
assert isinstance(result[0], TextContent)
assert result[0].text == "true"
assert result[0].text == "true" # type: ignore[attr-defined]
async def test_annotated_field_validation(self):
mcp = FastMCP()
@ -411,8 +399,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("analyze", {"x": "a"})
assert isinstance(result[0], TextContent)
assert result[0].text == "a"
assert result[0].text == "a" # type: ignore[attr-defined]
async def test_enum_type_validation_error(self):
mcp = FastMCP()
@ -444,8 +431,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("analyze", {"x": "red"})
assert isinstance(result[0], TextContent)
assert result[0].text == "red"
assert result[0].text == "red" # type: ignore[attr-defined]
async def test_union_type_validation(self):
mcp = FastMCP()
@ -456,12 +442,10 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("analyze", {"x": 1})
assert isinstance(result[0], TextContent)
assert result[0].text == "1"
assert result[0].text == "1" # type: ignore[attr-defined]
result = await client.call_tool("analyze", {"x": 1.0})
assert isinstance(result[0], TextContent)
assert result[0].text == "1.0"
assert result[0].text == "1.0" # type: ignore[attr-defined]
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
await client.call_tool("analyze", {"x": "not a number"})
@ -479,8 +463,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("send_path", {"path": str(test_path)})
assert isinstance(result[0], TextContent)
assert result[0].text == str(test_path)
assert result[0].text == str(test_path) # type: ignore[attr-defined]
async def test_path_type_error(self):
mcp = FastMCP()
@ -505,8 +488,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("send_uuid", {"x": test_uuid})
assert isinstance(result[0], TextContent)
assert result[0].text == str(test_uuid)
assert result[0].text == str(test_uuid) # type: ignore[attr-defined]
async def test_uuid_type_error(self):
mcp = FastMCP()
@ -530,8 +512,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("send_datetime", {"x": dt})
assert isinstance(result[0], TextContent)
assert result[0].text == dt.isoformat()
assert result[0].text == dt.isoformat() # type: ignore[attr-defined]
async def test_datetime_type_parse_string(self):
mcp = FastMCP()
@ -544,8 +525,7 @@ class TestToolParameters:
result = await client.call_tool(
"send_datetime", {"x": "2021-01-01T00:00:00"}
)
assert isinstance(result[0], TextContent)
assert result[0].text == "2021-01-01T00:00:00"
assert result[0].text == "2021-01-01T00:00:00" # type: ignore[attr-defined]
async def test_datetime_type_error(self):
mcp = FastMCP()
@ -567,8 +547,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("send_date", {"x": datetime.date.today()})
assert isinstance(result[0], TextContent)
assert result[0].text == datetime.date.today().isoformat()
assert result[0].text == datetime.date.today().isoformat() # type: ignore[attr-defined]
async def test_date_type_parse_string(self):
mcp = FastMCP()
@ -579,8 +558,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("send_date", {"x": "2021-01-01"})
assert isinstance(result[0], TextContent)
assert result[0].text == "2021-01-01"
assert result[0].text == "2021-01-01" # type: ignore[attr-defined]
async def test_timedelta_type(self):
mcp = FastMCP()
@ -593,8 +571,7 @@ class TestToolParameters:
result = await client.call_tool(
"send_timedelta", {"x": datetime.timedelta(days=1)}
)
assert isinstance(result[0], TextContent)
assert result[0].text == "1 day, 0:00:00"
assert result[0].text == "1 day, 0:00:00" # type: ignore[attr-defined]
async def test_timedelta_type_parse_int(self):
mcp = FastMCP()
@ -605,8 +582,7 @@ class TestToolParameters:
async with Client(mcp) as client:
result = await client.call_tool("send_timedelta", {"x": 1000})
assert isinstance(result[0], TextContent)
assert result[0].text == "0:16:40"
assert result[0].text == "0:16:40" # type: ignore[attr-defined]
class TestToolContextInjection:
@ -639,7 +615,7 @@ class TestToolContextInjection:
result = await client.call_tool("tool_with_context", {"x": 42})
assert len(result) == 1
content = result[0]
assert isinstance(content, TextContent)
assert content.text == "2" # type: ignore[attr-defined]
async def test_async_context(self):
"""Test that context works in async functions."""
@ -654,8 +630,7 @@ class TestToolContextInjection:
result = await client.call_tool("async_tool", {"x": 42})
assert len(result) == 1
content = result[0]
assert isinstance(content, TextContent)
assert content.text == "Async request 2: 42"
assert content.text == "Async request 2: 42" # type: ignore[attr-defined]
async def test_optional_context(self):
"""Test that context is optional."""
@ -669,8 +644,7 @@ class TestToolContextInjection:
result = await client.call_tool("no_context", {"x": 21})
assert len(result) == 1
content = result[0]
assert isinstance(content, TextContent)
assert content.text == "42"
assert content.text == "42" # type: ignore[attr-defined]
async def test_context_resource_access(self):
"""Test that context can access resources."""
@ -692,8 +666,7 @@ class TestToolContextInjection:
result = await client.call_tool("tool_with_resource", {})
assert len(result) == 1
content = result[0]
assert isinstance(content, TextContent)
assert "Read resource: resource data" in content.text
assert "Read resource: resource data" in content.text # type: ignore[attr-defined]
async def test_tool_decorator_with_tags(self):
"""Test that the tool decorator properly sets tags."""
@ -721,8 +694,7 @@ class TestToolContextInjection:
async with Client(mcp) as client:
result = await client.call_tool("MyTool", {"x": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "4"
assert result[0].text == "4" # type: ignore[attr-defined]
class TestResource:
@ -739,8 +711,7 @@ class TestResource:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello, world!"
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
async def test_binary_resource(self):
mcp = FastMCP()
@ -758,8 +729,7 @@ class TestResource:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://binary"))
assert isinstance(result[0], BlobResourceContents)
assert result[0].blob == base64.b64encode(b"Binary data").decode()
assert result[0].blob == base64.b64encode(b"Binary data").decode() # type: ignore[attr-defined]
async def test_file_resource_text(self, tmp_path: Path):
mcp = FastMCP()
@ -775,8 +745,7 @@ class TestResource:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("file://test.txt"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello from file!"
assert result[0].text == "Hello from file!" # type: ignore[attr-defined]
async def test_file_resource_binary(self, tmp_path: Path):
mcp = FastMCP()
@ -795,8 +764,7 @@ class TestResource:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("file://test.bin"))
assert isinstance(result[0], BlobResourceContents)
assert result[0].blob == base64.b64encode(b"Binary file data").decode()
assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
class TestResourceContext:
@ -810,8 +778,7 @@ class TestResourceContext:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "2"
assert result[0].text == "2" # type: ignore[attr-defined]
class TestResourceTemplates:
@ -860,8 +827,7 @@ class TestResourceTemplates:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test/data"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
assert result[0].text == "Data for test" # type: ignore[attr-defined]
async def test_resource_mismatched_params(self):
"""Test that mismatched parameters raise an error"""
@ -888,8 +854,7 @@ class TestResourceTemplates:
result = await client.read_resource(
AnyUrl("resource://cursor/fastmcp/data")
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for cursor/fastmcp"
assert result[0].text == "Data for cursor/fastmcp" # type: ignore[attr-defined]
async def test_resource_multiple_mismatched_params(self):
"""Test that mismatched parameters raise an error"""
@ -913,8 +878,7 @@ class TestResourceTemplates:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://static"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static data"
assert result[0].text == "Static data" # type: ignore[attr-defined]
async def test_template_with_varkwargs(self):
"""Test that a template can have **kwargs."""
@ -926,8 +890,7 @@ class TestResourceTemplates:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("test://1/2/3"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "6"
assert result[0].text == "6" # type: ignore[attr-defined]
async def test_template_with_default_params(self):
"""Test that a template can have default parameters."""
@ -946,13 +909,11 @@ class TestResourceTemplates:
# Call the template and verify it uses the default value
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("math://add/5"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "15" # 5 + default 10
assert result[0].text == "15" # type: ignore[attr-defined]
# Can also call with explicit params
result2 = await client.read_resource(AnyUrl("math://add/7"))
assert isinstance(result2[0], TextResourceContents)
assert result2[0].text == "17" # 7 + default 10
assert result2[0].text == "17" # type: ignore[attr-defined]
async def test_template_to_resource_conversion(self):
"""Test that a template can be converted to a resource."""
@ -971,8 +932,7 @@ class TestResourceTemplates:
# When accessed, should create a concrete resource
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test/data"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
assert result[0].text == "Data for test" # type: ignore[attr-defined]
async def test_stacked_resource_template_decorators(self):
"""Test that resource template decorators can be stacked."""
@ -1011,15 +971,15 @@ class TestResourceTemplates:
email_result = await client.read_resource(
AnyUrl("users://email/user@example.com")
)
assert isinstance(email_result[0], TextResourceContents)
email_data = json.loads(email_result[0].text)
assert email_result[0].text # type: ignore[attr-defined]
email_data = json.loads(email_result[0].text) # type: ignore[attr-defined]
assert email_data["lookup"] == "email"
assert email_data["email"] == "user@example.com"
# Test lookup by name
name_result = await client.read_resource(AnyUrl("users://name/John"))
assert isinstance(name_result[0], TextResourceContents)
name_data = json.loads(name_result[0].text)
assert name_result[0].text # type: ignore[attr-defined]
name_data = json.loads(name_result[0].text) # type: ignore[attr-defined]
assert name_data["lookup"] == "name"
assert name_data["name"] == "John"
assert name_data["email"] == "dummy@example.com"
@ -1044,8 +1004,7 @@ class TestResourceTemplates:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test/data"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource: test/data"
assert result[0].text == "Template resource: test/data" # type: ignore[attr-defined]
async def test_templates_match_in_order_of_definition(self):
"""
@ -1065,12 +1024,10 @@ class TestResourceTemplates:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://a/b/c"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 1: a/b/c"
assert result[0].text == "Template resource 1: a/b/c" # type: ignore[attr-defined]
result = await client.read_resource(AnyUrl("resource://a/b"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 1: a/b"
assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
async def test_templates_shadow_each_other_reorder(self):
"""
@ -1089,12 +1046,10 @@ class TestResourceTemplates:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://a/b/c"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 2: a/b/c"
assert result[0].text == "Template resource 2: a/b/c" # type: ignore[attr-defined]
result = await client.read_resource(AnyUrl("resource://a/b"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 1: a/b"
assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
class TestResourceTemplateContext:
@ -1108,8 +1063,7 @@ class TestResourceTemplateContext:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text.startswith("Resource template: test 2")
assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
async def test_resource_template_context_with_callable_object(self):
mcp = FastMCP()
@ -1122,8 +1076,7 @@ class TestResourceTemplateContext:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text.startswith("Resource template: test 2")
assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
class TestPrompts:
@ -1143,8 +1096,7 @@ class TestPrompts:
assert prompt.name == "fn"
# Don't compare functions directly since validate_call wraps them
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_with_name(self):
"""Test prompt decorator with custom name."""
@ -1159,8 +1111,7 @@ class TestPrompts:
prompt = prompts_dict["custom_name"]
assert prompt.name == "custom_name"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
async def test_prompt_decorator_with_description(self):
"""Test prompt decorator with custom description."""
@ -1175,8 +1126,7 @@ class TestPrompts:
prompt = prompts_dict["fn"]
assert prompt.description == "A custom description"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
def test_prompt_decorator_error(self):
"""Test error when decorator is used incorrectly."""
@ -1224,8 +1174,7 @@ class TestPrompts:
message = result.messages[0]
assert message.role == "user"
content = message.content
assert isinstance(content, TextContent)
assert content.text == "Hello, World!"
assert content.text == "Hello, World!" # type: ignore[attr-defined]
async def test_get_prompt_with_resource(self):
"""Test getting a prompt that returns resource content."""
@ -1249,10 +1198,10 @@ class TestPrompts:
result = await client.get_prompt("fn")
assert result.messages[0].role == "user"
content = result.messages[0].content
assert isinstance(content, EmbeddedResource)
assert isinstance(content, EmbeddedResource) # type: ignore[attr-defined]
resource = content.resource
assert isinstance(resource, TextResourceContents)
assert resource.text == "File contents"
assert isinstance(resource, TextResourceContents) # type: ignore[attr-defined]
assert resource.text == "File contents" # type: ignore[attr-defined]
assert resource.mimeType == "text/plain"
async def test_get_unknown_prompt(self):
@ -1342,5 +1291,4 @@ class TestPromptContext:
assert len(result.messages) == 1
message = result.messages[0]
assert message.role == "user"
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, World! 2"
assert message.content.text == "Hello, World! 2" # type: ignore[attr-defined]

View file

@ -1,6 +1,6 @@
from typing import Any
from mcp.types import TextContent, ToolAnnotations
from mcp.types import ToolAnnotations
from fastmcp import Client, FastMCP
@ -212,8 +212,7 @@ async def test_tool_functionality_with_annotations():
"create_item", {"name": "test_item", "value": 42}
)
assert len(result) == 1
assert isinstance(result[0], TextContent)
# The result should contain the expected JSON
assert '"name": "test_item"' in result[0].text
assert '"value": 42' in result[0].text
assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined]
assert '"value": 42' in result[0].text # type: ignore[attr-defined]

View file

@ -1,10 +1,5 @@
"""Tests for example servers"""
from mcp.types import (
PromptMessage,
TextContent,
TextResourceContents,
)
from pydantic import AnyUrl
from fastmcp import Client
@ -17,8 +12,7 @@ async def test_simple_echo():
async with Client(mcp) as client:
result = await client.call_tool("echo", {"text": "hello"})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "hello"
assert result[0].text == "hello" # type: ignore[attr-defined]
async def test_complex_inputs():
@ -31,8 +25,7 @@ async def test_complex_inputs():
"name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
async def test_desktop(monkeypatch):
@ -43,15 +36,12 @@ async def test_desktop(monkeypatch):
# Test the add function
result = await client.call_tool("add", {"a": 1, "b": 2})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert result[0].text == "3" # type: ignore[attr-defined]
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("greeting://rooter12"))
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert isinstance(result[0].text, str)
assert result[0].text == "Hello, rooter12!"
assert result[0].text == "Hello, rooter12!" # type: ignore[attr-defined]
async def test_echo():
@ -61,27 +51,19 @@ async def test_echo():
async with Client(mcp) as client:
result = await client.call_tool("echo_tool", {"text": "hello"})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "hello"
assert result[0].text == "hello" # type: ignore[attr-defined]
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("echo://static"))
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert isinstance(result[0].text, str)
assert result[0].text == "Echo!"
assert result[0].text == "Echo!" # type: ignore[attr-defined]
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("echo://server42"))
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert isinstance(result[0].text, str)
assert result[0].text == "Echo: server42"
assert result[0].text == "Echo: server42" # type: ignore[attr-defined]
async with Client(mcp) as client:
result = await client.get_prompt("echo", {"text": "hello"})
assert len(result.messages) == 1
assert isinstance(result.messages[0], PromptMessage)
assert isinstance(result.messages[0].content, TextContent)
assert isinstance(result.messages[0].content.text, str)
assert result.messages[0].content.text == "hello"
assert result.messages[0].content.text == "hello" # type: ignore[attr-defined]

View file

@ -1,5 +1,5 @@
import pytest
from mcp.types import ImageContent, TextContent
from mcp.types import ImageContent
from pydantic import BaseModel
from fastmcp import FastMCP, Image
@ -209,9 +209,7 @@ class TestLegacyToolJsonParsing:
# Run the tool which will do JSON parsing
result = await tool.run(json_args)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "1-a,b,c"
assert result[0].text == "1-a,b,c" # type: ignore[attr-dict]
async def test_str_vs_list_str(self):
"""Test handling of string vs list[str] type annotations."""
@ -223,23 +221,17 @@ class TestLegacyToolJsonParsing:
# Test regular string input (should remain a string)
result = await tool.run({"str_or_list": "hello"})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "hello"
assert result[0].text == "hello" # type: ignore[attr-dict]
# Test JSON string input (should be parsed as a string)
result = await tool.run({"str_or_list": '"hello"'})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "hello"
assert result[0].text == "hello" # type: ignore[attr-dict]
# Test JSON list input (should be parsed as a list)
result = await tool.run({"str_or_list": '["hello", "world"]'})
assert len(result) == 1
assert isinstance(result[0], TextContent)
# The exact formatting might vary, so we just check that it contains the key elements
text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "")
text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "") # type: ignore[attr-dict]
assert "hello" in text_without_whitespace
assert "world" in text_without_whitespace
assert "[" in text_without_whitespace
@ -256,9 +248,7 @@ class TestLegacyToolJsonParsing:
# Invalid JSON should remain a string
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
result = await tool.run({"string": invalid_json})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == invalid_json
assert result[0].text == invalid_json # type: ignore[attr-dict]
async def test_keep_str_union_as_str(self):
"""Test that string arguments are kept as strings when parsing would create an invalid value"""
@ -273,9 +263,7 @@ class TestLegacyToolJsonParsing:
# Invalid JSON for the union type should remain a string
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
result = await tool.run({"string": invalid_json})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == invalid_json
assert result[0].text == invalid_json # type: ignore[attr-dict]
async def test_complex_type_validation(self):
"""Test that parsed JSON is validated against complex types"""
@ -292,11 +280,9 @@ class TestLegacyToolJsonParsing:
# Valid JSON for the model
valid_json = '{"x": 1, "y": {"1": "hello"}}'
result = await tool.run({"data": valid_json})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert '"x": 1' in result[0].text
assert '"y": {' in result[0].text
assert '"1": "hello"' in result[0].text
assert '"x": 1' in result[0].text # type: ignore[attr-dict]
assert '"y": {' in result[0].text # type: ignore[attr-dict]
assert '"1": "hello"' in result[0].text # type: ignore[attr-dict]
# Invalid JSON for the model (y has string keys, not int keys)
# Should throw a validation error
@ -317,8 +303,7 @@ class TestLegacyToolJsonParsing:
result = await client.call_tool(
"process_list", {"items": "[1, 2, 3, 4, 5]"}
)
assert isinstance(result[0], TextContent)
assert result[0].text == "15"
assert result[0].text == "15" # type: ignore[attr-dict]
async def test_tool_list_coercion_error(self):
"""Test that a list coercion error is raised if the input is not a valid list."""
@ -348,8 +333,7 @@ class TestLegacyToolJsonParsing:
result = await client.call_tool(
"process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
)
assert isinstance(result[0], TextContent)
assert result[0].text == "6"
assert result[0].text == "6" # type: ignore[attr-dict]
async def test_tool_set_coercion(self):
"""Test JSON string to set type coercion."""
@ -362,8 +346,7 @@ class TestLegacyToolJsonParsing:
async with Client(mcp) as client:
result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
assert isinstance(result[0], TextContent)
assert result[0].text == "15"
assert result[0].text == "15" # type: ignore[attr-dict]
async def test_tool_tuple_coercion(self):
"""Test JSON string to tuple type coercion."""
@ -376,5 +359,4 @@ class TestLegacyToolJsonParsing:
async with Client(mcp) as client:
result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
assert isinstance(result[0], TextContent)
assert result[0].text == "4"
assert result[0].text == "4" # type: ignore[attr-dict]

View file

@ -5,7 +5,7 @@ from typing import Annotated, Any
import pydantic_core
import pytest
from mcp.types import ImageContent, TextContent
from mcp.types import ImageContent
from pydantic import BaseModel
from fastmcp import Context, FastMCP, Image
@ -318,13 +318,8 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(add)
result = await manager.call_tool("add", {"a": 1, "b": 2})
assert isinstance(result, list)
assert len(result) == 1
from mcp.types import TextContent
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert json.loads(result[0].text) == 3
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_call_async_tool(self):
async def double(n: int) -> int:
@ -334,12 +329,7 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(double)
result = await manager.call_tool("double", {"n": 5})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "10"
assert json.loads(result[0].text) == 10
assert result[0].text == "10" # type: ignore[attr-defined]
async def test_call_tool_callable_object(self):
class Adder:
@ -352,11 +342,7 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(Adder())
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert json.loads(result[0].text) == 3
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_call_tool_callable_object_async(self):
class Adder:
@ -369,11 +355,7 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(Adder())
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert json.loads(result[0].text) == 3
assert result[0].text == "3" # type: ignore[attr-defined]
async def test_call_tool_with_default_args(self):
def add(a: int, b: int = 1) -> int:
@ -383,12 +365,8 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(add)
result = await manager.call_tool("add", {"a": 1})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "2"
assert json.loads(result[0].text) == 2
assert result[0].text == "2" # type: ignore[attr-defined]
async def test_call_tool_with_missing_args(self):
def add(a: int, b: int) -> int:
@ -413,11 +391,7 @@ class TestCallTools:
manager.add_tool_from_fn(sum_vals)
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "6"
assert json.loads(result[0].text) == 6
assert result[0].text == "6" # type: ignore[attr-defined]
async def test_call_tool_with_list_int_input_legacy_behavior(self):
"""Legacy behavior -- parse a stringified JSON object"""
@ -431,11 +405,7 @@ class TestCallTools:
with temporary_settings(tool_attempt_parse_json_args=True):
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "6"
assert json.loads(result[0].text) == 6
assert result[0].text == "6" # type: ignore[attr-defined]
async def test_call_tool_with_list_str_or_str_input(self):
def concat_strs(vals: list[str] | str) -> str:
@ -446,16 +416,10 @@ class TestCallTools:
# Try both with plain python object and with JSON list
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "abc"
assert result[0].text == "abc" # type: ignore[attr-defined]
result = await manager.call_tool("concat_strs", {"vals": "a"})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "a"
assert result[0].text == "a" # type: ignore[attr-defined]
async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self):
"""Legacy behavior -- parse a stringified JSON object"""
@ -468,16 +432,10 @@ class TestCallTools:
with temporary_settings(tool_attempt_parse_json_args=True):
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "abc"
assert result[0].text == "abc" # type: ignore[attr-defined]
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "a"
assert result[0].text == "a" # type: ignore[attr-defined]
async def test_call_tool_with_complex_model(self):
class MyShrimpTank(BaseModel):
@ -507,10 +465,7 @@ class TestCallTools:
},
)
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '[\n "rex",\n "gertrude"\n]'
assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
async def test_call_tool_with_custom_serializer(self):
"""Test that a custom serializer provided to FastMCP is used by tools."""
@ -530,10 +485,7 @@ class TestCallTools:
manager.add_tool_from_fn(get_data)
result = await manager.call_tool("get_data", {})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}'
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
async def test_call_tool_with_list_result_custom_serializer(self):
"""Test that a custom serializer provided to FastMCP is used by tools that return lists."""
@ -555,12 +507,9 @@ class TestCallTools:
manager.add_tool_from_fn(get_data)
result = await manager.call_tool("get_data", {})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert (
result[0].text
== 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]'
result[0].text # type: ignore[attr-defined]
== 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
)
async def test_custom_serializer_fallback_on_error(self):
@ -580,10 +529,7 @@ class TestCallTools:
manager.add_tool_from_fn(get_data)
result = await manager.call_tool("get_data", {})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == pydantic_core.to_json(uuid_result).decode()
assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined]
class TestToolSchema:
@ -648,10 +594,7 @@ class TestContextHandling:
with context:
result = await manager.call_tool("tool_with_context", {"x": 42})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
assert result[0].text == "42" # type: ignore[attr-defined]
async def test_context_injection_async(self):
"""Test that context is properly injected in async tools."""
@ -668,14 +611,10 @@ class TestContextHandling:
with context:
result = await manager.call_tool("async_tool", {"x": 42})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
assert result[0].text == "42" # type: ignore[attr-defined]
async def test_context_optional(self):
"""Test that context is optional when calling tools."""
from mcp.types import TextContent
def tool_with_context(x: int, ctx: Context | None) -> int:
return x
@ -689,10 +628,7 @@ class TestContextHandling:
with context:
result = await manager.call_tool("tool_with_context", {"x": 42})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
assert result[0].text == "42" # type: ignore[attr-defined]
def test_parameterized_context_parameter_detection(self):
"""Test that context parameters are properly detected in
@ -782,7 +718,6 @@ class TestCustomToolNames:
async def test_call_tool_with_custom_name(self):
"""Test calling a tool added with a custom name."""
from mcp.types import TextContent
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
@ -793,11 +728,7 @@ class TestCustomToolNames:
# Tool should be callable by its custom name
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "15"
assert json.loads(result[0].text) == 15
assert result[0].text == "15" # type: ignore[attr-defined]
# Original name should not be registered
with pytest.raises(NotFoundError, match="Unknown tool: multiply"):

View file

@ -1,8 +1,6 @@
import inspect
from pathlib import Path
from mcp.types import TextContent
from fastmcp.client.client import Client
from fastmcp.client.transports import (
SSETransport,
@ -136,7 +134,5 @@ async def test_multi_client(tmp_path: Path):
result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
assert isinstance(result_1[0], TextContent)
assert result_1[0].text == "3"
assert isinstance(result_2[0], TextContent)
assert result_2[0].text == "3"
assert result_1[0].text == "3" # type: ignore[attr-dict]
assert result_2[0].text == "3" # type: ignore[attr-dict]

470
uv.lock generated
View file

@ -39,6 +39,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" },
]
[[package]]
name = "authlib"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371, upload-time = "2025-05-23T00:21:45.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981, upload-time = "2025-05-23T00:21:43.075Z" },
]
[[package]]
name = "certifi"
version = "2025.4.26"
@ -48,6 +60,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" },
{ url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" },
{ url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" },
{ url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" },
{ url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" },
{ url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" },
{ url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" },
{ url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" },
{ url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" },
{ url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" },
{ url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" },
{ url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" },
{ url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" },
{ url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" },
{ url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" },
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" },
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" },
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" },
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" },
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" },
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
]
[[package]]
name = "cfgv"
version = "3.4.0"
@ -120,14 +189,14 @@ wheels = [
[[package]]
name = "click"
version = "8.1.8"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
@ -141,7 +210,7 @@ wheels = [
[[package]]
name = "copychat"
version = "0.5.3"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitpython" },
@ -151,69 +220,73 @@ dependencies = [
{ name = "tiktoken" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/15/123af9305c5afec6bd10994404c88a9246f113bf9969ecb69db1ea2aceae/copychat-0.5.3.tar.gz", hash = "sha256:c95c554d486da71bfe6b677ef59d18f0e2bb106804dd532775275ff32ed5933e", size = 55647, upload-time = "2025-04-23T22:11:44.694Z" }
sdist = { url = "https://files.pythonhosted.org/packages/25/05/aaf6134b5a7cd270f497275c3dca7a7eb697c8d32492fa82c5d3e4fbc99c/copychat-0.6.2.tar.gz", hash = "sha256:9f8eb589dd1b3a0c0d852834b5fd5baabdee90cb705971818eb08df2ac7485eb", size = 75101, upload-time = "2025-05-21T20:31:59.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d7/69145156acb64a4b4eedc05079ef0e470637f6a952e9590c4c4e25af04b3/copychat-0.5.3-py3-none-any.whl", hash = "sha256:bc2d5b9cf6acbfdec148d48fe7d3379e58e3f60ed391c0c72c51d3f92fb166e0", size = 16549, upload-time = "2025-04-23T22:11:42.77Z" },
{ url = "https://files.pythonhosted.org/packages/64/cc/2c260559dfcdeb15f0d6523021d56d32b5a616dc52cc84146eb8000bfa9b/copychat-0.6.2-py3-none-any.whl", hash = "sha256:9d0d0d0087a7ff6635b298fff60ae90e211c2a75c2718d7a2cb9ca45aaf98fdf", size = 19215, upload-time = "2025-05-21T20:31:57.938Z" },
]
[[package]]
name = "coverage"
version = "7.8.0"
version = "7.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872, upload-time = "2025-03-30T20:36:45.376Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/01/1c5e6ee4ebaaa5e079db933a9a45f61172048c7efa06648445821a201084/coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", size = 211379, upload-time = "2025-03-30T20:34:53.904Z" },
{ url = "https://files.pythonhosted.org/packages/e9/16/a463389f5ff916963471f7c13585e5f38c6814607306b3cb4d6b4cf13384/coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", size = 211814, upload-time = "2025-03-30T20:34:56.959Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b1/77062b0393f54d79064dfb72d2da402657d7c569cfbc724d56ac0f9c67ed/coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", size = 240937, upload-time = "2025-03-30T20:34:58.751Z" },
{ url = "https://files.pythonhosted.org/packages/d7/54/c7b00a23150083c124e908c352db03bcd33375494a4beb0c6d79b35448b9/coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", size = 238849, upload-time = "2025-03-30T20:35:00.521Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/a6b7cfebd34e7b49f844788fda94713035372b5200c23088e3bbafb30970/coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", size = 239986, upload-time = "2025-03-30T20:35:02.307Z" },
{ url = "https://files.pythonhosted.org/packages/21/8c/c965ecef8af54e6d9b11bfbba85d4f6a319399f5f724798498387f3209eb/coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", size = 239896, upload-time = "2025-03-30T20:35:04.141Z" },
{ url = "https://files.pythonhosted.org/packages/40/83/070550273fb4c480efa8381735969cb403fa8fd1626d74865bfaf9e4d903/coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", size = 238613, upload-time = "2025-03-30T20:35:05.889Z" },
{ url = "https://files.pythonhosted.org/packages/07/76/fbb2540495b01d996d38e9f8897b861afed356be01160ab4e25471f4fed1/coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", size = 238909, upload-time = "2025-03-30T20:35:07.76Z" },
{ url = "https://files.pythonhosted.org/packages/a3/7e/76d604db640b7d4a86e5dd730b73e96e12a8185f22b5d0799025121f4dcb/coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", size = 213948, upload-time = "2025-03-30T20:35:09.144Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a7/f8ce4aafb4a12ab475b56c76a71a40f427740cf496c14e943ade72e25023/coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", size = 214844, upload-time = "2025-03-30T20:35:10.734Z" },
{ url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493, upload-time = "2025-03-30T20:35:12.286Z" },
{ url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921, upload-time = "2025-03-30T20:35:14.18Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556, upload-time = "2025-03-30T20:35:15.616Z" },
{ url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245, upload-time = "2025-03-30T20:35:18.648Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032, upload-time = "2025-03-30T20:35:20.131Z" },
{ url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679, upload-time = "2025-03-30T20:35:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852, upload-time = "2025-03-30T20:35:23.525Z" },
{ url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389, upload-time = "2025-03-30T20:35:25.09Z" },
{ url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997, upload-time = "2025-03-30T20:35:26.914Z" },
{ url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911, upload-time = "2025-03-30T20:35:28.498Z" },
{ url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684, upload-time = "2025-03-30T20:35:29.959Z" },
{ url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935, upload-time = "2025-03-30T20:35:31.912Z" },
{ url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994, upload-time = "2025-03-30T20:35:33.455Z" },
{ url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885, upload-time = "2025-03-30T20:35:35.354Z" },
{ url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142, upload-time = "2025-03-30T20:35:37.121Z" },
{ url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906, upload-time = "2025-03-30T20:35:39.07Z" },
{ url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124, upload-time = "2025-03-30T20:35:40.598Z" },
{ url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317, upload-time = "2025-03-30T20:35:42.204Z" },
{ url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170, upload-time = "2025-03-30T20:35:44.216Z" },
{ url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969, upload-time = "2025-03-30T20:35:45.797Z" },
{ url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708, upload-time = "2025-03-30T20:35:47.417Z" },
{ url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981, upload-time = "2025-03-30T20:35:49.002Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495, upload-time = "2025-03-30T20:35:51.073Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538, upload-time = "2025-03-30T20:35:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561, upload-time = "2025-03-30T20:35:54.658Z" },
{ url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633, upload-time = "2025-03-30T20:35:56.221Z" },
{ url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712, upload-time = "2025-03-30T20:35:57.801Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000, upload-time = "2025-03-30T20:35:59.378Z" },
{ url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195, upload-time = "2025-03-30T20:36:01.005Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998, upload-time = "2025-03-30T20:36:03.006Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541, upload-time = "2025-03-30T20:36:04.638Z" },
{ url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767, upload-time = "2025-03-30T20:36:06.503Z" },
{ url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997, upload-time = "2025-03-30T20:36:08.137Z" },
{ url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708, upload-time = "2025-03-30T20:36:09.781Z" },
{ url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046, upload-time = "2025-03-30T20:36:11.409Z" },
{ url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139, upload-time = "2025-03-30T20:36:13.86Z" },
{ url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307, upload-time = "2025-03-30T20:36:16.074Z" },
{ url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116, upload-time = "2025-03-30T20:36:18.033Z" },
{ url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909, upload-time = "2025-03-30T20:36:19.644Z" },
{ url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068, upload-time = "2025-03-30T20:36:21.282Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443, upload-time = "2025-03-30T20:36:41.959Z" },
{ url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload-time = "2025-03-30T20:36:43.61Z" },
{ url = "https://files.pythonhosted.org/packages/26/6b/7dd06399a5c0b81007e3a6af0395cd60e6a30f959f8d407d3ee04642e896/coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a", size = 211573, upload-time = "2025-05-23T11:37:47.207Z" },
{ url = "https://files.pythonhosted.org/packages/f0/df/2b24090820a0bac1412955fb1a4dade6bc3b8dcef7b899c277ffaf16916d/coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be", size = 212006, upload-time = "2025-05-23T11:37:50.289Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c4/e4e3b998e116625562a872a342419652fa6ca73f464d9faf9f52f1aff427/coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3", size = 241128, upload-time = "2025-05-23T11:37:52.229Z" },
{ url = "https://files.pythonhosted.org/packages/b1/67/b28904afea3e87a895da850ba587439a61699bf4b73d04d0dfd99bbd33b4/coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6", size = 239026, upload-time = "2025-05-23T11:37:53.846Z" },
{ url = "https://files.pythonhosted.org/packages/8c/0f/47bf7c5630d81bc2cd52b9e13043685dbb7c79372a7f5857279cc442b37c/coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622", size = 240172, upload-time = "2025-05-23T11:37:55.711Z" },
{ url = "https://files.pythonhosted.org/packages/ba/38/af3eb9d36d85abc881f5aaecf8209383dbe0fa4cac2d804c55d05c51cb04/coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c", size = 240086, upload-time = "2025-05-23T11:37:57.724Z" },
{ url = "https://files.pythonhosted.org/packages/9e/64/c40c27c2573adeba0fe16faf39a8aa57368a1f2148865d6bb24c67eadb41/coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3", size = 238792, upload-time = "2025-05-23T11:37:59.737Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ab/b7c85146f15457671c1412afca7c25a5696d7625e7158002aa017e2d7e3c/coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404", size = 239096, upload-time = "2025-05-23T11:38:01.693Z" },
{ url = "https://files.pythonhosted.org/packages/d3/50/9446dad1310905fb1dc284d60d4320a5b25d4e3e33f9ea08b8d36e244e23/coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7", size = 214144, upload-time = "2025-05-23T11:38:03.68Z" },
{ url = "https://files.pythonhosted.org/packages/23/ed/792e66ad7b8b0df757db8d47af0c23659cdb5a65ef7ace8b111cacdbee89/coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347", size = 215043, upload-time = "2025-05-23T11:38:05.217Z" },
{ url = "https://files.pythonhosted.org/packages/6a/4d/1ff618ee9f134d0de5cc1661582c21a65e06823f41caf801aadf18811a8e/coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9", size = 211692, upload-time = "2025-05-23T11:38:08.485Z" },
{ url = "https://files.pythonhosted.org/packages/96/fa/c3c1b476de96f2bc7a8ca01a9f1fcb51c01c6b60a9d2c3e66194b2bdb4af/coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879", size = 212115, upload-time = "2025-05-23T11:38:09.989Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c2/5414c5a1b286c0f3881ae5adb49be1854ac5b7e99011501f81c8c1453065/coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a", size = 244740, upload-time = "2025-05-23T11:38:11.947Z" },
{ url = "https://files.pythonhosted.org/packages/cd/46/1ae01912dfb06a642ef3dd9cf38ed4996fda8fe884dab8952da616f81a2b/coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5", size = 242429, upload-time = "2025-05-23T11:38:13.955Z" },
{ url = "https://files.pythonhosted.org/packages/06/58/38c676aec594bfe2a87c7683942e5a30224791d8df99bcc8439fde140377/coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11", size = 244218, upload-time = "2025-05-23T11:38:15.631Z" },
{ url = "https://files.pythonhosted.org/packages/80/0c/95b1023e881ce45006d9abc250f76c6cdab7134a1c182d9713878dfefcb2/coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a", size = 243865, upload-time = "2025-05-23T11:38:17.622Z" },
{ url = "https://files.pythonhosted.org/packages/57/37/0ae95989285a39e0839c959fe854a3ae46c06610439350d1ab860bf020ac/coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb", size = 242038, upload-time = "2025-05-23T11:38:19.966Z" },
{ url = "https://files.pythonhosted.org/packages/4d/82/40e55f7c0eb5e97cc62cbd9d0746fd24e8caf57be5a408b87529416e0c70/coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54", size = 242567, upload-time = "2025-05-23T11:38:21.912Z" },
{ url = "https://files.pythonhosted.org/packages/f9/35/66a51adc273433a253989f0d9cc7aa6bcdb4855382cf0858200afe578861/coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a", size = 214194, upload-time = "2025-05-23T11:38:23.571Z" },
{ url = "https://files.pythonhosted.org/packages/f6/8f/a543121f9f5f150eae092b08428cb4e6b6d2d134152c3357b77659d2a605/coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975", size = 215109, upload-time = "2025-05-23T11:38:25.137Z" },
{ url = "https://files.pythonhosted.org/packages/77/65/6cc84b68d4f35186463cd7ab1da1169e9abb59870c0f6a57ea6aba95f861/coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53", size = 213521, upload-time = "2025-05-23T11:38:27.123Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" },
{ url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" },
{ url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" },
{ url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" },
{ url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" },
{ url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" },
{ url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" },
{ url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" },
{ url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" },
{ url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" },
{ url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" },
{ url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" },
{ url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" },
{ url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" },
{ url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" },
{ url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" },
{ url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" },
{ url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" },
{ url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" },
{ url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" },
{ url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" },
{ url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" },
{ url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" },
{ url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" },
{ url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" },
{ url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" },
{ url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" },
{ url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" },
{ url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" },
{ url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" },
{ url = "https://files.pythonhosted.org/packages/69/2f/572b29496d8234e4a7773200dd835a0d32d9e171f2d974f3fe04a9dbc271/coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837", size = 203636, upload-time = "2025-05-23T11:39:52.002Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" },
]
[package.optional-dependencies]
@ -221,6 +294,53 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "cryptography"
version = "45.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/b2/2345dc595998caa6f68adf84e8f8b50d18e9fc4638d32b22ea8daedd4b7a/cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71", size = 7056239, upload-time = "2025-05-25T14:16:12.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" },
{ url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" },
{ url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" },
{ url = "https://files.pythonhosted.org/packages/31/5f/d6f8753c8708912df52e67969e80ef70b8e8897306cd9eb8b98201f8c184/cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9", size = 3898150, upload-time = "2025-05-25T14:16:20.34Z" },
{ url = "https://files.pythonhosted.org/packages/8b/50/f256ab79c671fb066e47336706dc398c3b1e125f952e07d54ce82cf4011a/cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56", size = 4466473, upload-time = "2025-05-25T14:16:22.605Z" },
{ url = "https://files.pythonhosted.org/packages/62/e7/312428336bb2df0848d0768ab5a062e11a32d18139447a76dfc19ada8eed/cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca", size = 4211890, upload-time = "2025-05-25T14:16:24.738Z" },
{ url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" },
{ url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" },
{ url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" },
{ url = "https://files.pythonhosted.org/packages/f5/bb/e86e9cf07f73a98d84a4084e8fd420b0e82330a901d9cac8149f994c3417/cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710", size = 2934752, upload-time = "2025-05-25T14:16:32.204Z" },
{ url = "https://files.pythonhosted.org/packages/c7/75/063bc9ddc3d1c73e959054f1fc091b79572e716ef74d6caaa56e945b4af9/cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490", size = 3412465, upload-time = "2025-05-25T14:16:33.888Z" },
{ url = "https://files.pythonhosted.org/packages/71/9b/04ead6015229a9396890d7654ee35ef630860fb42dc9ff9ec27f72157952/cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06", size = 7031892, upload-time = "2025-05-25T14:16:36.214Z" },
{ url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" },
{ url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" },
{ url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" },
{ url = "https://files.pythonhosted.org/packages/d4/3d/5185b117c32ad4f40846f579369a80e710d6146c2baa8ce09d01612750db/cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc", size = 3886324, upload-time = "2025-05-25T14:16:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/67/85/caba91a57d291a2ad46e74016d1f83ac294f08128b26e2a81e9b4f2d2555/cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342", size = 4450447, upload-time = "2025-05-25T14:16:44.759Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d1/164e3c9d559133a38279215c712b8ba38e77735d3412f37711b9f8f6f7e0/cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b", size = 4200576, upload-time = "2025-05-25T14:16:46.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" },
{ url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" },
{ url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" },
{ url = "https://files.pythonhosted.org/packages/80/38/d572f6482d45789a7202fb87d052deb7a7b136bf17473ebff33536727a2c/cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab", size = 2924070, upload-time = "2025-05-25T14:16:53.472Z" },
{ url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" },
{ url = "https://files.pythonhosted.org/packages/1b/63/ce30cb7204e8440df2f0b251dc0464a26c55916610d1ba4aa912f838bcc8/cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49", size = 3578348, upload-time = "2025-05-25T14:16:56.792Z" },
{ url = "https://files.pythonhosted.org/packages/45/0b/87556d3337f5e93c37fda0a0b5d3e7b4f23670777ce8820fce7962a7ed22/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9", size = 4142867, upload-time = "2025-05-25T14:16:58.459Z" },
{ url = "https://files.pythonhosted.org/packages/72/ba/21356dd0bcb922b820211336e735989fe2cf0d8eaac206335a0906a5a38c/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc", size = 4385000, upload-time = "2025-05-25T14:17:00.656Z" },
{ url = "https://files.pythonhosted.org/packages/2f/2b/71c78d18b804c317b66283be55e20329de5cd7e1aec28e4c5fbbe21fd046/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1", size = 4144195, upload-time = "2025-05-25T14:17:02.782Z" },
{ url = "https://files.pythonhosted.org/packages/55/3e/9f9b468ea779b4dbfef6af224804abd93fbcb2c48605d7443b44aea77979/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e", size = 4384540, upload-time = "2025-05-25T14:17:04.49Z" },
{ url = "https://files.pythonhosted.org/packages/97/f5/6e62d10cf29c50f8205c0dc9aec986dca40e8e3b41bf1a7878ea7b11e5ee/cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0", size = 3328796, upload-time = "2025-05-25T14:17:06.174Z" },
{ url = "https://files.pythonhosted.org/packages/e7/d4/58a246342093a66af8935d6aa59f790cbb4731adae3937b538d054bdc2f9/cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7", size = 3589802, upload-time = "2025-05-25T14:17:07.792Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/751ebea58c87b5be533c429f01996050a72c7283b59eee250275746632ea/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8", size = 4146964, upload-time = "2025-05-25T14:17:09.538Z" },
{ url = "https://files.pythonhosted.org/packages/8d/01/28c90601b199964de383da0b740b5156f5d71a1da25e7194fdf793d373ef/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4", size = 4388103, upload-time = "2025-05-25T14:17:11.978Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ec/cd892180b9e42897446ef35c62442f5b8b039c3d63a05f618aa87ec9ebb5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972", size = 4150031, upload-time = "2025-05-25T14:17:14.131Z" },
{ url = "https://files.pythonhosted.org/packages/db/d4/22628c2dedd99289960a682439c6d3aa248dff5215123ead94ac2d82f3f5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c", size = 4387389, upload-time = "2025-05-25T14:17:17.303Z" },
{ url = "https://files.pythonhosted.org/packages/39/ec/ba3961abbf8ecb79a3586a4ff0ee08c9d7a9938b4312fb2ae9b63f48a8ba/cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19", size = 3337432, upload-time = "2025-05-25T14:17:19.507Z" },
]
[[package]]
name = "decorator"
version = "5.2.1"
@ -250,11 +370,14 @@ wheels = [
[[package]]
name = "exceptiongroup"
version = "1.2.2"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" },
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
@ -277,15 +400,15 @@ wheels = [
[[package]]
name = "fancycompleter"
version = "0.11.0"
version = "0.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
{ name = "pyreadline3", marker = "python_full_version < '3.13' and sys_platform == 'win32'" },
{ name = "pyrepl", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/03/eb007f5e90c13016debb6ecd717f0595ce758bf30906f2cb273673e8427d/fancycompleter-0.11.0.tar.gz", hash = "sha256:632b265b29dd0315b96d33d13d83132a541d6312262214f50211b3981bb4fa00", size = 341517, upload-time = "2025-04-13T12:48:09.487Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/4c/d11187dee93eff89d082afda79b63c79320ae1347e49485a38f05ad359d0/fancycompleter-0.11.1.tar.gz", hash = "sha256:5b4ad65d76b32b1259251516d0f1cb2d82832b1ff8506697a707284780757f69", size = 341776, upload-time = "2025-05-26T12:59:11.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/52/d3e234bf32ee97e71b45886a52871dc681345d64b449a930bab38c73cbcb/fancycompleter-0.11.0-py3-none-any.whl", hash = "sha256:a4712fdda8d7f3df08511ab2755ea0f1e669e2c65701a28c0c0aa2ff528521ed", size = 11166, upload-time = "2025-04-13T12:48:08.12Z" },
{ url = "https://files.pythonhosted.org/packages/30/c3/6f0e3896f193528bbd2b4d2122d4be8108a37efab0b8475855556a8c4afa/fancycompleter-0.11.1-py3-none-any.whl", hash = "sha256:44243d7fab37087208ca5acacf8f74c0aa4d733d04d593857873af7513cdf8a6", size = 11207, upload-time = "2025-05-26T12:59:09.857Z" },
]
[[package]]
@ -306,6 +429,7 @@ wheels = [
name = "fastmcp"
source = { editable = "." }
dependencies = [
{ name = "authlib" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "mcp" },
@ -321,16 +445,18 @@ dev = [
{ name = "copychat" },
{ name = "dirty-equals" },
{ name = "fastapi" },
{ name = "ipython", version = "8.36.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "ipython", version = "9.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "ipython", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pdbpp" },
{ name = "pre-commit" },
{ name = "pyinstrument" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-env" },
{ name = "pytest-flakefinder" },
{ name = "pytest-httpx" },
{ name = "pytest-report" },
{ name = "pytest-timeout" },
{ name = "pytest-xdist" },
@ -339,9 +465,10 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "authlib", specifier = ">=1.5.2" },
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mcp", specifier = ">=1.9.0,<2.0.0" },
{ name = "mcp", specifier = ">=1.9.2,<2.0.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "rich", specifier = ">=13.9.4" },
@ -357,12 +484,14 @@ dev = [
{ name = "ipython", specifier = ">=8.12.3" },
{ name = "pdbpp", specifier = ">=0.10.3" },
{ name = "pre-commit" },
{ name = "pyinstrument", specifier = ">=5.0.2" },
{ name = "pyright", specifier = ">=1.1.389" },
{ name = "pytest", specifier = ">=8.3.3" },
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-env", specifier = ">=1.1.5" },
{ name = "pytest-flakefinder" },
{ name = "pytest-httpx", specifier = ">=0.35.0" },
{ name = "pytest-report", specifier = ">=0.2.1" },
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.6.1" },
@ -450,11 +579,11 @@ wheels = [
[[package]]
name = "identify"
version = "2.6.10"
version = "2.6.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/83/b6ea0334e2e7327084a46aaaf71f2146fc061a192d6518c0d020120cd0aa/identify-2.6.10.tar.gz", hash = "sha256:45e92fd704f3da71cc3880036633f48b4b7265fd4de2b57627cb157216eb7eb8", size = 99201, upload-time = "2025-04-19T15:10:38.32Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/d3/85feeba1d097b81a44bcffa6a0beab7b4dfffe78e82fc54978d3ac380736/identify-2.6.10-py2.py3-none-any.whl", hash = "sha256:5f34248f54136beed1a7ba6a6b5c4b6cf21ff495aac7c359e1ef831ae3b8ab25", size = 99101, upload-time = "2025-04-19T15:10:36.701Z" },
{ url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" },
]
[[package]]
@ -477,7 +606,7 @@ wheels = [
[[package]]
name = "ipython"
version = "8.36.0"
version = "8.37.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
@ -495,14 +624,14 @@ dependencies = [
{ name = "traitlets", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/9f/d9a73710df947b7804bd9d93509463fb3a89e0ddc99c9fcc67279cddbeb6/ipython-8.36.0.tar.gz", hash = "sha256:24658e9fe5c5c819455043235ba59cfffded4a35936eefceceab6b192f7092ff", size = 5604997, upload-time = "2025-04-25T18:03:38.031Z" }
sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/d7/c1c9f371790b3a181e343c4815a361e5a0cc7d90ef6642d64ba5d05de289/ipython-8.36.0-py3-none-any.whl", hash = "sha256:12b913914d010dcffa2711505ec8be4bf0180742d97f1e5175e51f22086428c1", size = 831074, upload-time = "2025-04-25T18:03:34.951Z" },
{ url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864, upload-time = "2025-05-31T16:39:06.38Z" },
]
[[package]]
name = "ipython"
version = "9.2.0"
version = "9.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11'",
@ -520,9 +649,9 @@ dependencies = [
{ name = "traitlets", marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/02/63a84444a7409b3c0acd1de9ffe524660e0e5d82ee473e78b45e5bfb64a4/ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b", size = 4424394, upload-time = "2025-04-25T17:55:40.498Z" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/09/4c7e06b96fbd203e06567b60fb41b06db606b6a82db6db7b2c85bb72a15c/ipython-9.3.0.tar.gz", hash = "sha256:79eb896f9f23f50ad16c3bc205f686f6e030ad246cc309c6279a242b14afe9d8", size = 4426460, upload-time = "2025-05-31T16:34:55.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/ce/5e897ee51b7d26ab4e47e5105e7368d40ce6cfae2367acdf3165396d50be/ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6", size = 604277, upload-time = "2025-04-25T17:55:37.625Z" },
{ url = "https://files.pythonhosted.org/packages/3c/99/9ed3d52d00f1846679e3aa12e2326ac7044b5e7f90dc822b60115fa533ca/ipython-9.3.0-py3-none-any.whl", hash = "sha256:1a0b6dd9221a1f5dddf725b57ac0cb6fddc7b5f470576231ae9162b9b3455a04", size = 605320, upload-time = "2025-05-31T16:34:52.154Z" },
]
[[package]]
@ -575,7 +704,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.9.0"
version = "1.9.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -588,9 +717,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432, upload-time = "2025-05-15T18:51:06.615Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/03/77c49cce3ace96e6787af624611b627b2828f0dca0f8df6f330a10eea51e/mcp-1.9.2.tar.gz", hash = "sha256:3c7651c053d635fd235990a12e84509fe32780cd359a5bbef352e20d4d963c05", size = 333066, upload-time = "2025-05-29T14:42:17.76Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082, upload-time = "2025-05-15T18:51:04.916Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a6/8f5ee9da9f67c0fd8933f63d6105f02eabdac8a8c0926728368ffbb6744d/mcp-1.9.2-py3-none-any.whl", hash = "sha256:bc29f7fd67d157fef378f89a4210384f5fecf1168d0feb12d22929818723f978", size = 131083, upload-time = "2025-05-29T14:42:16.211Z" },
]
[[package]]
@ -677,20 +806,20 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.3.7"
version = "4.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291, upload-time = "2025-03-19T20:36:10.989Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload-time = "2025-03-19T20:36:09.038Z" },
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
]
[[package]]
name = "pluggy"
version = "1.5.0"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" },
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
@ -739,9 +868,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]]
name = "pydantic"
version = "2.11.4"
version = "2.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@ -749,9 +887,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540, upload-time = "2025-04-29T20:38:55.02Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900, upload-time = "2025-04-29T20:38:52.724Z" },
{ url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" },
]
[[package]]
@ -864,6 +1002,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
]
[[package]]
name = "pyinstrument"
version = "5.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/d0/665828770e8fcd5c50880dc83f03811f814d6260bc6a8068dca0a520e68a/pyinstrument-5.0.2.tar.gz", hash = "sha256:e466033ead16a48ffa8bedbd633b90d416fa772b3b22f61226882ace0371f5f3", size = 263930, upload-time = "2025-05-24T15:47:13.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/25/f64d0be5f574d2df9ddac3e7a381863f92d8ad30170b1a9de0cf805f4318/pyinstrument-5.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1aeaf6b39ad40b3f03bea5fa3a9bd453a92aeb721dde29c1597f842ed9c8566a", size = 129638, upload-time = "2025-05-24T15:45:20.113Z" },
{ url = "https://files.pythonhosted.org/packages/6e/b8/bc6657f91a8d2f7cf58b0993aa4e6cf20e027b53aca65c2464a50738d711/pyinstrument-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d734bd236d00e0e7f950019c689eaba1c9dd15e355867d8926c8b18b6077b221", size = 122220, upload-time = "2025-05-24T15:45:22.4Z" },
{ url = "https://files.pythonhosted.org/packages/63/5f/9a7edf13333015a9ccfd3fcf5c75ea793fbb30b153aebf6c6ace40a607b2/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:520208a9b6c3985473aa9c3f30875ae5e78e77a81081df1d8aeb4fd8b4caf197", size = 146928, upload-time = "2025-05-24T15:45:23.802Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f9/f7d7b28c9038f1a570e96c8eea2a9ffeeb3ee9e75cfc74a370554776f1a6/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75e115b759288b8d65a0bf31a34a542ae102c58ef407e0614a43e0c39d261875", size = 157136, upload-time = "2025-05-24T15:45:25.629Z" },
{ url = "https://files.pythonhosted.org/packages/db/ee/aa99f275b3c5f0f32ccd37f77cb64e57597a1f26280aec03a50d2158eab7/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:091f93e6787c485a7ddf670608c00448e858a056677fc25ce349f8e44d6a9e54", size = 144680, upload-time = "2025-05-24T15:45:27.06Z" },
{ url = "https://files.pythonhosted.org/packages/ae/77/cd7300a5e099c4ad971a647ea8fb9bd081482a9e5751479034e206cd1f69/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b07971afa2652cb4f2bdcffaef11aefa32b5384c0cfb32acf9955e96dd8df8", size = 145624, upload-time = "2025-05-24T15:45:28.517Z" },
{ url = "https://files.pythonhosted.org/packages/30/59/1957e2ca2277ecc69e247383527df331002e23940d5b0a79fc5f3b870d60/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bcb28a21b80eea5986eb5cb3180689b1d489b7c6fddf34e1f4df1f95d467ad", size = 145901, upload-time = "2025-05-24T15:45:30.365Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/396ebdf387cde376ac4b70d52f3df07374f2501ac4c09992dadf641cd71f/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:80d28162070ff40c6d2ac7dc15b933ba20ef49e891a2e650cd2b91d30cd262b2", size = 145355, upload-time = "2025-05-24T15:45:31.859Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fec77476a9b4a316861b29f14cd0962871ad5c54c21e41c540f7c18c950c/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c75e52a9bf76f084ba074323835cba4927ab3e572adfc96439698b097e523780", size = 145008, upload-time = "2025-05-24T15:45:33.417Z" },
{ url = "https://files.pythonhosted.org/packages/fd/75/dcd391ca2790b32e41bbd49ad33626e85eb1ce00116b273d5e1d99b3e829/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ccefdd7dd938548ada43c95b24c42ec57e258ac7994a5ec7e4cc934fa4f1743b", size = 145396, upload-time = "2025-05-24T15:45:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/ca/5c/64e026ccf2c7908d10882955993e73ac35a1a77426bde2617973deeda07c/pyinstrument-5.0.2-cp310-cp310-win32.whl", hash = "sha256:6b617fb024c244738aa2f6b8c2a25853eac765360ac91062578bbbcc8e22ebfe", size = 123419, upload-time = "2025-05-24T15:45:36.276Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7c/7d221db96d461c7d28897499bdad55a8ae5ded983f60743bdfbf17438c20/pyinstrument-5.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6788c8f93c1a6e0ad8d0ccde1631d17eca3839945d0fa4d506cf5d4bd7a26b77", size = 124299, upload-time = "2025-05-24T15:45:37.642Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f2/b3f2416740be762fdfb052b63e1d85591682fa1d2ea6ee1b10db774f6350/pyinstrument-5.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0eec7a263cc1ccfb101594e13256115366338fee2a156be4172fe5315f71ec45", size = 129386, upload-time = "2025-05-24T15:45:39.429Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fa/a55b0bf911041b51d2a7a0e8a3feef5ed5ddb48ff0943fc667079955c14c/pyinstrument-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddd5effefb470d7f1886dc16467501b866e3b5883cf74773f13179e718b28393", size = 122100, upload-time = "2025-05-24T15:45:41.253Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e1/c42b94c795bc89d5a486ad7ef349fe3b7a8c3a4e730c09b5fa54af616a6b/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e7458a6aa4048c1703354fc8a4a3c8b59d27b1409aafb707cf339d3c0bc794c", size = 145385, upload-time = "2025-05-24T15:45:43.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/41/b511141cc336ffeac284cce7d121f05802ffea4ab2c19df8869adda49743/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2373dd699711463011ec14e4918427a777f7ab73b31ae374d960725dbd5d5a28", size = 156093, upload-time = "2025-05-24T15:45:44.755Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/4a7bc4f1c60d4886efb7397fd5bdcc7e537d01ec7372824cd834fff967a1/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38ef498fbe71c2bbd11247b71e722290da93a367d88a5a8e0f66f6cc764c2b60", size = 143136, upload-time = "2025-05-24T15:45:46.469Z" },
{ url = "https://files.pythonhosted.org/packages/d8/69/0ac06cf609153fc5eb30ccc0071ce300a181f422836ca7ce8cd431ac3ab4/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a58a8a50f0cb3ee1c2e43ffec51bf48f48945e141feed7ccd9194917b97fe5b", size = 144077, upload-time = "2025-05-24T15:45:48.333Z" },
{ url = "https://files.pythonhosted.org/packages/e3/24/12bd82822393f708e5da8f6c0b82def3f0cbe1f4fbd72a082688c583d7fa/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad2a97c79ecf0e610df292abb5c46d01a4f99778598881d6e918650fa39801b6", size = 144545, upload-time = "2025-05-24T15:45:50.137Z" },
{ url = "https://files.pythonhosted.org/packages/c9/62/40e7511fa46247ca56734d34e2d2eb6b14390c72b155255ecd1b2288d02d/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:57ec0277042ee198eb749b76a975fe60f006cd51ea0c7ce3054c937577d19315", size = 144010, upload-time = "2025-05-24T15:45:52.256Z" },
{ url = "https://files.pythonhosted.org/packages/82/77/6d40880dc46a6243951ad7cd50a77f26f6ad126b80d803616934efccf539/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:73d34047266f27acb67218e331288c0241cf0080fe4b87dfad5596236c71abd7", size = 143746, upload-time = "2025-05-24T15:45:53.702Z" },
{ url = "https://files.pythonhosted.org/packages/9b/a2/08b056d2420199dab877c665ed45bb685863dc5b83d31b2c4311430b2bbd/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cfdc23284a8e2f27637b357c226a15d52b96608d9dde187b68dfe33a947f4908", size = 143928, upload-time = "2025-05-24T15:45:55.103Z" },
{ url = "https://files.pythonhosted.org/packages/39/a1/bab336f70cd5f798d7fa21ec92784b99d3b2df0b5c1736a64fdaa4521004/pyinstrument-5.0.2-cp311-cp311-win32.whl", hash = "sha256:3e6fa135aee6af2c608e912d8d07906bbac3c5e564d94f92721831a957297c26", size = 123395, upload-time = "2025-05-24T15:45:56.469Z" },
{ url = "https://files.pythonhosted.org/packages/f2/15/8a7ac268ffe913aa64bb42ad43315dd0fc3ac493d451a50d4431ecb736c2/pyinstrument-5.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:6317df42a98a8074ccd25af5482312ec59a1f27c05dab408eb3c7b2081242733", size = 124198, upload-time = "2025-05-24T15:45:57.814Z" },
{ url = "https://files.pythonhosted.org/packages/95/36/4afdffbc4fd77dd0155c8943101f175e701ba00cb374c5e84e64790a2a32/pyinstrument-5.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d0b680ef269b528d8dcd8151362fba9683b0ac22ffe74cc8161c33b53c65b899", size = 129527, upload-time = "2025-05-24T15:45:59.216Z" },
{ url = "https://files.pythonhosted.org/packages/96/fe/7ea5af73d65f8f22585005f6e2ce1016fb3145a8ecc1ded51f965c2e98cc/pyinstrument-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1c70b50ec90ae793b74733a6fc992723c6ee27c0fcb7d99848239316ded61189", size = 122068, upload-time = "2025-05-24T15:46:01.05Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d2/cf8f3b8fde3f3b6768f8407c681fb57e7b5a5bf5e7450a9fbec15164987b/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aae5f4f78515009f72393fdb271a15861534a586401383785f823cf8f60aa02", size = 146679, upload-time = "2025-05-24T15:46:02.841Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e2/6c00273778596560c7033cfee34aab07da6009f32c5a4dbcc35b64700e73/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3aec8bc3d1c064ff849ca3568d6b0a7cfa0162d590a9d4d250c7118d09518b22", size = 157606, upload-time = "2025-05-24T15:46:04.551Z" },
{ url = "https://files.pythonhosted.org/packages/4c/cc/ec099f566e381f8e5db21d9523dd97b3255047813da57481ab3f45436089/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28d87fac2bc0fed802b14a26982440f36c85dc53f303530ff7665a6e470315bb", size = 144317, upload-time = "2025-05-24T15:46:05.996Z" },
{ url = "https://files.pythonhosted.org/packages/37/a7/e2e54bf6d996b3c807534dbc4fe270f373660b89871c63965d3f895c285d/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b9caac53c7eda8187ed122d4f7fcc6e3392f04c583d6d70b373351cede2b829", size = 145622, upload-time = "2025-05-24T15:46:07.334Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c6/0b084ddf8d836076e04912ea83ccae0f83bf4897d0168b0fd7684efdc2a4/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8124419e8731a7bdbb9f7f885a8956806a4e9ab9dd19294f8a99e74c0bbdd327", size = 145645, upload-time = "2025-05-24T15:46:09.236Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4d/3e542c5986cc30bc86c304492f4696e58dc03d1816d35c5b2cabfac1d01e/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9990d9bd05fbb4fa83f24f0a62989b8e0a3ac15ff0fa19b49348c8ef5f9db50a", size = 145619, upload-time = "2025-05-24T15:46:10.643Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a7/1e4664bf5ada1cff56852d10954b1ff5a39dad17b9b98a2f27054a0c0d95/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1dc35f3d200866a43d4bc7570799a405f001591c8f19a30eb7a983a717c1e1f7", size = 145049, upload-time = "2025-05-24T15:46:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/fb/59/08a5237c8d1343842ac9ed3c661dce40c450f1750128fd4789ad80539253/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a335a40d0ba1fe3658ef1a5ff2fc7a6870905828014645cb19dab5c1de379447", size = 145451, upload-time = "2025-05-24T15:46:13.49Z" },
{ url = "https://files.pythonhosted.org/packages/53/d0/321b5301e36ac1577dbf73cb49769779c41ebf72ba70a3f6f62d34df902b/pyinstrument-5.0.2-cp312-cp312-win32.whl", hash = "sha256:29e565ce85e03d2541330a8174124c1ecdb073d945962a8eb738d3b1c806ac83", size = 123491, upload-time = "2025-05-24T15:46:15.319Z" },
{ url = "https://files.pythonhosted.org/packages/ae/a6/40f05febe6ab0856b4bfa119113d550d868d94a36b501e6b9fd64379b4ba/pyinstrument-5.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:300b0cc453ffe7661d5f3ceb94cdd98996fd9118f5ff1182b5336489c7d4e45c", size = 124277, upload-time = "2025-05-24T15:46:16.693Z" },
{ url = "https://files.pythonhosted.org/packages/03/88/48654e4b8c6853f218e0506e0609060a54559500b3af5ed6ac752ac4d64f/pyinstrument-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8141a5f78b927a88de46fb2bbb17e710e41d16e161fca99991635ff7196dbd5d", size = 129528, upload-time = "2025-05-24T15:46:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/92/a7/885418b733350f6c2b1d8fcca322a1eee87216a266ac516d7aefd6757ec8/pyinstrument-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:12a0095ae408dbbdd429501fd4c6a3ab51d1aeff5f31be36cc3eedc8c4870ede", size = 122072, upload-time = "2025-05-24T15:46:19.513Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d5/dd0b323d2949d1a3ee0531ec6cdd66c3c69c13b9a8739aeec929a0b55fd2/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eca651d840e8e75ae5330abfc5c90f6ea4af3f78f9f0269231328305a5f9c667", size = 146874, upload-time = "2025-05-24T15:46:21.38Z" },
{ url = "https://files.pythonhosted.org/packages/aa/3b/429572b57c9ae2874e86c48db91ddcd5d619bd798f73d7d2e51b28abb08d/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:89d6ffc5459b19f1c85d4433bb9bbc8925ec04a8d7caf2694218b1f557555f23", size = 155257, upload-time = "2025-05-24T15:46:22.791Z" },
{ url = "https://files.pythonhosted.org/packages/7a/98/03cd22f68607362fd8d1ba72e6367104a9dc32bd4a0dbafc823c4e366f35/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c84845ccc5318072708dc5535b6bedd54494e92a68e282e6b97b53c1db65331", size = 144380, upload-time = "2025-05-24T15:46:24.26Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c4/40d7b4be6c9620c4d9bbe9788eb9bac892f386c9bd40f1937464b2b95c09/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6511092384b5729bbbf4b35534120d2969c5fdfd4f39080badedd973676b8725", size = 145794, upload-time = "2025-05-24T15:46:25.751Z" },
{ url = "https://files.pythonhosted.org/packages/05/07/3b2084b78521d5bbbc328ca9527fb54fbf645a5e62f25169b49f7bbb0bc3/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73f08cff7a8d9714be15440046289ab1a70cbc429e09967a3a106ac61538773e", size = 145803, upload-time = "2025-05-24T15:46:27.277Z" },
{ url = "https://files.pythonhosted.org/packages/22/eb/e3ffcc8734e3d9f50b6bb750209c3ad0c4626dcc3754529741499d9f1d5c/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3905b510cdab1a8255a23fbdedcba4685245cbf814fd80f5b2005b472161d16e", size = 145763, upload-time = "2025-05-24T15:46:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/c6/34/6b94945a02afced9e486e9a6b20de0edcfec543e4942dea96d745e2148ac/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cd693a616166679da529168037c294ff25746c7ae5e8b547811fb25bb26439f5", size = 145208, upload-time = "2025-05-24T15:46:30.125Z" },
{ url = "https://files.pythonhosted.org/packages/99/af/0339bbfe52de9a7df01e5a244a5fec4c228d23b1f422a55318fc6d0b9d91/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:83a1659a3bc4123c81fcddfcc86608f37bd6a951da9692766c2251500a77ac06", size = 145591, upload-time = "2025-05-24T15:46:31.556Z" },
{ url = "https://files.pythonhosted.org/packages/c8/f4/76a2c652e203c15cbc7aa3f8341e07d1ea865764b3ed9f9a97b3c4a5eda2/pyinstrument-5.0.2-cp313-cp313-win32.whl", hash = "sha256:386d047db6c043dcc86bac592873234a89eaa258460e1ad8f47a11fcc7b024d5", size = 123490, upload-time = "2025-05-24T15:46:32.951Z" },
{ url = "https://files.pythonhosted.org/packages/e4/63/14f5c6253e8c85c758485c7717f542346a0d4487818afc28721912a1574b/pyinstrument-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:971c974c061019fa6177a021882255e639399bc15bf71b0a17979830702ad8d3", size = 124287, upload-time = "2025-05-24T15:46:34.333Z" },
]
[[package]]
name = "pyperclip"
version = "1.9.0"
@ -890,15 +1084,15 @@ wheels = [
[[package]]
name = "pyright"
version = "1.1.400"
version = "1.1.401"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/cb/c306618a02d0ee8aed5fb8d0fe0ecfed0dbf075f71468f03a30b5f4e1fe0/pyright-1.1.400.tar.gz", hash = "sha256:b8a3ba40481aa47ba08ffb3228e821d22f7d391f83609211335858bf05686bdb", size = 3846546, upload-time = "2025-04-24T12:55:18.907Z" }
sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193, upload-time = "2025-05-21T10:44:52.03Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/a5/5d285e4932cf149c90e3c425610c5efaea005475d5f96f1bfdb452956c62/pyright-1.1.400-py3-none-any.whl", hash = "sha256:c80d04f98b5a4358ad3a35e241dbf2a408eee33a40779df365644f8054d2517e", size = 5563460, upload-time = "2025-04-24T12:55:17.002Z" },
{ url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193, upload-time = "2025-05-21T10:44:50.129Z" },
]
[[package]]
@ -920,14 +1114,14 @@ wheels = [
[[package]]
name = "pytest-asyncio"
version = "0.26.0"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" },
]
[[package]]
@ -968,6 +1162,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" },
]
[[package]]
name = "pytest-httpx"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" },
]
[[package]]
name = "pytest-report"
version = "0.2.1"
@ -991,15 +1198,15 @@ wheels = [
[[package]]
name = "pytest-xdist"
version = "3.6.1"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "execnet" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060, upload-time = "2024-04-28T19:29:54.414Z" }
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/865845cfe987b21658e871d16e0a24e871e00884c545f246dd8f6f69edda/pytest_xdist-3.7.0.tar.gz", hash = "sha256:f9248c99a7c15b7d2f90715df93610353a485827bc06eefb6566d23f6400f126", size = 87550, upload-time = "2025-05-26T21:18:20.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108, upload-time = "2024-04-28T19:29:52.813Z" },
{ url = "https://files.pythonhosted.org/packages/0d/b2/0e802fde6f1c5b2f7ae7e9ad42b83fd4ecebac18a8a8c2f2f14e39dce6e1/pytest_xdist-3.7.0-py3-none-any.whl", hash = "sha256:7d3fbd255998265052435eb9daa4e99b62e6fb9cfb6efd1f858d4d8c0c7f0ca0", size = 46142, upload-time = "2025-05-26T21:18:18.759Z" },
]
[[package]]
@ -1164,27 +1371,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.11.8"
version = "0.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/f6/adcf73711f31c9f5393862b4281c875a462d9f639f4ccdf69dc368311c20/ruff-0.11.8.tar.gz", hash = "sha256:6d742d10626f9004b781f4558154bb226620a7242080e11caeffab1a40e99df8", size = 4086399, upload-time = "2025-05-01T14:53:24.459Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/0a/92416b159ec00cdf11e5882a9d80d29bf84bba3dbebc51c4898bfbca1da6/ruff-0.11.12.tar.gz", hash = "sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603", size = 4202289, upload-time = "2025-05-29T13:31:40.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/60/c6aa9062fa518a9f86cb0b85248245cddcd892a125ca00441df77d79ef88/ruff-0.11.8-py3-none-linux_armv6l.whl", hash = "sha256:896a37516c594805e34020c4a7546c8f8a234b679a7716a3f08197f38913e1a3", size = 10272473, upload-time = "2025-05-01T14:52:37.252Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/0325e50d106dc87c00695f7bcd5044c6d252ed5120ebf423773e00270f50/ruff-0.11.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab86d22d3d721a40dd3ecbb5e86ab03b2e053bc93c700dc68d1c3346b36ce835", size = 11040862, upload-time = "2025-05-01T14:52:41.022Z" },
{ url = "https://files.pythonhosted.org/packages/e6/27/b87ea1a7be37fef0adbc7fd987abbf90b6607d96aa3fc67e2c5b858e1e53/ruff-0.11.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:258f3585057508d317610e8a412788cf726efeefa2fec4dba4001d9e6f90d46c", size = 10385273, upload-time = "2025-05-01T14:52:43.551Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f7/3346161570d789045ed47a86110183f6ac3af0e94e7fd682772d89f7f1a1/ruff-0.11.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:727d01702f7c30baed3fc3a34901a640001a2828c793525043c29f7614994a8c", size = 10578330, upload-time = "2025-05-01T14:52:45.48Z" },
{ url = "https://files.pythonhosted.org/packages/c6/c3/327fb950b4763c7b3784f91d3038ef10c13b2d42322d4ade5ce13a2f9edb/ruff-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dca977cc4fc8f66e89900fa415ffe4dbc2e969da9d7a54bfca81a128c5ac219", size = 10122223, upload-time = "2025-05-01T14:52:47.675Z" },
{ url = "https://files.pythonhosted.org/packages/de/c7/ba686bce9adfeb6c61cb1bbadc17d58110fe1d602f199d79d4c880170f19/ruff-0.11.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c657fa987d60b104d2be8b052d66da0a2a88f9bd1d66b2254333e84ea2720c7f", size = 11697353, upload-time = "2025-05-01T14:52:50.264Z" },
{ url = "https://files.pythonhosted.org/packages/53/8e/a4fb4a1ddde3c59e73996bb3ac51844ff93384d533629434b1def7a336b0/ruff-0.11.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f2e74b021d0de5eceb8bd32919f6ff8a9b40ee62ed97becd44993ae5b9949474", size = 12375936, upload-time = "2025-05-01T14:52:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a1/9529cb1e2936e2479a51aeb011307e7229225df9ac64ae064d91ead54571/ruff-0.11.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b5ef39820abc0f2c62111f7045009e46b275f5b99d5e59dda113c39b7f4f38", size = 11850083, upload-time = "2025-05-01T14:52:55.424Z" },
{ url = "https://files.pythonhosted.org/packages/3e/94/8f7eac4c612673ae15a4ad2bc0ee62e03c68a2d4f458daae3de0e47c67ba/ruff-0.11.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1dba3135ca503727aa4648152c0fa67c3b1385d3dc81c75cd8a229c4b2a1458", size = 14005834, upload-time = "2025-05-01T14:52:58.056Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7c/6f63b46b2be870cbf3f54c9c4154d13fac4b8827f22fa05ac835c10835b2/ruff-0.11.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f024d32e62faad0f76b2d6afd141b8c171515e4fb91ce9fd6464335c81244e5", size = 11503713, upload-time = "2025-05-01T14:53:01.244Z" },
{ url = "https://files.pythonhosted.org/packages/3a/91/57de411b544b5fe072779678986a021d87c3ee5b89551f2ca41200c5d643/ruff-0.11.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d365618d3ad747432e1ae50d61775b78c055fee5936d77fb4d92c6f559741948", size = 10457182, upload-time = "2025-05-01T14:53:03.726Z" },
{ url = "https://files.pythonhosted.org/packages/01/49/cfe73e0ce5ecdd3e6f1137bf1f1be03dcc819d1bfe5cff33deb40c5926db/ruff-0.11.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4d9aaa91035bdf612c8ee7266153bcf16005c7c7e2f5878406911c92a31633cb", size = 10101027, upload-time = "2025-05-01T14:53:06.555Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/a5cfe47c62b3531675795f38a0ef1c52ff8de62eaddf370d46634391a3fb/ruff-0.11.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0eba551324733efc76116d9f3a0d52946bc2751f0cd30661564117d6fd60897c", size = 11111298, upload-time = "2025-05-01T14:53:08.825Z" },
{ url = "https://files.pythonhosted.org/packages/36/98/f76225f87e88f7cb669ae92c062b11c0a1e91f32705f829bd426f8e48b7b/ruff-0.11.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:161eb4cff5cfefdb6c9b8b3671d09f7def2f960cee33481dd898caf2bcd02304", size = 11566884, upload-time = "2025-05-01T14:53:11.626Z" },
{ url = "https://files.pythonhosted.org/packages/de/7e/fff70b02e57852fda17bd43f99dda37b9bcf3e1af3d97c5834ff48d04715/ruff-0.11.8-py3-none-win32.whl", hash = "sha256:5b18caa297a786465cc511d7f8be19226acf9c0a1127e06e736cd4e1878c3ea2", size = 10451102, upload-time = "2025-05-01T14:53:14.303Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/eaa571eb70648c9bde3120a1d5892597de57766e376b831b06e7c1e43945/ruff-0.11.8-py3-none-win_amd64.whl", hash = "sha256:6e70d11043bef637c5617297bdedec9632af15d53ac1e1ba29c448da9341b0c4", size = 11597410, upload-time = "2025-05-01T14:53:16.571Z" },
{ url = "https://files.pythonhosted.org/packages/cd/be/f6b790d6ae98f1f32c645f8540d5c96248b72343b0a56fab3a07f2941897/ruff-0.11.8-py3-none-win_arm64.whl", hash = "sha256:304432e4c4a792e3da85b7699feb3426a0908ab98bf29df22a31b0cdd098fac2", size = 10713129, upload-time = "2025-05-01T14:53:22.27Z" },
{ url = "https://files.pythonhosted.org/packages/60/cc/53eb79f012d15e136d40a8e8fc519ba8f55a057f60b29c2df34efd47c6e3/ruff-0.11.12-py3-none-linux_armv6l.whl", hash = "sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc", size = 10285597, upload-time = "2025-05-29T13:30:57.539Z" },
{ url = "https://files.pythonhosted.org/packages/e7/d7/73386e9fb0232b015a23f62fea7503f96e29c29e6c45461d4a73bac74df9/ruff-0.11.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3", size = 11053154, upload-time = "2025-05-29T13:31:00.865Z" },
{ url = "https://files.pythonhosted.org/packages/4e/eb/3eae144c5114e92deb65a0cb2c72326c8469e14991e9bc3ec0349da1331c/ruff-0.11.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa", size = 10403048, upload-time = "2025-05-29T13:31:03.413Z" },
{ url = "https://files.pythonhosted.org/packages/29/64/20c54b20e58b1058db6689e94731f2a22e9f7abab74e1a758dfba058b6ca/ruff-0.11.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012", size = 10597062, upload-time = "2025-05-29T13:31:05.539Z" },
{ url = "https://files.pythonhosted.org/packages/29/3a/79fa6a9a39422a400564ca7233a689a151f1039110f0bbbabcb38106883a/ruff-0.11.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a", size = 10155152, upload-time = "2025-05-29T13:31:07.986Z" },
{ url = "https://files.pythonhosted.org/packages/e5/a4/22c2c97b2340aa968af3a39bc38045e78d36abd4ed3fa2bde91c31e712e3/ruff-0.11.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7", size = 11723067, upload-time = "2025-05-29T13:31:10.57Z" },
{ url = "https://files.pythonhosted.org/packages/bc/cf/3e452fbd9597bcd8058856ecd42b22751749d07935793a1856d988154151/ruff-0.11.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a", size = 12460807, upload-time = "2025-05-29T13:31:12.88Z" },
{ url = "https://files.pythonhosted.org/packages/2f/ec/8f170381a15e1eb7d93cb4feef8d17334d5a1eb33fee273aee5d1f8241a3/ruff-0.11.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13", size = 12063261, upload-time = "2025-05-29T13:31:15.236Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bf/57208f8c0a8153a14652a85f4116c0002148e83770d7a41f2e90b52d2b4e/ruff-0.11.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be", size = 11329601, upload-time = "2025-05-29T13:31:18.68Z" },
{ url = "https://files.pythonhosted.org/packages/c3/56/edf942f7fdac5888094d9ffa303f12096f1a93eb46570bcf5f14c0c70880/ruff-0.11.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd", size = 11522186, upload-time = "2025-05-29T13:31:21.216Z" },
{ url = "https://files.pythonhosted.org/packages/ed/63/79ffef65246911ed7e2290aeece48739d9603b3a35f9529fec0fc6c26400/ruff-0.11.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef", size = 10449032, upload-time = "2025-05-29T13:31:23.417Z" },
{ url = "https://files.pythonhosted.org/packages/88/19/8c9d4d8a1c2a3f5a1ea45a64b42593d50e28b8e038f1aafd65d6b43647f3/ruff-0.11.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5", size = 10129370, upload-time = "2025-05-29T13:31:25.777Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0f/2d15533eaa18f460530a857e1778900cd867ded67f16c85723569d54e410/ruff-0.11.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02", size = 11123529, upload-time = "2025-05-29T13:31:28.396Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e2/4c2ac669534bdded835356813f48ea33cfb3a947dc47f270038364587088/ruff-0.11.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c", size = 11577642, upload-time = "2025-05-29T13:31:30.647Z" },
{ url = "https://files.pythonhosted.org/packages/a7/9b/c9ddf7f924d5617a1c94a93ba595f4b24cb5bc50e98b94433ab3f7ad27e5/ruff-0.11.12-py3-none-win32.whl", hash = "sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6", size = 10475511, upload-time = "2025-05-29T13:31:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d6/74fb6d3470c1aada019ffff33c0f9210af746cca0a4de19a1f10ce54968a/ruff-0.11.12-py3-none-win_amd64.whl", hash = "sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832", size = 11523573, upload-time = "2025-05-29T13:31:35.782Z" },
{ url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" },
]
[[package]]
@ -1216,15 +1423,14 @@ wheels = [
[[package]]
name = "sse-starlette"
version = "2.3.3"
version = "2.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/35/7d8d94eb0474352d55f60f80ebc30f7e59441a29e18886a6425f0bccd0d3/sse_starlette-2.3.3.tar.gz", hash = "sha256:fdd47c254aad42907cfd5c5b83e2282be15be6c51197bf1a9b70b8e990522072", size = 17499, upload-time = "2025-04-23T19:28:25.558Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/20/52fdb5ebb158294b0adb5662235dd396fc7e47aa31c293978d8d8942095a/sse_starlette-2.3.3-py3-none-any.whl", hash = "sha256:8b0a0ced04a329ff7341b01007580dd8cf71331cc21c0ccea677d500618da1e0", size = 10235, upload-time = "2025-04-23T19:28:24.115Z" },
{ url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" },
]
[[package]]
@ -1339,7 +1545,7 @@ wheels = [
[[package]]
name = "typer"
version = "0.15.3"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@ -1347,9 +1553,9 @@ dependencies = [
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641, upload-time = "2025-04-28T21:40:59.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload-time = "2025-04-28T21:40:56.269Z" },
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
]
[[package]]
@ -1363,14 +1569,14 @@ wheels = [
[[package]]
name = "typing-inspection"
version = "0.4.0"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" },
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
@ -1398,16 +1604,16 @@ wheels = [
[[package]]
name = "virtualenv"
version = "20.30.0"
version = "20.31.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", size = 4346945, upload-time = "2025-03-31T16:33:29.185Z" }
sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6", size = 4329461, upload-time = "2025-03-31T16:33:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" },
]
[[package]]