From 77b21358722d4891f4e425b7b12f83a6e0c5d097 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 15 May 2025 21:14:04 -0400 Subject: [PATCH 01/38] Introduce MCP client oauth flow --- .../client/{base.py => auth/__init__.py} | 0 src/fastmcp/client/auth/httpx_client.py | 424 ++++++++++++++++++ src/fastmcp/client/auth/oauth_cache.py | 122 +++++ src/fastmcp/client/client.py | 63 ++- src/fastmcp/client/sse.py | 65 +++ src/fastmcp/client/streamable_http.py | 61 +++ src/fastmcp/client/transports.py | 186 +------- src/fastmcp/settings.py | 3 + 8 files changed, 757 insertions(+), 167 deletions(-) rename src/fastmcp/client/{base.py => auth/__init__.py} (100%) create mode 100644 src/fastmcp/client/auth/httpx_client.py create mode 100644 src/fastmcp/client/auth/oauth_cache.py create mode 100644 src/fastmcp/client/sse.py create mode 100644 src/fastmcp/client/streamable_http.py diff --git a/src/fastmcp/client/base.py b/src/fastmcp/client/auth/__init__.py similarity index 100% rename from src/fastmcp/client/base.py rename to src/fastmcp/client/auth/__init__.py diff --git a/src/fastmcp/client/auth/httpx_client.py b/src/fastmcp/client/auth/httpx_client.py new file mode 100644 index 000000000..693bd0e3e --- /dev/null +++ b/src/fastmcp/client/auth/httpx_client.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import asyncio +import socket +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar +from typing import Any +from urllib.parse import urljoin + +import anyio +import httpx +import mcp.client.sse +import mcp.client.streamable_http +import mcp.shared._httpx_utils +from authlib.integrations.httpx_client import AsyncOAuth2Client +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from uvicorn import Config, Server + +from fastmcp.client.auth.oauth_cache import oauth_cache +from fastmcp.utilities.logging import get_logger + +_current_mcp_endpoint: ContextVar[str | None] = ContextVar("mcp_endpoint", default=None) + + +logger = get_logger(__name__) + + +def create_mcp_http_client( + headers: dict[str, Any] | None = None, + timeout: httpx.Timeout | None = None, + **kwargs: Any, +) -> httpx.AsyncClient: + # re-implements logic from mcp.shared._httpx_utils.create_mcp_http_client, but with **kwargs support + kwargs.setdefault("follow_redirects", True) + if timeout is None: + timeout = httpx.Timeout(30.0) + + return httpx.AsyncClient(headers=headers, timeout=timeout, **kwargs) + + +def find_available_port() -> int: + """Find an available port by letting the OS assign one.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def _get_redirect( + port: int, path: str = "/callback", timeout: float = 100.0 +) -> str: + """ + Start a temporary server to handle OAuth redirect and get the full redirect URL. + + Args: + port: The port to run the server on + path: The path to listen for redirects on + timeout: Number of seconds to wait before timing out + + Returns: + The full redirect URL from the browser + + Raises: + TimeoutError: If no redirect is received within the timeout period + """ + fut = asyncio.get_running_loop().create_future() + + async def cb(request): + if not fut.done(): + fut.set_result(str(request.url)) # full redirect URL + return PlainTextResponse( + "✅ FastMCP login complete! You can close this tab now." + ) + + server = Server( + Config( + app=Starlette(routes=[Route(path, cb)]), + host="127.0.0.1", + port=port, + lifespan="off", + log_level="error", + ) + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(server.serve) # background task for server + + try: + # Use anyio.fail_after to implement timeout + with anyio.fail_after(timeout): + redirect_url = await fut # wait for browser hit or timeout + return redirect_url + finally: + server.should_exit = True # stop the server loop + tg.cancel_scope.cancel() # tear down immediately + + +class OAuthBearerAuth(httpx.Auth): + """Auth handler that adds the OAuth bearer token to requests.""" + + def __init__(self, client: AsyncOAuth2Client) -> None: + self._client = client + + async def async_auth_flow(self, request: httpx.Request): + # Ensure token is loaded in the OAuth client + if ( + not self._client.token + or self._client.token.get("expires_at") + and self._client.token["expires_at"] < time.time() + ): + # We'll refresh or reauthorize in create_mcp_oauth_client + pass + + if self._client.token: + request.headers["Authorization"] = ( + f"Bearer {self._client.token['access_token']}" + ) + yield request + + +async def discover_oauth_metadata(base_url: str) -> dict[str, Any] | None: + """ + Discover OAuth metadata from the server according to RFC 8414. + + Returns None if the server appears to not require authentication. + """ + # First, try the well-known URL + well_known_url = urljoin(base_url, "/.well-known/oauth-authorization-server") + logger.debug(f"Attempting OAuth metadata discovery from: {well_known_url}") + + async with httpx.AsyncClient() as client: + # First try the well-known URL + try: + response = await client.get(well_known_url, timeout=10) + if response.status_code == 200: + logger.debug("Successfully discovered OAuth metadata") + return response.json() + except httpx.RequestError as e: + logger.debug(f"Failed to fetch OAuth metadata: {e}") + + # If well-known discovery fails, check WWW-Authenticate header + try: + response = await client.get(base_url, timeout=10) + + # If the base URL request succeeds without a 401/403 and has no WWW-Authenticate header, + # the server likely doesn't require authentication + if ( + response.status_code < 400 + and "WWW-Authenticate" not in response.headers + ): + logger.debug("Server appears to not require authentication") + return None + + auth_header = response.headers.get("WWW-Authenticate") + if auth_header and "resource_metadata" in auth_header: + # Extract metadata URL from header + import re + + metadata_match = re.search(r'resource_metadata="([^"]+)"', auth_header) + if metadata_match: + metadata_url = metadata_match.group(1) + metadata_response = await client.get(metadata_url, timeout=10) + if metadata_response.status_code == 200: + logger.debug( + "Successfully discovered OAuth metadata from WWW-Authenticate header" + ) + return metadata_response.json() + except httpx.RequestError as e: + logger.debug(f"Failed to fetch OAuth metadata from WWW-Authenticate: {e}") + + # Fallback to default endpoints based on the base URL + logger.debug("Falling back to default OAuth endpoints") + return { + "issuer": base_url, + "authorization_endpoint": urljoin(base_url, "/authorize"), + "token_endpoint": urljoin(base_url, "/token"), + "registration_endpoint": urljoin(base_url, "/register"), + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "none", + ], + "code_challenge_methods_supported": ["S256"], + } + + +async def register_client( + registration_endpoint: str, redirect_uri: str +) -> dict[str, Any]: + """ + Register an OAuth client using RFC 7591 dynamic registration. + + May raise httpx.HTTPStatusError if registration fails. + """ + logger.debug(f"Registering client at: {registration_endpoint}") + + payload = { + "client_name": "FastMCP Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", # public PKCE client + } + + async with httpx.AsyncClient() as client: + response = await client.post(registration_endpoint, json=payload, timeout=10) + # Allow HTTPStatusError to propagate to the caller + response.raise_for_status() + logger.debug("Client registration successful") + return response.json() + + +@asynccontextmanager +async def create_mcp_oauth_client( + mcp_endpoint: str, + redirect_uri: str | None = None, + scope: list[str] | None = None, + headers: dict[str, Any] | None = None, + timeout: httpx.Timeout | None = None, + **httpx_kwargs: Any, +) -> AsyncIterator[httpx.AsyncClient]: + """ + Create an authenticated OAuth client for an MCP server from an endpoint URL. + + This function handles: + 1. OAuth metadata discovery + 2. Dynamic client registration if needed + 3. Authorization code flow with PKCE + 4. Token refreshing + 5. Token persistence + + If the server doesn't require authentication, a regular client will be returned. + + Args: + mcp_endpoint: Full URL to an MCP endpoint (e.g., + https://mcp.example.com/sse). This will be used to discover the OAuth + configuration. + redirect_uri: OAuth redirect URI for the authorization flow. If None, + a server will be started on an available port. + scope: OAuth scopes to request + headers: Additional headers to include in the requests + timeout: Timeout for the requests + **httpx_kwargs: Additional arguments for the httpx client + + Returns: + An httpx.AsyncClient that handles authentication automatically + """ + # Extract base URL for OAuth discovery + base_url = oauth_cache.get_base_url(mcp_endpoint) + logger.debug(f"MCP Endpoint: {mcp_endpoint}") + logger.debug(f"Base URL for OAuth: {base_url}") + + # Discover OAuth metadata + metadata = await discover_oauth_metadata(base_url) + + # If metadata is None, the server doesn't require authentication + if metadata is None: + logger.info("Server doesn't require authentication, creating regular client") + async with create_mcp_http_client( + headers=headers, + timeout=timeout, + **httpx_kwargs, + ) as client: + yield client + return + + logger.debug(f"Using OAuth endpoints: {metadata}") + + # Use dynamic redirect URI if none provided + # Generate port only once and reuse it for all operations + port = None + if redirect_uri is None: + port = find_available_port() + redirect_uri = f"http://127.0.0.1:{port}/callback" + logger.debug(f"Using dynamic redirect URI: {redirect_uri}") + + # Load or register client - check if we need to update registration due to new redirect URI + creds = oauth_cache.load(mcp_endpoint, "client") + if creds and redirect_uri not in creds.get("redirect_uris", []): + logger.debug("Redirect URI not in registered URIs, re-registering client") + creds = None # Force re-registration + + # Register if needed + if not creds: + try: + creds = await register_client( + metadata["registration_endpoint"], redirect_uri + ) + oauth_cache.save(mcp_endpoint, creds, "client") + logger.debug(f"Client registered with redirect URI: {redirect_uri}") + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + # If registration endpoint returns 404, server likely doesn't support OAuth + logger.info("Registration endpoint not found, creating regular client") + async with create_mcp_http_client( + headers=headers, + timeout=timeout, + **httpx_kwargs, + ) as client: + yield client + return + else: + # Other HTTP errors should be propagated + raise + + # Create the OAuth client + oauth_client = AsyncOAuth2Client( + client_id=creds["client_id"], + client_secret=creds.get("client_secret"), # "public" clients omit secret + scope=scope or ["openid", "profile", "email"], + redirect_uri=redirect_uri, + **httpx_kwargs, + ) + + # Load token if exists - passing mcp_endpoint directly + token = oauth_cache.load(mcp_endpoint, "token") + if token: + oauth_client.token = token + + try: + # Ensure we have a valid token + if ( + not oauth_client.token + or not oauth_client.token.get("expires_at") + or oauth_client.token["expires_at"] < time.time() + ): + # Try to refresh if possible + if oauth_client.token and oauth_client.token.get("refresh_token"): + logger.debug("Refreshing token") + try: + # ignore type because refresh_token is awaitable but not typed as such + token = await oauth_client.refresh_token( # type: ignore[await-expr] + url=metadata["token_endpoint"], + refresh_token=oauth_client.token["refresh_token"], + ) + except Exception as e: + logger.warning(f"Failed to refresh token: {e}") + token = None + else: + token = None + + # If token is still not available, start authorization flow + if not token: + # Start authorization flow with PKCE + logger.info("Starting authorization flow") + uri, _ = oauth_client.create_authorization_url( + metadata["authorization_endpoint"], + redirect_uri=redirect_uri, + code_challenge_method="S256", + ) + import webbrowser + + webbrowser.open(uri) + + # Wait for redirect after user approval - reuse the same port + try: + # We need to ensure port is not None for the _get_redirect function + redirect_port = port if port is not None else find_available_port() + redirect_url = await _get_redirect(port=redirect_port) + logger.info("Received redirect, fetching token") + + # ignore type because fetch_token is awaitable but not typed as such + token = await oauth_client.fetch_token( # type: ignore[await-expr] + url=metadata["token_endpoint"], + authorization_response=redirect_url, + timeout=15, # seconds + ) + except Exception as e: + logger.warning(f"Failed to fetch token: {e}") + token = None + + # Save token for future use - passing mcp_endpoint directly + if token is not None: + oauth_cache.save(mcp_endpoint, token, "token") + logger.debug("Token saved successfully") + + # Create a standard httpx client with the OAuth bearer auth + async with create_mcp_http_client( + auth=OAuthBearerAuth(oauth_client), + headers=headers, + timeout=timeout, + **httpx_kwargs, + ) as client: + # Yield the authenticated client + yield client + finally: + await oauth_client.aclose() + + +@contextmanager +def patch_mcp_httpx_client(mcp_endpoint: str): + """ + This context manager can be used to monkeypatch the low-level function that + returns an httpx.AsyncClient. It replaces it with a function that returns an + MCP OAuth-aware client. + + This is ugly, but it lets us reuse the low-level SDK without maintaining a fork. + """ + original_shttp_client_fn = mcp.client.streamable_http.create_mcp_http_client # type: ignore + original_sse_client_fn = mcp.client.sse.create_mcp_http_client # type: ignore + + # use tokens to manage context across concurrent requests + token = _current_mcp_endpoint.set(mcp_endpoint) + + def patched_mcp_client(**kwargs): + url = _current_mcp_endpoint.get() + if url is None: + return mcp.shared._httpx_utils.create_mcp_http_client(**kwargs) + return create_mcp_oauth_client(mcp_endpoint=url, **kwargs) + + try: + mcp.client.streamable_http.create_mcp_http_client = patched_mcp_client # type: ignore + mcp.client.sse.create_mcp_http_client = patched_mcp_client # type: ignore + yield + finally: + _current_mcp_endpoint.reset(token) + mcp.client.streamable_http.create_mcp_http_client = original_shttp_client_fn # type: ignore + mcp.client.sse.create_mcp_http_client = original_sse_client_fn # type: ignore diff --git a/src/fastmcp/client/auth/oauth_cache.py b/src/fastmcp/client/auth/oauth_cache.py new file mode 100644 index 000000000..928b9fa86 --- /dev/null +++ b/src/fastmcp/client/auth/oauth_cache.py @@ -0,0 +1,122 @@ +import json +import time +from pathlib import Path +from typing import Any, ClassVar, Literal +from urllib.parse import urlparse + +from fastmcp.client.auth.httpx_client import logger +from fastmcp.settings import settings + + +class OAuthCache: + """Manages OAuth credentials and tokens caching.""" + + # Class variables + CACHE_DIR: ClassVar[Path] = settings.home / "oauth-cache" + + def __init__(self): + """Initialize the cache directory.""" + 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 with a path.""" + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc}" + + def get_cache_key(self, url: str) -> str: + """Generate a safe filesystem key from a URL, automatically extracting the base URL.""" + base_url = self.get_base_url(url) + # Replace scheme:// and non-alphanumeric characters with _ for safety + return base_url.replace("://", "_").replace(".", "_").replace("/", "_") + + def get_file_path(self, url: str, file_type: Literal["client", "token"]) -> Path: + """Get the file path for the specified cache file type and URL.""" + key = self.get_cache_key(url) + return self.CACHE_DIR / f"{key}_{file_type}.json" + + def save( + self, url: str, data: dict[str, Any], file_type: Literal["client", "token"] + ) -> None: + """Save data to the cache file using the base URL extracted from url.""" + path = self.get_file_path(url, file_type) + path.write_text(json.dumps(data)) + base_url = self.get_base_url(url) + logger.debug(f"Saved {file_type} data for {base_url}") + + def load( + self, url: str, file_type: Literal["client", "token"] + ) -> dict[str, Any] | None: + """Load data from the cache file using the base URL extracted from url.""" + path = self.get_file_path(url, file_type) + try: + return json.loads(path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + base_url = self.get_base_url(url) + logger.debug(f"No valid {file_type} cache found for {base_url}") + return None + + def has_valid_token(self, url: str) -> bool: + """Check if there's a valid non-expired token for the given URL.""" + token = self.load(url, "token") + if not token: + return False + + # Check expiration + expires_at = token.get("expires_at") + if not expires_at or expires_at < time.time(): + return False + + return True + + def list_cached_endpoints(self) -> list[str]: + """List all base URLs with cached credentials or tokens.""" + endpoints = set() + + file_types = ["client", "token"] + for file_type in file_types: + for file in self.CACHE_DIR.glob(f"*_{file_type}.json"): + key = file.name.replace(f"_{file_type}.json", "") + # This is a simplified conversion back to URL format + # May need enhancement for complex URLs + url = key.replace("_", "://", 1) + # Attempt to reconstruct the URL in a basic way + parts = url.split("_") + if len(parts) > 1: + # Reconstruct with dots and slashes + reconstructed = parts[0] + for part in parts[1:]: + if part: + reconstructed += f".{part}" + endpoints.add(reconstructed) + else: + endpoints.add(url) + + return sorted(list(endpoints)) + + def clear(self, url: str | None = None) -> None: + """ + Clear the OAuth cache for a specific URL or all cached data. + + Args: + url: The URL to clear cache for. If None, clears all cache. + """ + if url is None: + # Clear all files in the cache directory + file_types = ["client", "token"] + for file_type in file_types: + for file in self.CACHE_DIR.glob(f"*_{file_type}.json"): + file.unlink(missing_ok=True) + logger.info("Cleared all OAuth cache data") + else: + # Clear only files for the specific URL + path = self.get_file_path(url, "client") + path.unlink(missing_ok=True) + path = self.get_file_path(url, "token") + path.unlink(missing_ok=True) + base_url = self.get_base_url(url) + logger.info(f"Cleared OAuth cache for {base_url}") + + +# Initialize global cache instance +oauth_cache = OAuthCache() diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 9f5a111f9..97d4205c0 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,12 +1,22 @@ +import abc +import contextlib import datetime +from collections.abc import AsyncIterator from contextlib import AsyncExitStack from pathlib import Path -from typing import Any, cast +from typing import Any, TypedDict, cast import mcp.types from exceptiongroup import catch from mcp import ClientSession +from mcp.client.session import ( + ListRootsFnT, + LoggingFnT, + MessageHandlerFnT, + SamplingFnT, +) from pydantic import AnyUrl +from typing_extensions import Unpack from fastmcp.client.logging import LogHandler, MessageHandler from fastmcp.client.roots import ( @@ -19,10 +29,10 @@ from fastmcp.exceptions import ToolError from fastmcp.server import FastMCP from fastmcp.utilities.exceptions import get_catch_handlers -from .transports import ClientTransport, SessionKwargs, infer_transport - __all__ = [ "Client", + "ClientTransport", + "SessionKwargs", "RootsHandler", "RootsList", "LogHandler", @@ -31,6 +41,51 @@ __all__ = [ ] +class SessionKwargs(TypedDict, total=False): + """Keyword arguments for the MCP ClientSession constructor.""" + + sampling_callback: SamplingFnT | None + list_roots_callback: ListRootsFnT | None + logging_callback: LoggingFnT | None + message_handler: MessageHandlerFnT | None + read_timeout_seconds: datetime.timedelta | None + + +class ClientTransport(abc.ABC): + """ + Abstract base class for different MCP client transport mechanisms. + + A Transport is responsible for establishing and managing connections + to an MCP server, and providing a ClientSession within an async context. + """ + + @abc.abstractmethod + @contextlib.asynccontextmanager + async def connect_session( + self, **session_kwargs: Unpack[SessionKwargs] + ) -> AsyncIterator[ClientSession]: + """ + Establishes a connection and yields an active, initialized ClientSession. + + The session is guaranteed to be valid only within the scope of the + async context manager. Connection setup and teardown are handled + within this context. + + Args: + **session_kwargs: Keyword arguments to pass to the ClientSession + constructor (e.g., callbacks, timeouts). + + Yields: + An initialized mcp.ClientSession instance. + """ + raise NotImplementedError + yield None # type: ignore + + def __repr__(self) -> str: + # Basic representation for subclasses + return f"<{self.__class__.__name__}>" + + class Client: """ MCP client that delegates connection management to a Transport instance. @@ -76,6 +131,8 @@ class Client: message_handler: MessageHandler | None = None, timeout: datetime.timedelta | float | int | None = None, ): + from fastmcp.client.transports import infer_transport + self.transport = infer_transport(transport) self._session: ClientSession | None = None self._exit_stack: AsyncExitStack | None = None diff --git a/src/fastmcp/client/sse.py b/src/fastmcp/client/sse.py new file mode 100644 index 000000000..4e17c1dc9 --- /dev/null +++ b/src/fastmcp/client/sse.py @@ -0,0 +1,65 @@ +import contextlib +import datetime +import logging +from collections.abc import AsyncIterator +from typing import cast + +from mcp import ClientSession +from mcp.client.sse import sse_client +from pydantic import AnyUrl +from typing_extensions import Unpack + +from fastmcp.client.auth.httpx_client import patch_mcp_httpx_client +from fastmcp.client.client import ClientTransport, SessionKwargs + +logger = logging.getLogger(__name__) + + +class SSETransport(ClientTransport): + """Transport implementation that connects to an MCP server via Server-Sent Events.""" + + def __init__( + self, + url: str | AnyUrl, + headers: dict[str, str] | None = None, + sse_read_timeout: datetime.timedelta | float | int | None = None, + ): + if isinstance(url, AnyUrl): + url = str(url) + if not isinstance(url, str) or not url.startswith("http"): + raise ValueError("Invalid HTTP/S URL provided for SSE.") + self.url = url + self.headers = headers or {} + + if isinstance(sse_read_timeout, int | float): + sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) + self.sse_read_timeout = sse_read_timeout + + @contextlib.asynccontextmanager + async def connect_session( + self, **session_kwargs: Unpack[SessionKwargs] + ) -> AsyncIterator[ClientSession]: + client_kwargs = {} + # sse_read_timeout has a default value set, so we can't pass None without overriding it + # instead we simply leave the kwarg out if it's not provided + if self.sse_read_timeout is not None: + client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds() + if session_kwargs.get("read_timeout_seconds", None) is not None: + read_timeout_seconds = cast( + datetime.timedelta, session_kwargs.get("read_timeout_seconds") + ) + client_kwargs["timeout"] = read_timeout_seconds.total_seconds() + + with patch_mcp_httpx_client(self.url): + async with sse_client( + self.url, headers=self.headers, **client_kwargs + ) as transport: + read_stream, write_stream = transport + async with ClientSession( + read_stream, write_stream, **session_kwargs + ) as session: + await session.initialize() + yield session + + def __repr__(self) -> str: + return f"" diff --git a/src/fastmcp/client/streamable_http.py b/src/fastmcp/client/streamable_http.py new file mode 100644 index 000000000..a5d7b8e03 --- /dev/null +++ b/src/fastmcp/client/streamable_http.py @@ -0,0 +1,61 @@ +import contextlib +import datetime +from collections.abc import AsyncIterator + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from pydantic import AnyUrl +from typing_extensions import Unpack + +from fastmcp.client.auth.httpx_client import patch_mcp_httpx_client +from fastmcp.client.client import ClientTransport, SessionKwargs +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class StreamableHttpTransport(ClientTransport): + """Transport implementation that connects to an MCP server via Streamable HTTP Requests.""" + + def __init__( + self, + url: str | AnyUrl, + headers: dict[str, str] | None = None, + sse_read_timeout: datetime.timedelta | float | int | None = None, + ): + if isinstance(url, AnyUrl): + url = str(url) + if not isinstance(url, str) or not url.startswith("http"): + raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.") + self.url = url + self.headers = headers or {} + + if isinstance(sse_read_timeout, int | float): + sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) + self.sse_read_timeout = sse_read_timeout + + @contextlib.asynccontextmanager + async def connect_session( + self, **session_kwargs: Unpack[SessionKwargs] + ) -> AsyncIterator[ClientSession]: + client_kwargs = {} + # sse_read_timeout has a default value set, so we can't pass None without overriding it + # instead we simply leave the kwarg out if it's not provided + if self.sse_read_timeout is not None: + client_kwargs["sse_read_timeout"] = self.sse_read_timeout + if session_kwargs.get("read_timeout_seconds", None) is not None: + client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds") + + with patch_mcp_httpx_client(self.url): + async with streamablehttp_client( + self.url, headers=self.headers, **client_kwargs + ) as transport: + read_stream, write_stream, _ = transport + async with ClientSession( + read_stream, write_stream, **session_kwargs + ) as session: + await session.initialize() + yield session + + def __repr__(self) -> str: + return f"" diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7faeab613..fc33af335 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -1,76 +1,38 @@ -import abc import contextlib -import datetime -import inspect import os import shutil import sys -import warnings from collections.abc import AsyncIterator from pathlib import Path -from typing import Any, TypedDict, cast +from typing import Any from mcp import ClientSession, StdioServerParameters -from mcp.client.session import ( - ListRootsFnT, - LoggingFnT, - MessageHandlerFnT, - SamplingFnT, -) -from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client -from mcp.client.streamable_http import streamablehttp_client from mcp.client.websocket import websocket_client from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack +from fastmcp.client.client import ClientTransport, SessionKwargs +from fastmcp.client.sse import SSETransport +from fastmcp.client.streamable_http import StreamableHttpTransport from fastmcp.server import FastMCP as FastMCPServer - -class SessionKwargs(TypedDict, total=False): - """Keyword arguments for the MCP ClientSession constructor.""" - - sampling_callback: SamplingFnT | None - list_roots_callback: ListRootsFnT | None - logging_callback: LoggingFnT | None - message_handler: MessageHandlerFnT | None - read_timeout_seconds: datetime.timedelta | None - - -class ClientTransport(abc.ABC): - """ - Abstract base class for different MCP client transport mechanisms. - - A Transport is responsible for establishing and managing connections - to an MCP server, and providing a ClientSession within an async context. - """ - - @abc.abstractmethod - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - """ - Establishes a connection and yields an active, initialized ClientSession. - - The session is guaranteed to be valid only within the scope of the - async context manager. Connection setup and teardown are handled - within this context. - - Args: - **session_kwargs: Keyword arguments to pass to the ClientSession - constructor (e.g., callbacks, timeouts). - - Yields: - An initialized mcp.ClientSession instance. - """ - raise NotImplementedError - yield None # type: ignore - - def __repr__(self) -> str: - # Basic representation for subclasses - return f"<{self.__class__.__name__}>" +__all__ = [ + "ClientTransport", + "SSETransport", + "StreamableHttpTransport", + "FastMCPServer", + "WSTransport", + "StdioTransport", + "PythonStdioTransport", + "FastMCPStdioTransport", + "NodeStdioTransport", + "UvxStdioTransport", + "NpxStdioTransport", + "FastMCPTransport", + "infer_transport", +] class WSTransport(ClientTransport): @@ -99,101 +61,6 @@ class WSTransport(ClientTransport): return f"" -class SSETransport(ClientTransport): - """Transport implementation that connects to an MCP server via Server-Sent Events.""" - - def __init__( - self, - url: str | AnyUrl, - headers: dict[str, str] | None = None, - sse_read_timeout: datetime.timedelta | float | int | None = None, - ): - if isinstance(url, AnyUrl): - url = str(url) - if not isinstance(url, str) or not url.startswith("http"): - raise ValueError("Invalid HTTP/S URL provided for SSE.") - self.url = url - self.headers = headers or {} - - if isinstance(sse_read_timeout, int | float): - sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) - self.sse_read_timeout = sse_read_timeout - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - client_kwargs = {} - # sse_read_timeout has a default value set, so we can't pass None without overriding it - # instead we simply leave the kwarg out if it's not provided - if self.sse_read_timeout is not None: - client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds() - if session_kwargs.get("read_timeout_seconds", None) is not None: - read_timeout_seconds = cast( - datetime.timedelta, session_kwargs.get("read_timeout_seconds") - ) - client_kwargs["timeout"] = read_timeout_seconds.total_seconds() - - async with sse_client( - self.url, headers=self.headers, **client_kwargs - ) as transport: - read_stream, write_stream = transport - async with ClientSession( - read_stream, write_stream, **session_kwargs - ) as session: - await session.initialize() - yield session - - def __repr__(self) -> str: - return f"" - - -class StreamableHttpTransport(ClientTransport): - """Transport implementation that connects to an MCP server via Streamable HTTP Requests.""" - - def __init__( - self, - url: str | AnyUrl, - headers: dict[str, str] | None = None, - sse_read_timeout: datetime.timedelta | float | int | None = None, - ): - if isinstance(url, AnyUrl): - url = str(url) - if not isinstance(url, str) or not url.startswith("http"): - raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.") - self.url = url - self.headers = headers or {} - - if isinstance(sse_read_timeout, int | float): - sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) - self.sse_read_timeout = sse_read_timeout - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - client_kwargs = {} - # sse_read_timeout has a default value set, so we can't pass None without overriding it - # instead we simply leave the kwarg out if it's not provided - if self.sse_read_timeout is not None: - client_kwargs["sse_read_timeout"] = self.sse_read_timeout - if session_kwargs.get("read_timeout_seconds", None) is not None: - client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds") - - async with streamablehttp_client( - self.url, headers=self.headers, **client_kwargs - ) as transport: - read_stream, write_stream, _ = transport - async with ClientSession( - read_stream, write_stream, **session_kwargs - ) as session: - await session.initialize() - yield session - - def __repr__(self) -> str: - return f"" - - class StdioTransport(ClientTransport): """ Base transport for connecting to an MCP server via subprocess with stdio. @@ -500,18 +367,9 @@ def infer_transport( # the transport is an http(s) URL elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"): if str(transport).rstrip("/").endswith("/sse"): - warnings.warn( - inspect.cleandoc( - """ - As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP. - The provided URL ends in `/sse`, so you may encounter unexpected behavior. - If you intended to use SSE, please use the `SSETransport` class directly. - """ - ), - category=UserWarning, - stacklevel=2, - ) - return StreamableHttpTransport(url=transport) + return SSETransport(url=transport) + else: + return StreamableHttpTransport(url=transport) # the transport is a websocket URL elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"): diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 34209f5b0..104550025 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations import inspect +from pathlib import Path from typing import TYPE_CHECKING, Annotated, Literal from mcp.server.auth.settings import AuthSettings @@ -27,6 +28,8 @@ class Settings(BaseSettings): nested_model_default_partial_update=True, ) + home: Path = Path.home() / ".fastmcp" + test_mode: bool = False log_level: LOG_LEVEL = "INFO" client_raise_first_exceptiongroup_error: Annotated[ From 3859f59247d0aabb8f8edb1f545ece58b4819741 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 15 May 2025 21:19:26 -0400 Subject: [PATCH 02/38] Add authlib --- pyproject.toml | 1 + uv.lock | 127 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3181fbf1a..6a1287dec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "rich>=13.9.4", "typer>=0.15.2", "websockets>=14.0", + "authlib>=1.5.2", ] requires-python = ">=3.10" readme = "README.md" diff --git a/uv.lock b/uv.lock index b8398d3d5..8a07ae672 100644 --- a/uv.lock +++ b/uv.lock @@ -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.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/b3/5f5bc73c6558a21f951ffd267f41c6340d15f5fe0ff4b6bf37694f3558b8/authlib-1.5.2.tar.gz", hash = "sha256:fe85ec7e50c5f86f1e2603518bb3b4f632985eb4a355e52256530790e326c512", size = 153000, upload-time = "2025-04-02T10:31:36.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/71/8dcec996ea8cc882cec9cace91ae1b630a226b88b0f04ab2ffa778f565ad/authlib-1.5.2-py2.py3-none-any.whl", hash = "sha256:8804dd4402ac5e4a0435ac49e0b6e19e395357cfa632a3f624dcb4f6df13b4b1", size = 232055, upload-time = "2025-04-02T10:31:34.59Z" }, +] + [[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" @@ -221,6 +290,53 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "44.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/53/c776d80e9d26441bb3868457909b4e74dd9ccabd182e10b2b0ae7a07e265/cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88", size = 6670281, upload-time = "2025-05-02T19:34:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/6a/06/af2cf8d56ef87c77319e9086601bef621bedf40f6f59069e1b6d1ec498c5/cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137", size = 3959305, upload-time = "2025-05-02T19:34:53.042Z" }, + { url = "https://files.pythonhosted.org/packages/ae/01/80de3bec64627207d030f47bf3536889efee8913cd363e78ca9a09b13c8e/cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c", size = 4171040, upload-time = "2025-05-02T19:34:54.675Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/bb16b7541d207a19d9ae8b541c70037a05e473ddc72ccb1386524d4f023c/cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76", size = 3963411, upload-time = "2025-05-02T19:34:56.61Z" }, + { url = "https://files.pythonhosted.org/packages/42/b2/7d31f2af5591d217d71d37d044ef5412945a8a8e98d5a2a8ae4fd9cd4489/cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359", size = 3689263, upload-time = "2025-05-02T19:34:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/25/50/c0dfb9d87ae88ccc01aad8eb93e23cfbcea6a6a106a9b63a7b14c1f93c75/cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43", size = 4196198, upload-time = "2025-05-02T19:35:00.988Z" }, + { url = "https://files.pythonhosted.org/packages/66/c9/55c6b8794a74da652690c898cb43906310a3e4e4f6ee0b5f8b3b3e70c441/cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01", size = 3966502, upload-time = "2025-05-02T19:35:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f7/7cb5488c682ca59a02a32ec5f975074084db4c983f849d47b7b67cc8697a/cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d", size = 4196173, upload-time = "2025-05-02T19:35:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0b/2f789a8403ae089b0b121f8f54f4a3e5228df756e2146efdf4a09a3d5083/cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904", size = 4087713, upload-time = "2025-05-02T19:35:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/330c13655f1af398fc154089295cf259252f0ba5df93b4bc9d9c7d7f843e/cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44", size = 4299064, upload-time = "2025-05-02T19:35:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/a8/8c540a421b44fd267a7d58a1fd5f072a552d72204a3f08194f98889de76d/cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d", size = 2773887, upload-time = "2025-05-02T19:35:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0d/c4b1657c39ead18d76bbd122da86bd95bdc4095413460d09544000a17d56/cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d", size = 3209737, upload-time = "2025-05-02T19:35:12.12Z" }, + { url = "https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f", size = 6673501, upload-time = "2025-05-02T19:35:13.775Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f0/7491d44bba8d28b464a5bc8cc709f25a51e3eac54c0a4444cf2473a57c37/cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759", size = 3960307, upload-time = "2025-05-02T19:35:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/e5c5d0e1364d3346a5747cdcd7ecbb23ca87e6dea4f942a44e88be349f06/cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645", size = 4170876, upload-time = "2025-05-02T19:35:18.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/96/025cb26fc351d8c7d3a1c44e20cf9a01e9f7cf740353c9c7a17072e4b264/cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2", size = 3964127, upload-time = "2025-05-02T19:35:19.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/eb6522db7d9f84e8833ba3bf63313f8e257729cf3a8917379473fcfd6601/cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54", size = 3689164, upload-time = "2025-05-02T19:35:21.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93", size = 4198081, upload-time = "2025-05-02T19:35:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/1b/50/457f6911d36432a8811c3ab8bd5a6090e8d18ce655c22820994913dd06ea/cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c", size = 3967716, upload-time = "2025-05-02T19:35:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/35/6e/dca39d553075980ccb631955c47b93d87d27f3596da8d48b1ae81463d915/cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f", size = 4197398, upload-time = "2025-05-02T19:35:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/d1f2fe681eabc682067c66a74addd46c887ebacf39038ba01f8860338d3d/cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5", size = 4087900, upload-time = "2025-05-02T19:35:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f5/3599e48c5464580b73b236aafb20973b953cd2e7b44c7c2533de1d888446/cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b", size = 4301067, upload-time = "2025-05-02T19:35:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/d2c48c8137eb39d0c193274db5c04a75dab20d2f7c3f81a7dcc3a8897701/cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028", size = 2775467, upload-time = "2025-05-02T19:35:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/7f/10/abcf7418536df1eaba70e2cfc5c8a0ab07aa7aa02a5cbc6a78b9d8b4f121/cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d", size = 3393192, upload-time = "2025-05-02T19:35:37.468Z" }, + { url = "https://files.pythonhosted.org/packages/06/59/ecb3ef380f5891978f92a7f9120e2852b1df6f0a849c277b8ea45b865db2/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8", size = 3898419, upload-time = "2025-05-02T19:35:39.065Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d0/35e2313dbb38cf793aa242182ad5bc5ef5c8fd4e5dbdc380b936c7d51169/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4", size = 4117892, upload-time = "2025-05-02T19:35:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c8/31fb6e33b56c2c2100d76de3fd820afaa9d4d0b6aea1ccaf9aaf35dc7ce3/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff", size = 3900855, upload-time = "2025-05-02T19:35:42.599Z" }, + { url = "https://files.pythonhosted.org/packages/43/2a/08cc2ec19e77f2a3cfa2337b429676406d4bb78ddd130a05c458e7b91d73/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06", size = 4117619, upload-time = "2025-05-02T19:35:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/fc3d3f84022a75f2ac4b1a1c0e5d6a0c2ea259e14cd4aae3e0e68e56483c/cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9", size = 3136570, upload-time = "2025-05-02T19:35:46.94Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4b/c11ad0b6c061902de5223892d680e89c06c7c4d606305eb8de56c5427ae6/cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375", size = 3390230, upload-time = "2025-05-02T19:35:49.062Z" }, + { url = "https://files.pythonhosted.org/packages/58/11/0a6bf45d53b9b2290ea3cec30e78b78e6ca29dc101e2e296872a0ffe1335/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647", size = 3895216, upload-time = "2025-05-02T19:35:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/b28cdeb7270e957f0077a2c2bfad1b38f72f1f6d699679f97b816ca33642/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259", size = 4115044, upload-time = "2025-05-02T19:35:53.044Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/ec4082d3793f03cb248881fecefc26015813199b88f33e3e990a43f79835/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff", size = 3898034, upload-time = "2025-05-02T19:35:54.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7f/adf62e0b8e8d04d50c9a91282a57628c00c54d4ae75e2b02a223bd1f2613/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5", size = 4114449, upload-time = "2025-05-02T19:35:57.139Z" }, + { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369, upload-time = "2025-05-02T19:35:58.907Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -306,6 +422,7 @@ wheels = [ name = "fastmcp" source = { editable = "." } dependencies = [ + { name = "authlib" }, { name = "exceptiongroup" }, { name = "httpx" }, { name = "mcp" }, @@ -338,6 +455,7 @@ 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.8.1,<2.0.0" }, @@ -737,6 +855,15 @@ 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" From 3a7e2d8203253c03bd3b048011740d294b12c80c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 15 May 2025 21:20:41 -0400 Subject: [PATCH 03/38] Fix import --- src/fastmcp/client/auth/oauth_cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/auth/oauth_cache.py b/src/fastmcp/client/auth/oauth_cache.py index 928b9fa86..1cd3560a4 100644 --- a/src/fastmcp/client/auth/oauth_cache.py +++ b/src/fastmcp/client/auth/oauth_cache.py @@ -4,8 +4,10 @@ from pathlib import Path from typing import Any, ClassVar, Literal from urllib.parse import urlparse -from fastmcp.client.auth.httpx_client import logger from fastmcp.settings import settings +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) class OAuthCache: From a842241a54701db6f75100acd085da2b1fb07fe5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 10:42:26 -0400 Subject: [PATCH 04/38] OAuth client --- src/fastmcp/client/__init__.py | 2 + src/fastmcp/client/auth.py | 474 ++++++++++++++++++++++++ src/fastmcp/client/auth/__init__.py | 0 src/fastmcp/client/auth/httpx_client.py | 424 --------------------- src/fastmcp/client/auth/oauth_cache.py | 124 ------- src/fastmcp/client/client.py | 6 +- src/fastmcp/client/sse.py | 65 ---- src/fastmcp/client/streamable_http.py | 61 --- src/fastmcp/client/transports.py | 46 ++- src/fastmcp/low_level/README.md | 1 - src/fastmcp/low_level/__init__.py | 0 src/fastmcp/py.typed | 0 src/fastmcp/settings.py | 5 +- uv.lock | 333 +++++++++-------- 14 files changed, 692 insertions(+), 849 deletions(-) create mode 100644 src/fastmcp/client/auth.py delete mode 100644 src/fastmcp/client/auth/__init__.py delete mode 100644 src/fastmcp/client/auth/httpx_client.py delete mode 100644 src/fastmcp/client/auth/oauth_cache.py delete mode 100644 src/fastmcp/client/sse.py delete mode 100644 src/fastmcp/client/streamable_http.py delete mode 100644 src/fastmcp/low_level/README.md delete mode 100644 src/fastmcp/low_level/__init__.py delete mode 100644 src/fastmcp/py.typed diff --git a/src/fastmcp/client/__init__.py b/src/fastmcp/client/__init__.py index f8b61cf6f..9397be295 100644 --- a/src/fastmcp/client/__init__.py +++ b/src/fastmcp/client/__init__.py @@ -11,6 +11,7 @@ from .transports import ( FastMCPTransport, StreamableHttpTransport, ) +from .auth import OAuth __all__ = [ "Client", @@ -24,4 +25,5 @@ __all__ = [ "NpxStdioTransport", "FastMCPTransport", "StreamableHttpTransport", + "OAuth", ] diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py new file mode 100644 index 000000000..d2055864e --- /dev/null +++ b/src/fastmcp/client/auth.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import asyncio +import json +import socket +import webbrowser +from pathlib import Path +from typing import Any, Literal, cast +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, + OAuthToken, +) +from mcp.shared.auth import ( + OAuthMetadata as _MCPServerOAuthMetadata, +) +from pydantic import AnyHttpUrl, ValidationError +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from uvicorn import Config, Server + +from fastmcp.settings import settings as fastmcp_global_settings +from fastmcp.utilities.logging import get_logger + +__all__ = ["OAuth"] + +logger = get_logger(__name__) + + +# 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 fastmcp_global_settings.home / "oauth-mcp-client-cache" + ) + 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: + data = json.loads(path.read_text()) + return OAuthToken.model_validate(data) + 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: + data = json.loads(path.read_text()) + return OAuthClientInformationFull.model_validate(data) + 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_cache(self) -> None: + """Clear all cached data for this server.""" + # Use explicit literals to satisfy type checker + for file_type in [ + cast(Literal["client_info", "tokens"], "client_info"), + cast(Literal["client_info", "tokens"], "tokens"), + ]: + 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)}") + + def has_valid_token(self) -> bool: + """Check if there's a valid non-expired token (synchronous check).""" + path = self._get_file_path("tokens") + try: + data = json.loads(path.read_text()) + token = OAuthToken.model_validate(data) + + # Check if token has expiration info + if not token.expires_in: + return True # Assume valid if no expiration + + # We need to check when the token was saved vs current time + # For simplicity, we'll assume the token is fresh enough for now + # A more robust implementation would store the timestamp when saved + return True + + except (FileNotFoundError, json.JSONDecodeError, ValidationError): + return False + + @classmethod + def list_cached_servers(cls, cache_dir: Path | None = None) -> list[str]: + """List all servers with cached data.""" + cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache" + if not cache_dir.exists(): + return [] + + servers = set() + for file in cache_dir.glob("*_tokens.json"): + # Extract server info from filename + key_part = file.stem.replace("_tokens", "") + # Attempt to reconstruct URL (best effort) + if "_" in key_part: + try: + # Handle common patterns like "https_example_com_8080" + parts = key_part.split("_") + if len(parts) >= 3: + scheme = parts[0] + host_parts = parts[1:-1] if parts[-1].isdigit() else parts[1:] + port = parts[-1] if parts[-1].isdigit() else None + + host = ".".join(host_parts) + url = f"{scheme}://{host}" + if port: + url += f":{port}" + servers.add(url) + except Exception: + # If reconstruction fails, at least show the key + servers.add(key_part) + + return sorted(list(servers)) + + @classmethod + def clear_all_cache(cls, cache_dir: Path | None = None) -> None: + """Clear all cached data for all servers.""" + cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache" + if not cache_dir.exists(): + return + + # Use explicit literals to satisfy type checker + for file_type in [ + cast(Literal["client_info", "tokens"], "client_info"), + cast(Literal["client_info", "tokens"], "tokens"), + ]: + for file in cache_dir.glob(f"*_{file_type}.json"): + file.unlink(missing_ok=True) + logger.info("Cleared all OAuth client cache data.") + + +def find_available_port() -> int: + """Find an available port by letting the OS assign one.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def _get_redirect_callback( + port: int, path: str = "/callback", timeout: float = 300.0 +) -> tuple[str, str | None]: + """ + Start a temporary server to handle OAuth redirect and return auth code and state. + + Returns: + Tuple of (authorization_code, state) + """ + response_future = asyncio.get_running_loop().create_future() + + async def callback_handler(request): + if not response_future.done(): + query_params = dict(request.query_params) + auth_code = query_params.get("code") + state = query_params.get("state") + error = query_params.get("error") + + if error: + error_desc = query_params.get("error_description", "Unknown error") + response_future.set_exception( + RuntimeError(f"OAuth error: {error} - {error_desc}") + ) + return PlainTextResponse( + f"❌ OAuth Error: {error}\n{error_desc}\nYou can close this tab.", + status_code=400, + ) + + if not auth_code: + response_future.set_exception( + RuntimeError("OAuth callback missing authorization code") + ) + return PlainTextResponse( + "❌ OAuth Error: No authorization code received.\nYou can close this tab.", + status_code=400, + ) + + response_future.set_result((auth_code, state)) + return PlainTextResponse( + "✅ FastMCP OAuth login complete!\nYou can close this tab now." + ) + + return PlainTextResponse("Callback already processed. You can close this tab.") + + server = Server( + Config( + app=Starlette(routes=[Route(path, callback_handler)]), + host="127.0.0.1", + port=port, + lifespan="off", + log_level="warning", + ) + ) + + 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:{port}{path}" + ) + + 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() + + +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_endpoint_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_endpoint_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_endpoint_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. + + Args: + mcp_endpoint_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_endpoint_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"Opening browser for OAuth authorization: {authorization_url}") + webbrowser.open(authorization_url) + + async def callback_handler() -> tuple[str, str | None]: + """Handle OAuth callback and return (auth_code, state).""" + return await _get_redirect_callback(port=redirect_port) + + # 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 diff --git a/src/fastmcp/client/auth/__init__.py b/src/fastmcp/client/auth/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/client/auth/httpx_client.py b/src/fastmcp/client/auth/httpx_client.py deleted file mode 100644 index 693bd0e3e..000000000 --- a/src/fastmcp/client/auth/httpx_client.py +++ /dev/null @@ -1,424 +0,0 @@ -from __future__ import annotations - -import asyncio -import socket -import time -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager, contextmanager -from contextvars import ContextVar -from typing import Any -from urllib.parse import urljoin - -import anyio -import httpx -import mcp.client.sse -import mcp.client.streamable_http -import mcp.shared._httpx_utils -from authlib.integrations.httpx_client import AsyncOAuth2Client -from starlette.applications import Starlette -from starlette.responses import PlainTextResponse -from starlette.routing import Route -from uvicorn import Config, Server - -from fastmcp.client.auth.oauth_cache import oauth_cache -from fastmcp.utilities.logging import get_logger - -_current_mcp_endpoint: ContextVar[str | None] = ContextVar("mcp_endpoint", default=None) - - -logger = get_logger(__name__) - - -def create_mcp_http_client( - headers: dict[str, Any] | None = None, - timeout: httpx.Timeout | None = None, - **kwargs: Any, -) -> httpx.AsyncClient: - # re-implements logic from mcp.shared._httpx_utils.create_mcp_http_client, but with **kwargs support - kwargs.setdefault("follow_redirects", True) - if timeout is None: - timeout = httpx.Timeout(30.0) - - return httpx.AsyncClient(headers=headers, timeout=timeout, **kwargs) - - -def find_available_port() -> int: - """Find an available port by letting the OS assign one.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -async def _get_redirect( - port: int, path: str = "/callback", timeout: float = 100.0 -) -> str: - """ - Start a temporary server to handle OAuth redirect and get the full redirect URL. - - Args: - port: The port to run the server on - path: The path to listen for redirects on - timeout: Number of seconds to wait before timing out - - Returns: - The full redirect URL from the browser - - Raises: - TimeoutError: If no redirect is received within the timeout period - """ - fut = asyncio.get_running_loop().create_future() - - async def cb(request): - if not fut.done(): - fut.set_result(str(request.url)) # full redirect URL - return PlainTextResponse( - "✅ FastMCP login complete! You can close this tab now." - ) - - server = Server( - Config( - app=Starlette(routes=[Route(path, cb)]), - host="127.0.0.1", - port=port, - lifespan="off", - log_level="error", - ) - ) - - async with anyio.create_task_group() as tg: - tg.start_soon(server.serve) # background task for server - - try: - # Use anyio.fail_after to implement timeout - with anyio.fail_after(timeout): - redirect_url = await fut # wait for browser hit or timeout - return redirect_url - finally: - server.should_exit = True # stop the server loop - tg.cancel_scope.cancel() # tear down immediately - - -class OAuthBearerAuth(httpx.Auth): - """Auth handler that adds the OAuth bearer token to requests.""" - - def __init__(self, client: AsyncOAuth2Client) -> None: - self._client = client - - async def async_auth_flow(self, request: httpx.Request): - # Ensure token is loaded in the OAuth client - if ( - not self._client.token - or self._client.token.get("expires_at") - and self._client.token["expires_at"] < time.time() - ): - # We'll refresh or reauthorize in create_mcp_oauth_client - pass - - if self._client.token: - request.headers["Authorization"] = ( - f"Bearer {self._client.token['access_token']}" - ) - yield request - - -async def discover_oauth_metadata(base_url: str) -> dict[str, Any] | None: - """ - Discover OAuth metadata from the server according to RFC 8414. - - Returns None if the server appears to not require authentication. - """ - # First, try the well-known URL - well_known_url = urljoin(base_url, "/.well-known/oauth-authorization-server") - logger.debug(f"Attempting OAuth metadata discovery from: {well_known_url}") - - async with httpx.AsyncClient() as client: - # First try the well-known URL - try: - response = await client.get(well_known_url, timeout=10) - if response.status_code == 200: - logger.debug("Successfully discovered OAuth metadata") - return response.json() - except httpx.RequestError as e: - logger.debug(f"Failed to fetch OAuth metadata: {e}") - - # If well-known discovery fails, check WWW-Authenticate header - try: - response = await client.get(base_url, timeout=10) - - # If the base URL request succeeds without a 401/403 and has no WWW-Authenticate header, - # the server likely doesn't require authentication - if ( - response.status_code < 400 - and "WWW-Authenticate" not in response.headers - ): - logger.debug("Server appears to not require authentication") - return None - - auth_header = response.headers.get("WWW-Authenticate") - if auth_header and "resource_metadata" in auth_header: - # Extract metadata URL from header - import re - - metadata_match = re.search(r'resource_metadata="([^"]+)"', auth_header) - if metadata_match: - metadata_url = metadata_match.group(1) - metadata_response = await client.get(metadata_url, timeout=10) - if metadata_response.status_code == 200: - logger.debug( - "Successfully discovered OAuth metadata from WWW-Authenticate header" - ) - return metadata_response.json() - except httpx.RequestError as e: - logger.debug(f"Failed to fetch OAuth metadata from WWW-Authenticate: {e}") - - # Fallback to default endpoints based on the base URL - logger.debug("Falling back to default OAuth endpoints") - return { - "issuer": base_url, - "authorization_endpoint": urljoin(base_url, "/authorize"), - "token_endpoint": urljoin(base_url, "/token"), - "registration_endpoint": urljoin(base_url, "/register"), - "response_types_supported": ["code"], - "response_modes_supported": ["query"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "token_endpoint_auth_methods_supported": [ - "client_secret_basic", - "client_secret_post", - "none", - ], - "code_challenge_methods_supported": ["S256"], - } - - -async def register_client( - registration_endpoint: str, redirect_uri: str -) -> dict[str, Any]: - """ - Register an OAuth client using RFC 7591 dynamic registration. - - May raise httpx.HTTPStatusError if registration fails. - """ - logger.debug(f"Registering client at: {registration_endpoint}") - - payload = { - "client_name": "FastMCP Client", - "redirect_uris": [redirect_uri], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", # public PKCE client - } - - async with httpx.AsyncClient() as client: - response = await client.post(registration_endpoint, json=payload, timeout=10) - # Allow HTTPStatusError to propagate to the caller - response.raise_for_status() - logger.debug("Client registration successful") - return response.json() - - -@asynccontextmanager -async def create_mcp_oauth_client( - mcp_endpoint: str, - redirect_uri: str | None = None, - scope: list[str] | None = None, - headers: dict[str, Any] | None = None, - timeout: httpx.Timeout | None = None, - **httpx_kwargs: Any, -) -> AsyncIterator[httpx.AsyncClient]: - """ - Create an authenticated OAuth client for an MCP server from an endpoint URL. - - This function handles: - 1. OAuth metadata discovery - 2. Dynamic client registration if needed - 3. Authorization code flow with PKCE - 4. Token refreshing - 5. Token persistence - - If the server doesn't require authentication, a regular client will be returned. - - Args: - mcp_endpoint: Full URL to an MCP endpoint (e.g., - https://mcp.example.com/sse). This will be used to discover the OAuth - configuration. - redirect_uri: OAuth redirect URI for the authorization flow. If None, - a server will be started on an available port. - scope: OAuth scopes to request - headers: Additional headers to include in the requests - timeout: Timeout for the requests - **httpx_kwargs: Additional arguments for the httpx client - - Returns: - An httpx.AsyncClient that handles authentication automatically - """ - # Extract base URL for OAuth discovery - base_url = oauth_cache.get_base_url(mcp_endpoint) - logger.debug(f"MCP Endpoint: {mcp_endpoint}") - logger.debug(f"Base URL for OAuth: {base_url}") - - # Discover OAuth metadata - metadata = await discover_oauth_metadata(base_url) - - # If metadata is None, the server doesn't require authentication - if metadata is None: - logger.info("Server doesn't require authentication, creating regular client") - async with create_mcp_http_client( - headers=headers, - timeout=timeout, - **httpx_kwargs, - ) as client: - yield client - return - - logger.debug(f"Using OAuth endpoints: {metadata}") - - # Use dynamic redirect URI if none provided - # Generate port only once and reuse it for all operations - port = None - if redirect_uri is None: - port = find_available_port() - redirect_uri = f"http://127.0.0.1:{port}/callback" - logger.debug(f"Using dynamic redirect URI: {redirect_uri}") - - # Load or register client - check if we need to update registration due to new redirect URI - creds = oauth_cache.load(mcp_endpoint, "client") - if creds and redirect_uri not in creds.get("redirect_uris", []): - logger.debug("Redirect URI not in registered URIs, re-registering client") - creds = None # Force re-registration - - # Register if needed - if not creds: - try: - creds = await register_client( - metadata["registration_endpoint"], redirect_uri - ) - oauth_cache.save(mcp_endpoint, creds, "client") - logger.debug(f"Client registered with redirect URI: {redirect_uri}") - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - # If registration endpoint returns 404, server likely doesn't support OAuth - logger.info("Registration endpoint not found, creating regular client") - async with create_mcp_http_client( - headers=headers, - timeout=timeout, - **httpx_kwargs, - ) as client: - yield client - return - else: - # Other HTTP errors should be propagated - raise - - # Create the OAuth client - oauth_client = AsyncOAuth2Client( - client_id=creds["client_id"], - client_secret=creds.get("client_secret"), # "public" clients omit secret - scope=scope or ["openid", "profile", "email"], - redirect_uri=redirect_uri, - **httpx_kwargs, - ) - - # Load token if exists - passing mcp_endpoint directly - token = oauth_cache.load(mcp_endpoint, "token") - if token: - oauth_client.token = token - - try: - # Ensure we have a valid token - if ( - not oauth_client.token - or not oauth_client.token.get("expires_at") - or oauth_client.token["expires_at"] < time.time() - ): - # Try to refresh if possible - if oauth_client.token and oauth_client.token.get("refresh_token"): - logger.debug("Refreshing token") - try: - # ignore type because refresh_token is awaitable but not typed as such - token = await oauth_client.refresh_token( # type: ignore[await-expr] - url=metadata["token_endpoint"], - refresh_token=oauth_client.token["refresh_token"], - ) - except Exception as e: - logger.warning(f"Failed to refresh token: {e}") - token = None - else: - token = None - - # If token is still not available, start authorization flow - if not token: - # Start authorization flow with PKCE - logger.info("Starting authorization flow") - uri, _ = oauth_client.create_authorization_url( - metadata["authorization_endpoint"], - redirect_uri=redirect_uri, - code_challenge_method="S256", - ) - import webbrowser - - webbrowser.open(uri) - - # Wait for redirect after user approval - reuse the same port - try: - # We need to ensure port is not None for the _get_redirect function - redirect_port = port if port is not None else find_available_port() - redirect_url = await _get_redirect(port=redirect_port) - logger.info("Received redirect, fetching token") - - # ignore type because fetch_token is awaitable but not typed as such - token = await oauth_client.fetch_token( # type: ignore[await-expr] - url=metadata["token_endpoint"], - authorization_response=redirect_url, - timeout=15, # seconds - ) - except Exception as e: - logger.warning(f"Failed to fetch token: {e}") - token = None - - # Save token for future use - passing mcp_endpoint directly - if token is not None: - oauth_cache.save(mcp_endpoint, token, "token") - logger.debug("Token saved successfully") - - # Create a standard httpx client with the OAuth bearer auth - async with create_mcp_http_client( - auth=OAuthBearerAuth(oauth_client), - headers=headers, - timeout=timeout, - **httpx_kwargs, - ) as client: - # Yield the authenticated client - yield client - finally: - await oauth_client.aclose() - - -@contextmanager -def patch_mcp_httpx_client(mcp_endpoint: str): - """ - This context manager can be used to monkeypatch the low-level function that - returns an httpx.AsyncClient. It replaces it with a function that returns an - MCP OAuth-aware client. - - This is ugly, but it lets us reuse the low-level SDK without maintaining a fork. - """ - original_shttp_client_fn = mcp.client.streamable_http.create_mcp_http_client # type: ignore - original_sse_client_fn = mcp.client.sse.create_mcp_http_client # type: ignore - - # use tokens to manage context across concurrent requests - token = _current_mcp_endpoint.set(mcp_endpoint) - - def patched_mcp_client(**kwargs): - url = _current_mcp_endpoint.get() - if url is None: - return mcp.shared._httpx_utils.create_mcp_http_client(**kwargs) - return create_mcp_oauth_client(mcp_endpoint=url, **kwargs) - - try: - mcp.client.streamable_http.create_mcp_http_client = patched_mcp_client # type: ignore - mcp.client.sse.create_mcp_http_client = patched_mcp_client # type: ignore - yield - finally: - _current_mcp_endpoint.reset(token) - mcp.client.streamable_http.create_mcp_http_client = original_shttp_client_fn # type: ignore - mcp.client.sse.create_mcp_http_client = original_sse_client_fn # type: ignore diff --git a/src/fastmcp/client/auth/oauth_cache.py b/src/fastmcp/client/auth/oauth_cache.py deleted file mode 100644 index 1cd3560a4..000000000 --- a/src/fastmcp/client/auth/oauth_cache.py +++ /dev/null @@ -1,124 +0,0 @@ -import json -import time -from pathlib import Path -from typing import Any, ClassVar, Literal -from urllib.parse import urlparse - -from fastmcp.settings import settings -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class OAuthCache: - """Manages OAuth credentials and tokens caching.""" - - # Class variables - CACHE_DIR: ClassVar[Path] = settings.home / "oauth-cache" - - def __init__(self): - """Initialize the cache directory.""" - 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 with a path.""" - parsed = urlparse(url) - return f"{parsed.scheme}://{parsed.netloc}" - - def get_cache_key(self, url: str) -> str: - """Generate a safe filesystem key from a URL, automatically extracting the base URL.""" - base_url = self.get_base_url(url) - # Replace scheme:// and non-alphanumeric characters with _ for safety - return base_url.replace("://", "_").replace(".", "_").replace("/", "_") - - def get_file_path(self, url: str, file_type: Literal["client", "token"]) -> Path: - """Get the file path for the specified cache file type and URL.""" - key = self.get_cache_key(url) - return self.CACHE_DIR / f"{key}_{file_type}.json" - - def save( - self, url: str, data: dict[str, Any], file_type: Literal["client", "token"] - ) -> None: - """Save data to the cache file using the base URL extracted from url.""" - path = self.get_file_path(url, file_type) - path.write_text(json.dumps(data)) - base_url = self.get_base_url(url) - logger.debug(f"Saved {file_type} data for {base_url}") - - def load( - self, url: str, file_type: Literal["client", "token"] - ) -> dict[str, Any] | None: - """Load data from the cache file using the base URL extracted from url.""" - path = self.get_file_path(url, file_type) - try: - return json.loads(path.read_text()) - except (FileNotFoundError, json.JSONDecodeError): - base_url = self.get_base_url(url) - logger.debug(f"No valid {file_type} cache found for {base_url}") - return None - - def has_valid_token(self, url: str) -> bool: - """Check if there's a valid non-expired token for the given URL.""" - token = self.load(url, "token") - if not token: - return False - - # Check expiration - expires_at = token.get("expires_at") - if not expires_at or expires_at < time.time(): - return False - - return True - - def list_cached_endpoints(self) -> list[str]: - """List all base URLs with cached credentials or tokens.""" - endpoints = set() - - file_types = ["client", "token"] - for file_type in file_types: - for file in self.CACHE_DIR.glob(f"*_{file_type}.json"): - key = file.name.replace(f"_{file_type}.json", "") - # This is a simplified conversion back to URL format - # May need enhancement for complex URLs - url = key.replace("_", "://", 1) - # Attempt to reconstruct the URL in a basic way - parts = url.split("_") - if len(parts) > 1: - # Reconstruct with dots and slashes - reconstructed = parts[0] - for part in parts[1:]: - if part: - reconstructed += f".{part}" - endpoints.add(reconstructed) - else: - endpoints.add(url) - - return sorted(list(endpoints)) - - def clear(self, url: str | None = None) -> None: - """ - Clear the OAuth cache for a specific URL or all cached data. - - Args: - url: The URL to clear cache for. If None, clears all cache. - """ - if url is None: - # Clear all files in the cache directory - file_types = ["client", "token"] - for file_type in file_types: - for file in self.CACHE_DIR.glob(f"*_{file_type}.json"): - file.unlink(missing_ok=True) - logger.info("Cleared all OAuth cache data") - else: - # Clear only files for the specific URL - path = self.get_file_path(url, "client") - path.unlink(missing_ok=True) - path = self.get_file_path(url, "token") - path.unlink(missing_ok=True) - base_url = self.get_base_url(url) - logger.info(f"Cleared OAuth cache for {base_url}") - - -# Initialize global cache instance -oauth_cache = OAuthCache() diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index b5e4d31a8..e86669c20 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,9 +1,10 @@ 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 @@ -144,8 +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)) + if auth is not None: + self.transport._set_auth(auth) self._session: ClientSession | None = None self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 diff --git a/src/fastmcp/client/sse.py b/src/fastmcp/client/sse.py deleted file mode 100644 index 4e17c1dc9..000000000 --- a/src/fastmcp/client/sse.py +++ /dev/null @@ -1,65 +0,0 @@ -import contextlib -import datetime -import logging -from collections.abc import AsyncIterator -from typing import cast - -from mcp import ClientSession -from mcp.client.sse import sse_client -from pydantic import AnyUrl -from typing_extensions import Unpack - -from fastmcp.client.auth.httpx_client import patch_mcp_httpx_client -from fastmcp.client.client import ClientTransport, SessionKwargs - -logger = logging.getLogger(__name__) - - -class SSETransport(ClientTransport): - """Transport implementation that connects to an MCP server via Server-Sent Events.""" - - def __init__( - self, - url: str | AnyUrl, - headers: dict[str, str] | None = None, - sse_read_timeout: datetime.timedelta | float | int | None = None, - ): - if isinstance(url, AnyUrl): - url = str(url) - if not isinstance(url, str) or not url.startswith("http"): - raise ValueError("Invalid HTTP/S URL provided for SSE.") - self.url = url - self.headers = headers or {} - - if isinstance(sse_read_timeout, int | float): - sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) - self.sse_read_timeout = sse_read_timeout - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - client_kwargs = {} - # sse_read_timeout has a default value set, so we can't pass None without overriding it - # instead we simply leave the kwarg out if it's not provided - if self.sse_read_timeout is not None: - client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds() - if session_kwargs.get("read_timeout_seconds", None) is not None: - read_timeout_seconds = cast( - datetime.timedelta, session_kwargs.get("read_timeout_seconds") - ) - client_kwargs["timeout"] = read_timeout_seconds.total_seconds() - - with patch_mcp_httpx_client(self.url): - async with sse_client( - self.url, headers=self.headers, **client_kwargs - ) as transport: - read_stream, write_stream = transport - async with ClientSession( - read_stream, write_stream, **session_kwargs - ) as session: - await session.initialize() - yield session - - def __repr__(self) -> str: - return f"" diff --git a/src/fastmcp/client/streamable_http.py b/src/fastmcp/client/streamable_http.py deleted file mode 100644 index a5d7b8e03..000000000 --- a/src/fastmcp/client/streamable_http.py +++ /dev/null @@ -1,61 +0,0 @@ -import contextlib -import datetime -from collections.abc import AsyncIterator - -from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client -from pydantic import AnyUrl -from typing_extensions import Unpack - -from fastmcp.client.auth.httpx_client import patch_mcp_httpx_client -from fastmcp.client.client import ClientTransport, SessionKwargs -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class StreamableHttpTransport(ClientTransport): - """Transport implementation that connects to an MCP server via Streamable HTTP Requests.""" - - def __init__( - self, - url: str | AnyUrl, - headers: dict[str, str] | None = None, - sse_read_timeout: datetime.timedelta | float | int | None = None, - ): - if isinstance(url, AnyUrl): - url = str(url) - if not isinstance(url, str) or not url.startswith("http"): - raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.") - self.url = url - self.headers = headers or {} - - if isinstance(sse_read_timeout, int | float): - sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) - self.sse_read_timeout = sse_read_timeout - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - client_kwargs = {} - # sse_read_timeout has a default value set, so we can't pass None without overriding it - # instead we simply leave the kwarg out if it's not provided - if self.sse_read_timeout is not None: - client_kwargs["sse_read_timeout"] = self.sse_read_timeout - if session_kwargs.get("read_timeout_seconds", None) is not None: - client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds") - - with patch_mcp_httpx_client(self.url): - async with streamablehttp_client( - self.url, headers=self.headers, **client_kwargs - ) as transport: - read_stream, write_stream, _ = transport - async with ClientSession( - read_stream, write_stream, **session_kwargs - ) as session: - await session.initialize() - yield session - - def __repr__(self) -> str: - return f"" diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index c9988da03..134dffa24 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -5,21 +5,29 @@ import datetime import os import shutil import sys +import warnings from collections.abc import AsyncIterator 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 httpx from mcp import ClientSession, StdioServerParameters +from mcp.client.session import ( + ListRootsFnT, + LoggingFnT, + MessageHandlerFnT, + SamplingFnT, +) +from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client from mcp.client.websocket import websocket_client from mcp.server.fastmcp import FastMCP as FastMCP1Server from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack -from fastmcp.client.client import ClientTransport, SessionKwargs -from fastmcp.client.sse import SSETransport -from fastmcp.client.streamable_http import StreamableHttpTransport +from fastmcp.client.auth import OAuth from fastmcp.server import FastMCP as FastMCPServer from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP @@ -101,6 +109,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.""" @@ -140,6 +152,7 @@ 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, ): if isinstance(url, AnyUrl): @@ -148,11 +161,20 @@ class SSETransport(ClientTransport): raise ValueError("Invalid HTTP/S URL provided for SSE.") self.url = url self.headers = headers or {} + self._set_auth(auth) 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] @@ -174,7 +196,7 @@ class SSETransport(ClientTransport): ) client_kwargs["timeout"] = read_timeout_seconds.total_seconds() - async with sse_client(self.url, **client_kwargs) as transport: + 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 @@ -192,6 +214,7 @@ 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, ): if isinstance(url, AnyUrl): @@ -200,11 +223,20 @@ class StreamableHttpTransport(ClientTransport): raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.") self.url = url self.headers = headers or {} + self._set_auth(auth) 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] @@ -223,7 +255,9 @@ 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: + 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 diff --git a/src/fastmcp/low_level/README.md b/src/fastmcp/low_level/README.md deleted file mode 100644 index 929ebd521..000000000 --- a/src/fastmcp/low_level/README.md +++ /dev/null @@ -1 +0,0 @@ -Patched low-level objects. When possible, we prefer the official SDK, but we patch bugs here if necessary. \ No newline at end of file diff --git a/src/fastmcp/low_level/__init__.py b/src/fastmcp/low_level/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/py.typed b/src/fastmcp/py.typed deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 1a9e5be2f..89ca731e3 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -2,16 +2,13 @@ from __future__ import annotations as _annotations import inspect from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Literal +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 typing_extensions import Self -if TYPE_CHECKING: - pass - LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] diff --git a/uv.lock b/uv.lock index fb1d955f3..7755eac15 100644 --- a/uv.lock +++ b/uv.lock @@ -41,14 +41,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.5.2" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/b3/5f5bc73c6558a21f951ffd267f41c6340d15f5fe0ff4b6bf37694f3558b8/authlib-1.5.2.tar.gz", hash = "sha256:fe85ec7e50c5f86f1e2603518bb3b4f632985eb4a355e52256530790e326c512", size = 153000, upload-time = "2025-04-02T10:31:36.488Z" } +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/e3/71/8dcec996ea8cc882cec9cace91ae1b630a226b88b0f04ab2ffa778f565ad/authlib-1.5.2-py2.py3-none-any.whl", hash = "sha256:8804dd4402ac5e4a0435ac49e0b6e19e395357cfa632a3f624dcb4f6df13b4b1", size = 232055, upload-time = "2025-04-02T10:31:34.59Z" }, + { 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]] @@ -189,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]] @@ -210,7 +210,7 @@ wheels = [ [[package]] name = "copychat" -version = "0.5.3" +version = "0.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitpython" }, @@ -220,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] @@ -292,49 +296,49 @@ toml = [ [[package]] name = "cryptography" -version = "44.0.3" +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/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" } +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/08/53/c776d80e9d26441bb3868457909b4e74dd9ccabd182e10b2b0ae7a07e265/cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88", size = 6670281, upload-time = "2025-05-02T19:34:50.665Z" }, - { url = "https://files.pythonhosted.org/packages/6a/06/af2cf8d56ef87c77319e9086601bef621bedf40f6f59069e1b6d1ec498c5/cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137", size = 3959305, upload-time = "2025-05-02T19:34:53.042Z" }, - { url = "https://files.pythonhosted.org/packages/ae/01/80de3bec64627207d030f47bf3536889efee8913cd363e78ca9a09b13c8e/cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c", size = 4171040, upload-time = "2025-05-02T19:34:54.675Z" }, - { url = "https://files.pythonhosted.org/packages/bd/48/bb16b7541d207a19d9ae8b541c70037a05e473ddc72ccb1386524d4f023c/cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76", size = 3963411, upload-time = "2025-05-02T19:34:56.61Z" }, - { url = "https://files.pythonhosted.org/packages/42/b2/7d31f2af5591d217d71d37d044ef5412945a8a8e98d5a2a8ae4fd9cd4489/cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359", size = 3689263, upload-time = "2025-05-02T19:34:58.591Z" }, - { url = "https://files.pythonhosted.org/packages/25/50/c0dfb9d87ae88ccc01aad8eb93e23cfbcea6a6a106a9b63a7b14c1f93c75/cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43", size = 4196198, upload-time = "2025-05-02T19:35:00.988Z" }, - { url = "https://files.pythonhosted.org/packages/66/c9/55c6b8794a74da652690c898cb43906310a3e4e4f6ee0b5f8b3b3e70c441/cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01", size = 3966502, upload-time = "2025-05-02T19:35:03.091Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f7/7cb5488c682ca59a02a32ec5f975074084db4c983f849d47b7b67cc8697a/cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d", size = 4196173, upload-time = "2025-05-02T19:35:05.018Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0b/2f789a8403ae089b0b121f8f54f4a3e5228df756e2146efdf4a09a3d5083/cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904", size = 4087713, upload-time = "2025-05-02T19:35:07.187Z" }, - { url = "https://files.pythonhosted.org/packages/1d/aa/330c13655f1af398fc154089295cf259252f0ba5df93b4bc9d9c7d7f843e/cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44", size = 4299064, upload-time = "2025-05-02T19:35:08.879Z" }, - { url = "https://files.pythonhosted.org/packages/10/a8/8c540a421b44fd267a7d58a1fd5f072a552d72204a3f08194f98889de76d/cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d", size = 2773887, upload-time = "2025-05-02T19:35:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0d/c4b1657c39ead18d76bbd122da86bd95bdc4095413460d09544000a17d56/cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d", size = 3209737, upload-time = "2025-05-02T19:35:12.12Z" }, - { url = "https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f", size = 6673501, upload-time = "2025-05-02T19:35:13.775Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f0/7491d44bba8d28b464a5bc8cc709f25a51e3eac54c0a4444cf2473a57c37/cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759", size = 3960307, upload-time = "2025-05-02T19:35:15.917Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c8/e5c5d0e1364d3346a5747cdcd7ecbb23ca87e6dea4f942a44e88be349f06/cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645", size = 4170876, upload-time = "2025-05-02T19:35:18.138Z" }, - { url = "https://files.pythonhosted.org/packages/73/96/025cb26fc351d8c7d3a1c44e20cf9a01e9f7cf740353c9c7a17072e4b264/cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2", size = 3964127, upload-time = "2025-05-02T19:35:19.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/44/eb6522db7d9f84e8833ba3bf63313f8e257729cf3a8917379473fcfd6601/cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54", size = 3689164, upload-time = "2025-05-02T19:35:21.449Z" }, - { url = "https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93", size = 4198081, upload-time = "2025-05-02T19:35:23.187Z" }, - { url = "https://files.pythonhosted.org/packages/1b/50/457f6911d36432a8811c3ab8bd5a6090e8d18ce655c22820994913dd06ea/cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c", size = 3967716, upload-time = "2025-05-02T19:35:25.426Z" }, - { url = "https://files.pythonhosted.org/packages/35/6e/dca39d553075980ccb631955c47b93d87d27f3596da8d48b1ae81463d915/cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f", size = 4197398, upload-time = "2025-05-02T19:35:27.678Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/d1f2fe681eabc682067c66a74addd46c887ebacf39038ba01f8860338d3d/cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5", size = 4087900, upload-time = "2025-05-02T19:35:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f5/3599e48c5464580b73b236aafb20973b953cd2e7b44c7c2533de1d888446/cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b", size = 4301067, upload-time = "2025-05-02T19:35:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/d2c48c8137eb39d0c193274db5c04a75dab20d2f7c3f81a7dcc3a8897701/cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028", size = 2775467, upload-time = "2025-05-02T19:35:33.805Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/7f/10/abcf7418536df1eaba70e2cfc5c8a0ab07aa7aa02a5cbc6a78b9d8b4f121/cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d", size = 3393192, upload-time = "2025-05-02T19:35:37.468Z" }, - { url = "https://files.pythonhosted.org/packages/06/59/ecb3ef380f5891978f92a7f9120e2852b1df6f0a849c277b8ea45b865db2/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8", size = 3898419, upload-time = "2025-05-02T19:35:39.065Z" }, - { url = "https://files.pythonhosted.org/packages/bb/d0/35e2313dbb38cf793aa242182ad5bc5ef5c8fd4e5dbdc380b936c7d51169/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4", size = 4117892, upload-time = "2025-05-02T19:35:40.839Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c8/31fb6e33b56c2c2100d76de3fd820afaa9d4d0b6aea1ccaf9aaf35dc7ce3/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff", size = 3900855, upload-time = "2025-05-02T19:35:42.599Z" }, - { url = "https://files.pythonhosted.org/packages/43/2a/08cc2ec19e77f2a3cfa2337b429676406d4bb78ddd130a05c458e7b91d73/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06", size = 4117619, upload-time = "2025-05-02T19:35:44.774Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/fc3d3f84022a75f2ac4b1a1c0e5d6a0c2ea259e14cd4aae3e0e68e56483c/cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9", size = 3136570, upload-time = "2025-05-02T19:35:46.94Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4b/c11ad0b6c061902de5223892d680e89c06c7c4d606305eb8de56c5427ae6/cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375", size = 3390230, upload-time = "2025-05-02T19:35:49.062Z" }, - { url = "https://files.pythonhosted.org/packages/58/11/0a6bf45d53b9b2290ea3cec30e78b78e6ca29dc101e2e296872a0ffe1335/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647", size = 3895216, upload-time = "2025-05-02T19:35:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/0a/27/b28cdeb7270e957f0077a2c2bfad1b38f72f1f6d699679f97b816ca33642/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259", size = 4115044, upload-time = "2025-05-02T19:35:53.044Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/ec4082d3793f03cb248881fecefc26015813199b88f33e3e990a43f79835/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff", size = 3898034, upload-time = "2025-05-02T19:35:54.72Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7f/adf62e0b8e8d04d50c9a91282a57628c00c54d4ae75e2b02a223bd1f2613/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5", size = 4114449, upload-time = "2025-05-02T19:35:57.139Z" }, - { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369, upload-time = "2025-05-02T19:35:58.907Z" }, + { 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]] @@ -366,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]] @@ -393,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]] @@ -568,11 +575,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]] @@ -693,7 +700,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.9.0" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -706,9 +713,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]] @@ -795,20 +802,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]] @@ -868,7 +875,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.4" +version = "2.11.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -876,9 +883,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]] @@ -1017,15 +1024,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]] @@ -1047,14 +1054,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]] @@ -1118,15 +1125,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]] @@ -1291,27 +1298,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]] @@ -1343,15 +1350,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.3.3" +version = "2.3.5" 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/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511, upload-time = "2025-05-12T18:23:52.601Z" } 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/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233, upload-time = "2025-05-12T18:23:50.722Z" }, ] [[package]] @@ -1466,7 +1473,7 @@ wheels = [ [[package]] name = "typer" -version = "0.15.3" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1474,9 +1481,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]] @@ -1490,14 +1497,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]] @@ -1525,16 +1532,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]] From 67c36b0c110667d041d98bc74488e15fa3e5e1df Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 10:44:26 -0400 Subject: [PATCH 05/38] Require mcp 1.9.2 or greater --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a11aed301..a55e60254 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "python-dotenv>=1.1.0", "exceptiongroup>=1.2.2", "httpx>=0.28.1", - "mcp>=1.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", diff --git a/uv.lock b/uv.lock index 7755eac15..93f9a4262 100644 --- a/uv.lock +++ b/uv.lock @@ -466,7 +466,7 @@ 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" }, From c438987d4ea93305ff64790132bfb870c453e91c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 13:03:01 -0400 Subject: [PATCH 06/38] Add callback server --- src/fastmcp/client/auth.py | 149 +++++--------- src/fastmcp/client/oauth_callback.py | 296 +++++++++++++++++++++++++++ 2 files changed, 343 insertions(+), 102 deletions(-) create mode 100644 src/fastmcp/client/oauth_callback.py diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index d2055864e..1c02b4f64 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -2,13 +2,11 @@ from __future__ import annotations import asyncio import json -import socket import webbrowser from pathlib import Path -from typing import Any, Literal, cast +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 @@ -21,11 +19,11 @@ from mcp.shared.auth import ( OAuthMetadata as _MCPServerOAuthMetadata, ) from pydantic import AnyHttpUrl, ValidationError -from starlette.applications import Starlette -from starlette.responses import PlainTextResponse -from starlette.routing import Route -from uvicorn import Config, Server +from fastmcp.client.oauth_callback import ( + create_oauth_callback_server, + find_available_port, +) from fastmcp.settings import settings as fastmcp_global_settings from fastmcp.utilities.logging import get_logger @@ -186,11 +184,8 @@ class FileTokenStorage(TokenStorage): def clear_cache(self) -> None: """Clear all cached data for this server.""" - # Use explicit literals to satisfy type checker - for file_type in [ - cast(Literal["client_info", "tokens"], "client_info"), - cast(Literal["client_info", "tokens"], "tokens"), - ]: + 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)}") @@ -253,95 +248,13 @@ class FileTokenStorage(TokenStorage): if not cache_dir.exists(): return - # Use explicit literals to satisfy type checker - for file_type in [ - cast(Literal["client_info", "tokens"], "client_info"), - cast(Literal["client_info", "tokens"], "tokens"), - ]: + 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.") -def find_available_port() -> int: - """Find an available port by letting the OS assign one.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -async def _get_redirect_callback( - port: int, path: str = "/callback", timeout: float = 300.0 -) -> tuple[str, str | None]: - """ - Start a temporary server to handle OAuth redirect and return auth code and state. - - Returns: - Tuple of (authorization_code, state) - """ - response_future = asyncio.get_running_loop().create_future() - - async def callback_handler(request): - if not response_future.done(): - query_params = dict(request.query_params) - auth_code = query_params.get("code") - state = query_params.get("state") - error = query_params.get("error") - - if error: - error_desc = query_params.get("error_description", "Unknown error") - response_future.set_exception( - RuntimeError(f"OAuth error: {error} - {error_desc}") - ) - return PlainTextResponse( - f"❌ OAuth Error: {error}\n{error_desc}\nYou can close this tab.", - status_code=400, - ) - - if not auth_code: - response_future.set_exception( - RuntimeError("OAuth callback missing authorization code") - ) - return PlainTextResponse( - "❌ OAuth Error: No authorization code received.\nYou can close this tab.", - status_code=400, - ) - - response_future.set_result((auth_code, state)) - return PlainTextResponse( - "✅ FastMCP OAuth login complete!\nYou can close this tab now." - ) - - return PlainTextResponse("Callback already processed. You can close this tab.") - - server = Server( - Config( - app=Starlette(routes=[Route(path, callback_handler)]), - host="127.0.0.1", - port=port, - lifespan="off", - log_level="warning", - ) - ) - - 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:{port}{path}" - ) - - 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() - - async def discover_oauth_metadata( server_base_url: str, httpx_kwargs: dict[str, Any] | None = None ) -> _MCPServerOAuthMetadata | None: @@ -417,12 +330,16 @@ def OAuth( """ 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_endpoint_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 + mcp_endpoint_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 @@ -460,7 +377,35 @@ def OAuth( async def callback_handler() -> tuple[str, str | None]: """Handle OAuth callback and return (auth_code, state).""" - return await _get_redirect_callback(port=redirect_port) + # 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 + import anyio + + 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}" + ) + + try: + with anyio.fail_after(300.0): # 5 minute timeout + auth_code, state = await response_future + return auth_code, state + except TimeoutError: + raise TimeoutError("OAuth callback timed out after 300 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( diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py new file mode 100644 index 000000000..e0aec7970 --- /dev/null +++ b/src/fastmcp/client/oauth_callback.py @@ -0,0 +1,296 @@ +""" +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 +import socket + +from starlette.applications import Starlette +from starlette.responses import HTMLResponse +from starlette.routing import Route +from uvicorn import Config, Server + +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""" +
+ Connected to: {server_url} +
+ """ + + return f""" + + + + + + {title} + + + +
+ {status_emoji} +
{message}
+ {server_info} +
+ You can safely close this tab now. +
+
+ + + """ + + +def find_available_port() -> int: + """Find an available port by letting the OS assign one.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +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): + """Handle OAuth callback requests with proper HTML responses.""" + query_params = dict(request.query_params) + auth_code = query_params.get("code") + state = query_params.get("state") + error = query_params.get("error") + + if error: + error_desc = query_params.get("error_description", "Unknown error") + + # Resolve future with exception if provided + if response_future and not response_future.done(): + response_future.set_exception( + RuntimeError(f"OAuth error: {error} - {error_desc}") + ) + + return HTMLResponse( + create_callback_html( + f"OAuth Error: {error}
{error_desc}", is_success=False + ), + status_code=400, + ) + + if not auth_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( + "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((auth_code, state)) + + return HTMLResponse( + create_callback_html("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, + ) From 1aeb61be5aa3d5dd12dd2c5d88df4d0ccc64fa3b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 13:24:43 -0400 Subject: [PATCH 07/38] Update token cache --- src/fastmcp/client/auth.py | 94 ++++++++++------------------ src/fastmcp/client/client.py | 1 - src/fastmcp/client/oauth_callback.py | 7 ++- 3 files changed, 38 insertions(+), 64 deletions(-) diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index 1c02b4f64..7b589026e 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -1,24 +1,29 @@ from __future__ import annotations import asyncio +import datetime 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, - OAuthToken, ) from mcp.shared.auth import ( OAuthMetadata as _MCPServerOAuthMetadata, ) -from pydantic import AnyHttpUrl, ValidationError +from mcp.shared.auth import ( + OAuthToken as _MCPOAuthToken, +) +from pydantic import AnyHttpUrl, ValidationError, model_validator +from typing_extensions import Self from fastmcp.client.oauth_callback import ( create_oauth_callback_server, @@ -32,6 +37,21 @@ __all__ = ["OAuth"] logger = get_logger(__name__) +class OAuthToken(_MCPOAuthToken): + """ + OAuth token that stores expiration as a datetime object + """ + + expires_at: datetime.datetime | None = None + + @model_validator(mode="after") + def set_expires_at(self) -> Self: + if self.expires_in is not None and self.expires_at is None: + now = datetime.datetime.now(datetime.timezone.utc) + self.expires_at = now + datetime.timedelta(seconds=self.expires_in) + return self + + # Flexible OAuth models for real-world compatibility class ServerOAuthMetadata(_MCPServerOAuthMetadata): """ @@ -149,17 +169,24 @@ class FileTokenStorage(TokenStorage): async def get_tokens(self) -> OAuthToken | None: """Load tokens from file storage.""" path = self._get_file_path("tokens") + try: - data = json.loads(path.read_text()) - return OAuthToken.model_validate(data) + 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: + async def set_tokens(self, tokens: _MCPOAuthToken) -> None: """Save tokens to file storage.""" + # Convert to custom model with expiration datetime + tokens = OAuthToken.model_validate(tokens) 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)}") @@ -168,8 +195,7 @@ class FileTokenStorage(TokenStorage): """Load client information from file storage.""" path = self._get_file_path("client_info") try: - data = json.loads(path.read_text()) - return OAuthClientInformationFull.model_validate(data) + return OAuthClientInformationFull.model_validate_json(path.read_text()) except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e: logger.debug( f"Could not load client info for {self.get_base_url(self.server_url)}: {e}" @@ -190,59 +216,8 @@ class FileTokenStorage(TokenStorage): path.unlink(missing_ok=True) logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}") - def has_valid_token(self) -> bool: - """Check if there's a valid non-expired token (synchronous check).""" - path = self._get_file_path("tokens") - try: - data = json.loads(path.read_text()) - token = OAuthToken.model_validate(data) - - # Check if token has expiration info - if not token.expires_in: - return True # Assume valid if no expiration - - # We need to check when the token was saved vs current time - # For simplicity, we'll assume the token is fresh enough for now - # A more robust implementation would store the timestamp when saved - return True - - except (FileNotFoundError, json.JSONDecodeError, ValidationError): - return False - @classmethod - def list_cached_servers(cls, cache_dir: Path | None = None) -> list[str]: - """List all servers with cached data.""" - cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache" - if not cache_dir.exists(): - return [] - - servers = set() - for file in cache_dir.glob("*_tokens.json"): - # Extract server info from filename - key_part = file.stem.replace("_tokens", "") - # Attempt to reconstruct URL (best effort) - if "_" in key_part: - try: - # Handle common patterns like "https_example_com_8080" - parts = key_part.split("_") - if len(parts) >= 3: - scheme = parts[0] - host_parts = parts[1:-1] if parts[-1].isdigit() else parts[1:] - port = parts[-1] if parts[-1].isdigit() else None - - host = ".".join(host_parts) - url = f"{scheme}://{host}" - if port: - url += f":{port}" - servers.add(url) - except Exception: - # If reconstruction fails, at least show the key - servers.add(key_part) - - return sorted(list(servers)) - - @classmethod - def clear_all_cache(cls, cache_dir: Path | None = None) -> None: + def clear_all(cls, cache_dir: Path | None = None) -> None: """Clear all cached data for all servers.""" cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache" if not cache_dir.exists(): @@ -388,7 +363,6 @@ def OAuth( ) # Run server until response is received with timeout logic - import anyio async with anyio.create_task_group() as tg: tg.start_soon(server.serve) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index e86669c20..641d34e45 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -44,7 +44,6 @@ from .transports import ( __all__ = [ "Client", - "ClientTransport", "SessionKwargs", "RootsHandler", "RootsList", diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index e0aec7970..8376050a2 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -221,7 +221,7 @@ def create_oauth_callback_server( return HTMLResponse( create_callback_html( - f"OAuth Error: {error}
{error_desc}", is_success=False + f"FastMCP OAuth Error: {error}
{error_desc}", is_success=False ), status_code=400, ) @@ -235,7 +235,8 @@ def create_oauth_callback_server( return HTMLResponse( create_callback_html( - "OAuth Error: No authorization code received", is_success=False + "FastMCP OAuth Error: No authorization code received", + is_success=False, ), status_code=400, ) @@ -245,7 +246,7 @@ def create_oauth_callback_server( response_future.set_result((auth_code, state)) return HTMLResponse( - create_callback_html("OAuth login complete!", server_url=server_url) + create_callback_html("FastMCP OAuth login complete!", server_url=server_url) ) app = Starlette(routes=[Route(callback_path, callback_handler)]) From 334163a4b744df9ee9158e9bd921af7a24327b06 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 14:23:34 -0400 Subject: [PATCH 08/38] Add basic server primitives --- src/fastmcp/server/auth/__init__.py | 0 src/fastmcp/server/auth/auth.py | 38 ++ src/fastmcp/server/auth/in_memory_provider.py | 326 ++++++++++++++++++ src/fastmcp/server/http.py | 104 ++---- src/fastmcp/server/server.py | 22 +- src/fastmcp/settings.py | 3 - src/fastmcp/utilities/tests.py | 7 +- 7 files changed, 413 insertions(+), 87 deletions(-) create mode 100644 src/fastmcp/server/auth/__init__.py create mode 100644 src/fastmcp/server/auth/auth.py create mode 100644 src/fastmcp/server/auth/in_memory_provider.py diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py new file mode 100644 index 000000000..b92160304 --- /dev/null +++ b/src/fastmcp/server/auth/auth.py @@ -0,0 +1,38 @@ +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + OAuthAuthorizationServerProvider, + RefreshToken, +) +from mcp.server.auth.settings import ( + AuthSettings, + 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, + ): + 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.settings = AuthSettings( + issuer_url=issuer_url, + service_documentation_url=service_documentation_url, + client_registration_options=client_registration_options, + revocation_options=revocation_options, + required_scopes=required_scopes, + ) diff --git a/src/fastmcp/server/auth/in_memory_provider.py b/src/fastmcp/server/auth/in_memory_provider.py new file mode 100644 index 000000000..5887305c6 --- /dev/null +++ b/src/fastmcp/server/auth/in_memory_provider.py @@ -0,0 +1,326 @@ +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 +# Refresh tokens often have longer or no expiry; let's make them non-expiring for simplicity +DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None + + +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 or "https://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. diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index de65c7e7e..d0501431f 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -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.settings.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.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, ) + ) 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( diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1d0b59ab8..19c2ea574 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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,7 @@ 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.http import ( StarletteWithLifespan, create_sse_app, @@ -110,8 +110,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]], @@ -186,13 +185,7 @@ 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 + self.auth = auth # Set up MCP protocol handlers self._setup_handlers() @@ -903,8 +896,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, ) @@ -951,8 +943,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, @@ -963,8 +954,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, ) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 89ca731e3..405dc7ce8 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -4,7 +4,6 @@ import inspect 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 typing_extensions import Self @@ -171,8 +170,6 @@ 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 = ( diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 0d64a1292..cb697129f 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -94,7 +94,7 @@ def run_server_in_process( 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 +102,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") From 90180498280cbf76aa4cc180a8031fd58d994a31 Mon Sep 17 00:00:00 2001 From: Sillocan Date: Fri, 30 May 2025 16:17:06 -0700 Subject: [PATCH 09/38] test: Demonstrate issue with concurrent proxy tasks --- tests/server/test_proxy.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index f310fa505..22fe3415b 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -3,6 +3,7 @@ from typing import Any import mcp.types import pytest +from anyio import create_task_group from dirty_equals import Contains from mcp import McpError @@ -242,3 +243,26 @@ class TestPrompts: 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!" + + +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" + ) From 62eea7a20bad8a77e9e7fe307309a823efbb14a7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 20:08:42 -0400 Subject: [PATCH 10/38] Clean up --- src/fastmcp/client/auth.py | 31 +++++++------- src/fastmcp/client/oauth_callback.py | 40 ++++++++++++++----- src/fastmcp/server/auth/in_memory_provider.py | 3 +- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index 7b589026e..bd63419a2 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -186,7 +186,7 @@ class FileTokenStorage(TokenStorage): async def set_tokens(self, tokens: _MCPOAuthToken) -> None: """Save tokens to file storage.""" # Convert to custom model with expiration datetime - tokens = OAuthToken.model_validate(tokens) + tokens = OAuthToken.model_validate(tokens.model_dump()) 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)}") @@ -266,7 +266,7 @@ async def discover_oauth_metadata( async def check_if_auth_required( - mcp_endpoint_url: str, httpx_kwargs: dict[str, Any] | None = None + mcp_url: str, httpx_kwargs: dict[str, Any] | None = None ) -> bool: """ Check if the MCP endpoint requires authentication by making a test request. @@ -277,7 +277,7 @@ async def check_if_auth_required( async with httpx.AsyncClient(**(httpx_kwargs or {})) as client: try: # Try a simple request to the endpoint - response = await client.get(mcp_endpoint_url, timeout=5.0) + 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): @@ -296,7 +296,7 @@ async def check_if_auth_required( def OAuth( - mcp_endpoint_url: str, + mcp_url: str, scopes: str | list[str] | None = None, client_name: str = "FastMCP Client", token_storage_cache_dir: Path | None = None, @@ -309,17 +309,18 @@ def OAuth( httpx.AsyncClient (or appropriate FastMCP client/transport instance) Args: - mcp_endpoint_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 + 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_endpoint_url) + parsed_url = urlparse(mcp_url) server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" # Setup OAuth client @@ -347,7 +348,7 @@ def OAuth( # Define OAuth handlers async def redirect_handler(authorization_url: str) -> None: """Open browser for authorization.""" - logger.info(f"Opening browser for OAuth authorization: {authorization_url}") + logger.info(f"OAuth authorization URL: {authorization_url}") webbrowser.open(authorization_url) async def callback_handler() -> tuple[str, str | None]: @@ -363,19 +364,19 @@ def OAuth( ) # 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(300.0): # 5 minute timeout + with anyio.fail_after(TIMEOUT): auth_code, state = await response_future return auth_code, state except TimeoutError: - raise TimeoutError("OAuth callback timed out after 300 seconds") + 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 diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index 8376050a2..f9cecd16b 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -9,8 +9,10 @@ from __future__ import annotations import asyncio import socket +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 @@ -184,6 +186,21 @@ def find_available_port() -> int: return s.getsockname()[1] +@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", @@ -203,30 +220,31 @@ def create_oauth_callback_server( Configured uvicorn Server instance (not yet running) """ - async def callback_handler(request): + async def callback_handler(request: Request): """Handle OAuth callback requests with proper HTML responses.""" query_params = dict(request.query_params) - auth_code = query_params.get("code") - state = query_params.get("state") - error = query_params.get("error") + callback_response = CallbackResponse.from_dict(query_params) - if error: - error_desc = query_params.get("error_description", "Unknown error") + 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: {error} - {error_desc}") + RuntimeError( + f"OAuth error: {callback_response.error} - {error_desc}" + ) ) return HTMLResponse( create_callback_html( - f"FastMCP OAuth Error: {error}
{error_desc}", is_success=False + f"FastMCP OAuth Error: {callback_response.error}
{error_desc}", + is_success=False, ), status_code=400, ) - if not auth_code: + if not callback_response.code: # Resolve future with exception if provided if response_future and not response_future.done(): response_future.set_exception( @@ -243,7 +261,9 @@ def create_oauth_callback_server( # Success case if response_future and not response_future.done(): - response_future.set_result((auth_code, state)) + response_future.set_result( + (callback_response.code, callback_response.state) + ) return HTMLResponse( create_callback_html("FastMCP OAuth login complete!", server_url=server_url) diff --git a/src/fastmcp/server/auth/in_memory_provider.py b/src/fastmcp/server/auth/in_memory_provider.py index 5887305c6..59ac0d2ad 100644 --- a/src/fastmcp/server/auth/in_memory_provider.py +++ b/src/fastmcp/server/auth/in_memory_provider.py @@ -25,8 +25,7 @@ from fastmcp.server.auth.auth import ( # Default expiration times (in seconds) DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour -# Refresh tokens often have longer or no expiry; let's make them non-expiring for simplicity -DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None +DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry class InMemoryOAuthProvider(OAuthProvider): From f779624a7451a83fc77331808c0bf697547868e2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 21:17:51 -0400 Subject: [PATCH 11/38] Add client factory --- src/fastmcp/client/transports.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 134dffa24..42b524485 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -6,9 +6,17 @@ 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, Literal, TypedDict, TypeVar, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypedDict, + TypeVar, + cast, + overload, +) import httpx from mcp import ClientSession, StdioServerParameters @@ -154,6 +162,7 @@ class SSETransport(ClientTransport): 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) @@ -162,6 +171,7 @@ class SSETransport(ClientTransport): 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) @@ -196,6 +206,9 @@ class SSETransport(ClientTransport): ) client_kwargs["timeout"] = read_timeout_seconds.total_seconds() + 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( @@ -216,6 +229,7 @@ class StreamableHttpTransport(ClientTransport): 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) @@ -224,6 +238,7 @@ class StreamableHttpTransport(ClientTransport): 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) @@ -255,8 +270,13 @@ class StreamableHttpTransport(ClientTransport): if session_kwargs.get("read_timeout_seconds", None) is not None: client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds") + 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 + self.url, + auth=self.auth, + **client_kwargs, ) as transport: read_stream, write_stream, _ = transport async with ClientSession( From 1d131a8c96eb405cb03f515e510c59cba7763bf8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 21:17:56 -0400 Subject: [PATCH 12/38] Create test_oauth.py --- tests/client/test_oauth.py | 284 +++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 tests/client/test_oauth.py diff --git a/tests/client/test_oauth.py b/tests/client/test_oauth.py new file mode 100644 index 000000000..1c2488ec8 --- /dev/null +++ b/tests/client/test_oauth.py @@ -0,0 +1,284 @@ +import sys +from collections.abc import Generator +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +import uvicorn + +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.in_memory_provider 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, transport: str | None = None) -> None: + try: + # Configure OAuth provider with the actual server URL + issuer_url = f"http://{host}:{port}" + app = fastmcp_server(issuer_url).http_app() + 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="module") +def streamable_http_server() -> Generator[str, None, None]: + with run_server_in_process(run_server) 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) From b8e1e8114b9a54b4e9b520c300d0cff64b95dc4c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 30 May 2025 21:38:17 -0400 Subject: [PATCH 13/38] Fix issue when client info is written but flow is incomplete --- src/fastmcp/client/auth.py | 60 +++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index bd63419a2..6165fdfd8 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import datetime import json import webbrowser from pathlib import Path @@ -20,10 +19,9 @@ from mcp.shared.auth import ( OAuthMetadata as _MCPServerOAuthMetadata, ) from mcp.shared.auth import ( - OAuthToken as _MCPOAuthToken, + OAuthToken as OAuthToken, ) -from pydantic import AnyHttpUrl, ValidationError, model_validator -from typing_extensions import Self +from pydantic import AnyHttpUrl, ValidationError from fastmcp.client.oauth_callback import ( create_oauth_callback_server, @@ -37,19 +35,8 @@ __all__ = ["OAuth"] logger = get_logger(__name__) -class OAuthToken(_MCPOAuthToken): - """ - OAuth token that stores expiration as a datetime object - """ - - expires_at: datetime.datetime | None = None - - @model_validator(mode="after") - def set_expires_at(self) -> Self: - if self.expires_in is not None and self.expires_at is None: - now = datetime.datetime.now(datetime.timezone.utc) - self.expires_at = now + datetime.timedelta(seconds=self.expires_in) - return self +def default_cache_dir() -> Path: + return fastmcp_global_settings.home / "oauth-mcp-client-cache" # Flexible OAuth models for real-world compatibility @@ -140,9 +127,7 @@ class FileTokenStorage(TokenStorage): 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 fastmcp_global_settings.home / "oauth-mcp-client-cache" - ) + self.cache_dir = cache_dir or default_cache_dir() self.cache_dir.mkdir(exist_ok=True, parents=True) @staticmethod @@ -172,10 +157,10 @@ class FileTokenStorage(TokenStorage): 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 + # 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( @@ -183,10 +168,8 @@ class FileTokenStorage(TokenStorage): ) return None - async def set_tokens(self, tokens: _MCPOAuthToken) -> None: + async def set_tokens(self, tokens: OAuthToken) -> None: """Save tokens to file storage.""" - # Convert to custom model with expiration datetime - tokens = OAuthToken.model_validate(tokens.model_dump()) 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)}") @@ -195,7 +178,24 @@ class FileTokenStorage(TokenStorage): """Load client information from file storage.""" path = self._get_file_path("client_info") try: - return OAuthClientInformationFull.model_validate_json(path.read_text()) + 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}" @@ -208,7 +208,7 @@ class FileTokenStorage(TokenStorage): 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_cache(self) -> None: + 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: @@ -219,7 +219,7 @@ class FileTokenStorage(TokenStorage): @classmethod def clear_all(cls, cache_dir: Path | None = None) -> None: """Clear all cached data for all servers.""" - cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache" + cache_dir = cache_dir or default_cache_dir() if not cache_dir.exists(): return From 90dd0c04f35f5cbbb4aaf6df32783dbfdb259529 Mon Sep 17 00:00:00 2001 From: Sillocan Date: Fri, 30 May 2025 18:45:59 -0700 Subject: [PATCH 14/38] fix: Use a background task for managing session state mimicing stdiotransport --- src/fastmcp/client/client.py | 57 +++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 03fb7fd5a..b982c0464 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,3 +1,4 @@ +import asyncio import datetime from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path @@ -147,6 +148,10 @@ class Client(Generic[ClientTransportT]): 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 = asyncio.Event() + self._stop_event = asyncio.Event() self._initialize_result: mcp.types.InitializeResult | None = None if log_handler is None: @@ -186,6 +191,7 @@ class Client(Generic[ClientTransportT]): self._session_kwargs["sampling_callback"] = create_sampling_callback( sampling_handler ) + # self._session_manager = self._context_manager() @property def session(self) -> ClientSession: @@ -237,34 +243,45 @@ 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 - + 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 __aexit__(self, exc_type, exc_val, exc_tb): - self._nesting_counter -= 1 + async with self._context_lock: + self._nesting_counter -= 1 + if self._nesting_counter != 0: + return + self._stop_event.set() + runner_task = self._session_task + self._session_task = None + if runner_task: + await runner_task + # Reset for future reconnects + self._stop_event = anyio.Event() + self._ready_event = anyio.Event() - 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 _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.transport.close() From 10c868274beab8cd24a9283e272aad1bc57f4446 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 18:03:14 -0400 Subject: [PATCH 15/38] Ensure close() cleans up clients --- src/fastmcp/client/client.py | 52 +++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index b908fbbd7..15e72c7f1 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -150,13 +150,6 @@ class Client(Generic[ClientTransportT]): self.transport = cast(ClientTransportT, infer_transport(transport)) if auth is not None: self.transport._set_auth(auth) - 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 = asyncio.Event() - self._stop_event = asyncio.Event() self._initialize_result: mcp.types.InitializeResult | None = None if log_handler is None: @@ -196,7 +189,15 @@ class Client(Generic[ClientTransportT]): self._session_kwargs["sampling_callback"] = create_sampling_callback( sampling_handler ) - # self._session_manager = self._context_manager() + + # 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 = asyncio.Event() + self._stop_event = asyncio.Event() @property def session(self) -> ClientSession: @@ -252,6 +253,14 @@ class Client(Generic[ClientTransportT]): self._initialize_result = None async def __aenter__(self): + await self._connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self._disconnect() + + 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: @@ -262,19 +271,37 @@ class Client(Generic[ClientTransportT]): self._nesting_counter += 1 return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + 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: - self._nesting_counter -= 1 - if self._nesting_counter != 0: + # 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: @@ -289,9 +316,8 @@ class Client(Generic[ClientTransportT]): self._ready_event.set() async def close(self): + await self._disconnect(force=True) await self.transport.close() - self._session = None - self._initialize_result = None # --- MCP Client Methods --- From 814a6e441980532aae2baa9b5ce53d46c53f8a8c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 18:08:43 -0400 Subject: [PATCH 16/38] Add concurrency test --- tests/client/test_client.py | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 36952f35e..b4253d306 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -342,6 +342,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)) From fdfa1fe1084f5c47056d8887d63f4d9a6a0a7441 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 18:12:17 -0400 Subject: [PATCH 17/38] Use anyio.event --- src/fastmcp/client/client.py | 4 ++-- src/fastmcp/client/transports.py | 9 +++++---- tests/server/test_logging.py | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 15e72c7f1..cc82c6eb3 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -196,8 +196,8 @@ class Client(Generic[ClientTransportT]): self._nesting_counter: int = 0 self._context_lock = anyio.Lock() self._session_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() @property def session(self) -> ClientSession: diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 42b524485..cafe9588c 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -18,6 +18,7 @@ from typing import ( overload, ) +import anyio import httpx from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( @@ -327,8 +328,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( @@ -391,8 +392,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() diff --git a/tests/server/test_logging.py b/tests/server/test_logging.py index ea827c7f0..1a3c09a63 100644 --- a/tests/server/test_logging.py +++ b/tests/server/test_logging.py @@ -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 = { From 2390fb4da669df839ae6e57920a4662cfc48a234 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:32:08 -0400 Subject: [PATCH 18/38] clean up test inits --- tests/auth/__init__.py | 0 tests/cli/__init__.py | 0 tests/client/__init__.py | 1 - tests/server/http/__init__.py | 0 tests/server/openapi/__init__.py | 0 tests/server/test_lifespan.py | 396 ------------------------------- 6 files changed, 397 deletions(-) create mode 100644 tests/auth/__init__.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/server/http/__init__.py create mode 100644 tests/server/openapi/__init__.py delete mode 100644 tests/server/test_lifespan.py diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/client/__init__.py b/tests/client/__init__.py index 92836662d..e69de29bb 100644 --- a/tests/client/__init__.py +++ b/tests/client/__init__.py @@ -1 +0,0 @@ -"""Client tests package.""" diff --git a/tests/server/http/__init__.py b/tests/server/http/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/openapi/__init__.py b/tests/server/openapi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py deleted file mode 100644 index ad041bbf9..000000000 --- a/tests/server/test_lifespan.py +++ /dev/null @@ -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 - ) From ad8182eed0361925e542fb48a092747569ed96ef Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:33:50 -0400 Subject: [PATCH 19/38] Create http utility --- src/fastmcp/client/auth.py | 2 +- src/fastmcp/client/oauth_callback.py | 9 +-------- src/fastmcp/server/auth/auth.py | 10 ++++++++++ src/fastmcp/utilities/http.py | 8 ++++++++ .../test_oauth.py => auth/test_oauth_client.py} | 4 ++-- 5 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 src/fastmcp/utilities/http.py rename tests/{client/test_oauth.py => auth/test_oauth_client.py} (98%) diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index 6165fdfd8..43df9d442 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -25,9 +25,9 @@ from pydantic import AnyHttpUrl, ValidationError from fastmcp.client.oauth_callback import ( create_oauth_callback_server, - find_available_port, ) 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"] diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index f9cecd16b..891e4cdb0 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -8,7 +8,6 @@ and display styled responses to users. from __future__ import annotations import asyncio -import socket from dataclasses import dataclass from starlette.applications import Starlette @@ -17,6 +16,7 @@ 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__) @@ -179,13 +179,6 @@ def create_callback_html( """ -def find_available_port() -> int: - """Find an available port by letting the OS assign one.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - @dataclass class CallbackResponse: code: str | None = None diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index b92160304..b5f07c523 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -23,6 +23,16 @@ class OAuthProvider( 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) diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py new file mode 100644 index 000000000..22c165735 --- /dev/null +++ b/src/fastmcp/utilities/http.py @@ -0,0 +1,8 @@ +import socket + + +def find_available_port() -> int: + """Find an available port by letting the OS assign one.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] diff --git a/tests/client/test_oauth.py b/tests/auth/test_oauth_client.py similarity index 98% rename from tests/client/test_oauth.py rename to tests/auth/test_oauth_client.py index 1c2488ec8..e743e25c3 100644 --- a/tests/client/test_oauth.py +++ b/tests/auth/test_oauth_client.py @@ -11,7 +11,7 @@ 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.in_memory_provider import InMemoryOAuthProvider +from fastmcp.server.auth.providers.in_memory import InMemory from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process @@ -20,7 +20,7 @@ def fastmcp_server(issuer_url: str): """Create a FastMCP server with OAuth authentication.""" server = FastMCP( "TestServer", - auth=InMemoryOAuthProvider( + auth=InMemory( issuer_url=issuer_url, client_registration_options=ClientRegistrationOptions(enabled=True), ), From 3d454380b15f52aa7626f6a3e8974eb7379be5a4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:36:04 -0400 Subject: [PATCH 20/38] Fix import and remaining available port --- src/fastmcp/utilities/tests.py | 5 ++--- tests/auth/test_oauth_client.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index cb697129f..4fa73006e 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -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 @@ -84,9 +85,7 @@ def run_server_in_process( 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() proc = multiprocessing.Process( target=server_fn, args=(host, port, *args), daemon=True diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index e743e25c3..2a1fd4c23 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -11,7 +11,7 @@ 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 InMemory +from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider as InMemory from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process From 97c9b9cbe44979cd7847a0e8de8fe43bf246643a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:30:36 -0400 Subject: [PATCH 21/38] Fix import --- src/fastmcp/client/transports.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index cafe9588c..5ab1d9a9e 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -37,7 +37,6 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.client.auth import OAuth -from fastmcp.server import FastMCP as FastMCPServer from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger @@ -55,7 +54,6 @@ __all__ = [ "ClientTransport", "SSETransport", "StreamableHttpTransport", - "FastMCPServer", "StdioTransport", "PythonStdioTransport", "FastMCPStdioTransport", @@ -656,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 @@ -770,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ... @overload -def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ... +def infer_transport(transport: FastMCP) -> FastMCPTransport: ... @overload @@ -805,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor def infer_transport( transport: ClientTransport - | FastMCPServer + | FastMCP | FastMCP1Server | AnyUrl | Path @@ -822,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 @@ -860,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 From 9cc67eeb70e895670df484e5a09112e0ac4448c2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:34:14 -0400 Subject: [PATCH 22/38] Update src/fastmcp/utilities/http.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/utilities/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py index 22c165735..c1237d62e 100644 --- a/src/fastmcp/utilities/http.py +++ b/src/fastmcp/utilities/http.py @@ -3,6 +3,6 @@ import socket def find_available_port() -> int: """Find an available port by letting the OS assign one.""" - with socket.socket() as s: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] From f1e3713fc68e0aca867ea1221fb3148090267947 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:41:18 -0400 Subject: [PATCH 23/38] Update test typing --- tests/client/test_client.py | 22 +-- tests/client/test_openapi.py | 28 ++-- tests/client/test_roots.py | 4 +- tests/client/test_sse.py | 4 +- tests/client/test_stdio.py | 28 ++-- tests/client/test_streamable_http.py | 4 +- tests/prompts/test_prompt_manager.py | 6 +- tests/resources/test_file_resources.py | 1 - tests/server/http/test_http_dependencies.py | 19 +-- tests/server/openapi/test_openapi.py | 54 ++----- tests/server/test_import_server.py | 18 +-- tests/server/test_mount.py | 38 ++--- tests/server/test_proxy.py | 24 +-- tests/server/test_server.py | 115 +++++-------- tests/server/test_server_interactions.py | 170 +++++++------------- tests/server/test_tool_annotations.py | 7 +- tests/test_examples.py | 34 +--- tests/tools/test_tool.py | 46 ++---- tests/tools/test_tool_manager.py | 111 +++---------- tests/utilities/test_mcp_config.py | 8 +- 20 files changed, 221 insertions(+), 520 deletions(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index b4253d306..d1398640d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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." @@ -522,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) @@ -538,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") @@ -554,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") diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 0a05bb00d..8ea642096 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -5,7 +5,6 @@ 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 @@ -111,8 +110,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 +120,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 +130,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 +142,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 +150,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 +160,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 +170,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 +185,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 +195,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" diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py index 74b478a6d..91739aa6b 100644 --- a/tests/client/test_roots.py +++ b/tests/client/test_roots.py @@ -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", ] diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 787657882..ebf7a0a31 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -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 @@ -96,8 +95,7 @@ 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" diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 71bbefe4a..c32975b48 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -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 diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 53e765242..34723e7c4 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -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 @@ -106,8 +105,7 @@ 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" diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index e00aba3e0..51710792c 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -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 diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 5f355e360..05ba0fe75 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -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): diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 192090792..938b55c14 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -4,7 +4,6 @@ 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 @@ -99,8 +98,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 +109,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 +122,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 +132,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 +145,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 +156,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" diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index f07f6c87c..97b5297a6 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -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"] diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 93512f23d..ed03567be 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -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,7 +321,6 @@ 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.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" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index a04a14edf..17fdf3a68 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -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): diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 22fe3415b..12f568047 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -1,7 +1,6 @@ import json from typing import Any -import mcp.types import pytest from anyio import create_task_group from dirty_equals import Contains @@ -90,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(): @@ -138,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=""): @@ -164,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: @@ -177,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"): @@ -202,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 @@ -239,10 +231,8 @@ 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( diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 1f420213c..56ae434ba 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -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 @@ -48,8 +44,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: @@ -114,8 +109,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 +128,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 +156,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 +170,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 +182,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 +192,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 +206,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 +218,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 +249,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 +302,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 +329,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 +373,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 +391,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 +404,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 +415,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 +434,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 +461,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 +492,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 +512,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 +525,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 +536,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 +577,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 +602,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 +616,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 +639,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 +665,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 +683,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 +698,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 +711,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 +908,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 +994,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] diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index d7c7577c5..6d7c9ed4c 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -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_ 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] diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index eee54de76..dfe2ef744 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -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] diff --git a/tests/test_examples.py b/tests/test_examples.py index fcee6c521..0fa1da3a4 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -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] diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 141035534..bcb9a671a 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -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] diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 5cc3aa33e..75ccf0349 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -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"): diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index b7737da1d..bd0c84bee 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -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] From 39c8f398635be711ad9f3eba8c3018b66516f217 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:46:12 -0400 Subject: [PATCH 24/38] Fix remaining typing issues --- tests/server/test_import_server.py | 2 +- tests/server/test_server_interactions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index ed03567be..2e004bbfe 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -321,7 +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 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." diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 6d7c9ed4c..3264c0601 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -979,7 +979,7 @@ class TestResourceTemplates: # Test lookup by name name_result = await client.read_resource(AnyUrl("users://name/John")) assert name_result[0].text # type: ignore[attr-defined] - name_ + 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" From 8a0f9aab5acd90e7e2b20d9b89095f437f3e71d2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 22:42:00 -0400 Subject: [PATCH 25/38] Support providing tools at init --- src/fastmcp/server/server.py | 8 ++++++++ tests/server/test_server.py | 19 +++++++++++++++++++ uv.lock | 23 +++++++++++------------ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 19c2ea574..5af2b596d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -127,6 +127,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: @@ -187,6 +188,13 @@ class FastMCP(Generic[LifespanResultT]): 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() diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 56ae434ba..a5416229d 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -12,6 +12,7 @@ from fastmcp.server.server import ( has_resource_prefix, remove_resource_prefix, ) +from fastmcp.tools.tool import Tool class TestCreateServer: @@ -93,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): diff --git a/uv.lock b/uv.lock index 93f9a4262..ae92d334e 100644 --- a/uv.lock +++ b/uv.lock @@ -445,8 +445,8 @@ 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 = "pyright" }, @@ -602,7 +602,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'", @@ -620,14 +620,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'", @@ -645,9 +645,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]] @@ -1350,15 +1350,14 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.3.5" +version = "2.3.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511, upload-time = "2025-05-12T18:23:52.601Z" } +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/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233, upload-time = "2025-05-12T18:23:50.722Z" }, + { 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]] From 05c87d9f8922f2f3d9c83f9f1cbcc2354adb14b8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 22:42:51 -0400 Subject: [PATCH 26/38] Update fastmcp.mdx --- docs/servers/fastmcp.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index ad2a356b0..4967d6cfa 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -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 to add to the server. In some cases, providing tools as a list of functions may be more convenient than using the `@mcp.tool` decorator. * `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration ## Components From 7ac2c79ad93bba47094644103e2a02473299bb50 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 22:49:25 -0400 Subject: [PATCH 27/38] Update fastmcp.mdx --- docs/servers/fastmcp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index 4967d6cfa..cfcd6452d 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -35,7 +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 to add to the server. In some cases, providing tools as a list of functions may be more convenient than using the `@mcp.tool` decorator. +* `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 From 8fb709c98c8162d5a0a01f21dedddfc21b375723 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 09:35:06 -0400 Subject: [PATCH 28/38] Simplify code for running servers in processes --- src/fastmcp/utilities/tests.py | 22 ++++-- tests/auth/test_oauth_client.py | 25 +------ tests/client/test_openapi.py | 77 +++++---------------- tests/client/test_sse.py | 36 +++------- tests/client/test_streamable_http.py | 56 +++++---------- tests/server/http/test_http_dependencies.py | 44 ++---------- 6 files changed, 70 insertions(+), 190 deletions(-) diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 4fa73006e..149182f3b 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal import uvicorn +import fastmcp from fastmcp.settings import settings from fastmcp.utilities.http import find_available_port @@ -72,14 +73,19 @@ 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: FastMCP | 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 (or a function 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 FastMCP server to run, or a function that runs a FastMCP + server. If a FastMCP server is provided, its .run() method is called + with the provided arguments and keyword arguments. Returns: The server URL. @@ -87,8 +93,14 @@ def run_server_in_process( host = "127.0.0.1" port = find_available_port() + if isinstance(server_fn, fastmcp.FastMCP): + server_fn = server_fn.run + + 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() diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index 2a1fd4c23..5d668c6dc 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -1,11 +1,9 @@ -import sys from collections.abc import Generator from unittest.mock import patch from urllib.parse import parse_qs, urlparse import httpx import pytest -import uvicorn import fastmcp.client.auth # Import module, not the function directly from fastmcp.client import Client @@ -39,30 +37,13 @@ def fastmcp_server(issuer_url: str): return server -def run_server(host: str, port: int, transport: str | None = None) -> None: - try: - # Configure OAuth provider with the actual server URL - issuer_url = f"http://{host}:{port}" - app = fastmcp_server(issuer_url).http_app() - 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(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) as url: + with run_server_in_process(run_server, transport="streamable-http") as url: yield f"{url}/mcp" diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 8ea642096..000895e87 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -1,9 +1,7 @@ import json -import sys from collections.abc import Generator import pytest -import uvicorn from fastapi import FastAPI, Request from fastmcp import Client, FastMCP @@ -34,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): diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index ebf7a0a31..39b556dda 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -63,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" @@ -101,22 +92,17 @@ async def test_http_headers(sse_server: str): 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 diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 34723e7c4..2c4e349e3 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -63,28 +63,13 @@ 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", - ) - ) - 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(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" @@ -111,28 +96,23 @@ async def test_http_headers(streamable_http_server: str): def run_nested_server(host: str, port: int) -> None: - try: - mcp_app = fastmcp_server().http_app(path="/final/mcp") + 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, + 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 = 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() async def test_nested_streamable_http_server_resolves_correctly(): diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 938b55c14..580ceabd3 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -1,9 +1,7 @@ import json -import sys from collections.abc import Generator import pytest -import uvicorn from fastmcp.client import Client from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -40,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" From d7824f14142613fff68acf753f7500e329d5939b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 09:35:42 -0400 Subject: [PATCH 29/38] Move nested server --- tests/client/test_streamable_http.py | 40 ++++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 2c4e349e3..a806f7200 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -67,6 +67,26 @@ 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() + + @pytest.fixture(scope="module") def streamable_http_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="streamable-http") as url: @@ -95,26 +115,6 @@ async def test_http_headers(streamable_http_server: str): assert json_result["x-demo-header"] == "ABC" -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() - - async def test_nested_streamable_http_server_resolves_correctly(): # tests patch for # https://github.com/modelcontextprotocol/python-sdk/pull/659 From 7f5744aeb1909b63033b1a640ffc71694300ca67 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 09:39:39 -0400 Subject: [PATCH 30/38] Ensure pickleable args --- src/fastmcp/utilities/tests.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 149182f3b..fd9e45945 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Literal import uvicorn -import fastmcp from fastmcp.settings import settings from fastmcp.utilities.http import find_available_port @@ -73,19 +72,21 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No @contextmanager def run_server_in_process( - server_fn: FastMCP | Callable[..., None], + server_fn: Callable[..., None], *args, provide_host_and_port: bool = True, **kwargs, ) -> Generator[str, None, None]: """ - Context manager that runs a FastMCP server (or a function that runs a FastMCP server) in a separate process and + 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: - server_fn: The FastMCP server to run, or a function that runs a FastMCP - server. If a FastMCP server is provided, its .run() method is called - with the provided arguments and keyword arguments. + 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. @@ -93,9 +94,6 @@ def run_server_in_process( host = "127.0.0.1" port = find_available_port() - if isinstance(server_fn, fastmcp.FastMCP): - server_fn = server_fn.run - if provide_host_and_port: kwargs |= {"host": host, "port": port} From 20ae68bb7dce5506030498093df5c425c848a54b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 11:13:21 -0400 Subject: [PATCH 31/38] Add basic bearer auth for server and client --- pyproject.toml | 1 + src/fastmcp/client/auth.py | 9 + src/fastmcp/server/auth/bearer.py | 256 ++++++++++++ src/fastmcp/server/auth/providers/__init__.py | 0 src/fastmcp/server/auth/providers/bearer.py | 359 +++++++++++++++++ .../in_memory.py} | 7 +- src/fastmcp/server/dependencies.py | 10 + tests/auth/providers/test_bearer.py | 366 ++++++++++++++++++ tests/auth/test_oauth_client.py | 4 +- uv.lock | 58 +++ 10 files changed, 1067 insertions(+), 3 deletions(-) create mode 100644 src/fastmcp/server/auth/bearer.py create mode 100644 src/fastmcp/server/auth/providers/__init__.py create mode 100644 src/fastmcp/server/auth/providers/bearer.py rename src/fastmcp/server/auth/{in_memory_provider.py => providers/in_memory.py} (98%) create mode 100644 tests/auth/providers/test_bearer.py diff --git a/pyproject.toml b/pyproject.toml index a55e60254..35b538b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ 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", diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index 43df9d442..28c2bba84 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -392,3 +392,12 @@ def OAuth( ) 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 diff --git a/src/fastmcp/server/auth/bearer.py b/src/fastmcp/server/auth/bearer.py new file mode 100644 index 000000000..729fcf1dc --- /dev/null +++ b/src/fastmcp/server/auth/bearer.py @@ -0,0 +1,256 @@ +""" +Simple JWT Bearer Token validation for hosted MCP servers. + +Uses RS256 (asymmetric) where your control plane signs with a private key +and hosted MCP servers validate with the corresponding public key. + +Example usage: +# Static public key +provider = BearerTokenValidatorProvider( + public_key='''-----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY-----''', + issuer="https://auth.yourservice.com" +) + +# Or JWKS URI (recommended for production - allows key rotation) +provider = BearerTokenValidatorProvider( + jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", + issuer="https://auth.yourservice.com" +) +""" + +import time +from typing import Any + +import httpx +from authlib.jose import JsonWebKey, JsonWebToken +from authlib.jose.errors import JoseError +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + RefreshToken, +) +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthToken, +) + +from fastmcp.server.auth.auth import ( + ClientRegistrationOptions, + OAuthProvider, + RevocationOptions, +) + + +class BearerTokenValidatorProvider(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. + """ + + def __init__( + self, + issuer: str, + public_key: str | None = None, + jwks_uri: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + ): + """ + Initialize the provider. + + Args: + issuer: Expected issuer claim (your control plane) + public_key: RSA public key in PEM format (for static key) + jwks_uri: URI to fetch keys from (for key rotation) + audience: Expected audience claim (optional) + required_scopes: List of required scopes for access + """ + 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, + 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") + + if not kid: + raise ValueError("Token missing key ID (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) -> 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 + if ( + current_time - self._jwks_cache_time < self._cache_ttl + and kid in self._jwks_cache + ): + return self._jwks_cache[kid] + + # 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") + if key_kid: + jwk = JsonWebKey.import_key(key_data) + self._jwks_cache[key_kid] = jwk.get_public_key() + + self._jwks_cache_time = current_time + + if kid not in self._jwks_cache: + raise ValueError(f"Key ID '{kid}' not found in JWKS") + + return self._jwks_cache[kid] + + 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 + 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 + client_id = claims.get("sub") or claims.get("client_id") 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") diff --git a/src/fastmcp/server/auth/providers/__init__.py b/src/fastmcp/server/auth/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py new file mode 100644 index 000000000..3cedfee7b --- /dev/null +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -0,0 +1,359 @@ +""" +Simple JWT Bearer Token validation for hosted MCP servers. + +Uses RS256 (asymmetric) where your control plane signs with a private key +and hosted MCP servers validate with the corresponding public key. + +Example usage: +# Static public key +provider = BearerTokenValidatorProvider( + public_key='''-----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY-----''', + issuer="https://auth.yourservice.com" +) + +# Or JWKS URI (recommended for production - allows key rotation) +provider = BearerTokenValidatorProvider( + jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", + issuer="https://auth.yourservice.com" +) +""" + +import time +from dataclasses import dataclass +from typing import Any + +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, +) + + +@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, + ) -> 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 + + 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"} + + # 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. + """ + + def __init__( + self, + issuer: str | None = None, + public_key: str | None = None, + jwks_uri: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + ): + """ + Initialize the provider. + + Args: + issuer: Expected issuer claim (your control plane) + public_key: RSA public key in PEM format (for static key) + jwks_uri: URI to fetch keys from (for key rotation) + audience: Expected audience claim (optional) + required_scopes: List of required scopes for access + """ + 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 "http://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") + + if not kid: + raise ValueError("Token missing key ID (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) -> 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 + if ( + current_time - self._jwks_cache_time < self._cache_ttl + and kid in self._jwks_cache + ): + return self._jwks_cache[kid] + + # 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") + if key_kid: + jwk = JsonWebKey.import_key(key_data) + self._jwks_cache[key_kid] = jwk.get_public_key() + + self._jwks_cache_time = current_time + + if kid not in self._jwks_cache: + raise ValueError(f"Key ID '{kid}' not found in JWKS") + + return self._jwks_cache[kid] + + 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 + 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") diff --git a/src/fastmcp/server/auth/in_memory_provider.py b/src/fastmcp/server/auth/providers/in_memory.py similarity index 98% rename from src/fastmcp/server/auth/in_memory_provider.py rename to src/fastmcp/server/auth/providers/in_memory.py index 59ac0d2ad..6494ef18b 100644 --- a/src/fastmcp/server/auth/in_memory_provider.py +++ b/src/fastmcp/server/auth/providers/in_memory.py @@ -1,3 +1,8 @@ +""" +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 @@ -43,7 +48,7 @@ class InMemoryOAuthProvider(OAuthProvider): required_scopes: list[str] | None = None, ): super().__init__( - issuer_url or "https://example.com", + 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, diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index e2d279dc5..572af5282 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -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 --- diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py new file mode 100644 index 000000000..8f81d4339 --- /dev/null +++ b/tests/auth/providers/test_bearer.py @@ -0,0 +1,366 @@ +from collections.abc import Generator + +import httpx +import pytest + +from fastmcp import Client, FastMCP +from fastmcp.client.auth import BearerAuth +from fastmcp.server.auth.providers.bearer import BearerAuthProvider, 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, **kwargs) -> str: + mcp = FastMCP( + auth=BearerAuthProvider( + issuer="https://test.example.com", + public_key=public_key, + ) + ) + + @mcp.tool() + def add(a: int, b: int) -> int: + return a + b + + mcp.run(host=host, port=port, **kwargs) + + +@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, 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.get_secret_value() + + 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 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", + ) + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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"} + + @pytest.mark.asyncio + 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"} + + @pytest.mark.asyncio + 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 == [] + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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 + + @pytest.mark.asyncio + 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, match="401"): + async with Client(mcp_server_url) as client: + await client.ping() + + async def test_authorized_access(self, mcp_server_url: str, bearer_token): + async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client: + await client.ping() diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index 5d668c6dc..71db7fe47 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -9,7 +9,7 @@ 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.in_memory_provider import InMemoryOAuthProvider as InMemory +from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process @@ -18,7 +18,7 @@ def fastmcp_server(issuer_url: str): """Create a FastMCP server with OAuth authentication.""" server = FastMCP( "TestServer", - auth=InMemory( + auth=InMemoryOAuthProvider( issuer_url=issuer_url, client_registration_options=ClientRegistrationOptions(enabled=True), ), diff --git a/uv.lock b/uv.lock index ae92d334e..e1cd22726 100644 --- a/uv.lock +++ b/uv.lock @@ -449,6 +449,7 @@ dev = [ { 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" }, @@ -482,6 +483,7 @@ 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" }, @@ -998,6 +1000,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" From ed94a2ae8977f29cef9193dc7fb59c6c17a2d1c0 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 11:57:44 -0400 Subject: [PATCH 32/38] Update test_bearer.py --- tests/auth/providers/test_bearer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index 8f81d4339..480c9a1b9 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -32,7 +32,7 @@ def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider: ) -def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> str: +def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> None: mcp = FastMCP( auth=BearerAuthProvider( issuer="https://test.example.com", @@ -65,7 +65,7 @@ class TestRSAKeyPair: # Check that keys are in PEM format private_pem = key_pair.private_key.get_secret_value() - public_pem = key_pair.public_key.get_secret_value() + public_pem = key_pair.public_key assert "-----BEGIN PRIVATE KEY-----" in private_pem assert "-----END PRIVATE KEY-----" in private_pem From da9c51e13289f406fb2220caa3d7d746b60d355a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 12:09:30 -0400 Subject: [PATCH 33/38] Add tests; update default issuer --- src/fastmcp/server/auth/providers/bearer.py | 2 +- tests/auth/providers/test_bearer.py | 54 ++++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index 3cedfee7b..ccf25f0bd 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -177,7 +177,7 @@ class BearerAuthProvider(OAuthProvider): raise ValueError("Provide either public_key or jwks_uri, not both") super().__init__( - issuer_url=issuer or "http://fastmcp.example.com", + issuer_url=issuer or "https://fastmcp.example.com", client_registration_options=ClientRegistrationOptions(enabled=False), revocation_options=RevocationOptions(enabled=False), required_scopes=required_scopes, diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index 480c9a1b9..ccc84fe73 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from typing import Any import httpx import pytest @@ -32,11 +33,17 @@ def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider: ) -def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> None: +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( - issuer="https://test.example.com", public_key=public_key, + **auth_kwargs or {}, ) ) @@ -44,13 +51,15 @@ def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> None: def add(a: int, b: int) -> int: return a + b - mcp.run(host=host, port=port, **kwargs) + 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, transport="streamable-http" + run_mcp_server, + public_key=rsa_key_pair.public_key, + run_kwargs=dict(transport="streamable-http"), ) as url: yield f"{url}/mcp" @@ -75,7 +84,8 @@ class TestRSAKeyPair: 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" + subject="test-user", + issuer="https://test.example.com", ) assert isinstance(token, str) @@ -359,8 +369,38 @@ class TestFastMCPBearerAuth: async def test_unauthorized_access(self, mcp_server_url: str): with pytest.raises(httpx.HTTPStatusError, match="401"): async with Client(mcp_server_url) as client: - await client.ping() + tools = await client.list_tools() # noqa: F841 + 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: - await client.ping() + 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, match="401"): + async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client: + tools = await client.list_tools() # noqa: F841 + 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, match="401"): + async with Client(mcp_server_url, auth=BearerAuth(token)) as client: + tools = await client.list_tools() # noqa: F841 + 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, match="401"): + async with Client(mcp_server_url, auth=BearerAuth(token)) as client: + tools = await client.list_tools() # noqa: F841 + assert "tools" not in locals() From 172f67c6176105cc895590a4c3144d5a560391bc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 12:44:56 -0400 Subject: [PATCH 34/38] Add jwks tests --- pyproject.toml | 1 + src/fastmcp/server/auth/bearer.py | 256 ------------------- src/fastmcp/server/auth/providers/bearer.py | 75 ++++-- tests/auth/providers/test_bearer.py | 264 ++++++++++++++++++-- uv.lock | 15 ++ 5 files changed, 318 insertions(+), 293 deletions(-) delete mode 100644 src/fastmcp/server/auth/bearer.py diff --git a/pyproject.toml b/pyproject.toml index 35b538b58..a6fce4ad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "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", diff --git a/src/fastmcp/server/auth/bearer.py b/src/fastmcp/server/auth/bearer.py deleted file mode 100644 index 729fcf1dc..000000000 --- a/src/fastmcp/server/auth/bearer.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Simple JWT Bearer Token validation for hosted MCP servers. - -Uses RS256 (asymmetric) where your control plane signs with a private key -and hosted MCP servers validate with the corresponding public key. - -Example usage: -# Static public key -provider = BearerTokenValidatorProvider( - public_key='''-----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... - -----END PUBLIC KEY-----''', - issuer="https://auth.yourservice.com" -) - -# Or JWKS URI (recommended for production - allows key rotation) -provider = BearerTokenValidatorProvider( - jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", - issuer="https://auth.yourservice.com" -) -""" - -import time -from typing import Any - -import httpx -from authlib.jose import JsonWebKey, JsonWebToken -from authlib.jose.errors import JoseError -from mcp.server.auth.provider import ( - AccessToken, - AuthorizationCode, - AuthorizationParams, - RefreshToken, -) -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthToken, -) - -from fastmcp.server.auth.auth import ( - ClientRegistrationOptions, - OAuthProvider, - RevocationOptions, -) - - -class BearerTokenValidatorProvider(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. - """ - - def __init__( - self, - issuer: str, - public_key: str | None = None, - jwks_uri: str | None = None, - audience: str | None = None, - required_scopes: list[str] | None = None, - ): - """ - Initialize the provider. - - Args: - issuer: Expected issuer claim (your control plane) - public_key: RSA public key in PEM format (for static key) - jwks_uri: URI to fetch keys from (for key rotation) - audience: Expected audience claim (optional) - required_scopes: List of required scopes for access - """ - 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, - 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") - - if not kid: - raise ValueError("Token missing key ID (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) -> 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 - if ( - current_time - self._jwks_cache_time < self._cache_ttl - and kid in self._jwks_cache - ): - return self._jwks_cache[kid] - - # 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") - if key_kid: - jwk = JsonWebKey.import_key(key_data) - self._jwks_cache[key_kid] = jwk.get_public_key() - - self._jwks_cache_time = current_time - - if kid not in self._jwks_cache: - raise ValueError(f"Key ID '{kid}' not found in JWKS") - - return self._jwks_cache[kid] - - 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 - 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 - client_id = claims.get("sub") or claims.get("client_id") 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") diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index ccf25f0bd..419ed255c 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -6,7 +6,7 @@ and hosted MCP servers validate with the corresponding public key. Example usage: # Static public key -provider = BearerTokenValidatorProvider( +provider = BearerAuthProvider( public_key='''-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----''', @@ -14,7 +14,7 @@ provider = BearerTokenValidatorProvider( ) # Or JWKS URI (recommended for production - allows key rotation) -provider = BearerTokenValidatorProvider( +provider = Bear( jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", issuer="https://auth.yourservice.com" ) @@ -22,7 +22,7 @@ provider = BearerTokenValidatorProvider( import time from dataclasses import dataclass -from typing import Any +from typing import Any, TypedDict import httpx from authlib.jose import JsonWebKey, JsonWebToken @@ -48,6 +48,25 @@ from fastmcp.server.auth.auth import ( ) +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 @@ -96,6 +115,7 @@ class RSAKeyPair: 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. @@ -108,6 +128,7 @@ class RSAKeyPair: 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 @@ -135,6 +156,8 @@ class RSAKeyPair: # Create header header = {"alg": "RS256"} + if kid: + header["kid"] = kid # Sign and return token token_bytes = jwt.encode( @@ -209,27 +232,25 @@ class BearerAuthProvider(OAuthProvider): header = json.loads(base64.urlsafe_b64decode(header_b64)) kid = header.get("kid") - if not kid: - raise ValueError("Token missing key ID (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) -> str: + 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 - if ( - current_time - self._jwks_cache_time < self._cache_ttl - and kid in self._jwks_cache - ): - return self._jwks_cache[kid] + # 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: @@ -242,16 +263,32 @@ class BearerAuthProvider(OAuthProvider): 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() + if key_kid: - jwk = JsonWebKey.import_key(key_data) - self._jwks_cache[key_kid] = jwk.get_public_key() + 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 - if kid not in self._jwks_cache: - raise ValueError(f"Key ID '{kid}' not found in JWKS") - - return self._jwks_cache[kid] + # 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}") diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index ccc84fe73..a54f4d416 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -3,10 +3,15 @@ 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, RSAKeyPair +from fastmcp.server.auth.providers.bearer import ( + BearerAuthProvider, + JWKSData, + RSAKeyPair, +) from fastmcp.utilities.tests import run_server_in_process @@ -103,6 +108,194 @@ class TestRSAKeyPair: # 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) + jwk_data = jwk.as_dict() + 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.""" @@ -143,7 +336,6 @@ class TestBearerToken: issuer="https://test.example.com", ) - @pytest.mark.asyncio async def test_valid_token_validation( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -163,7 +355,6 @@ class TestBearerToken: assert "write" in access_token.scopes assert access_token.expires_at is not None - @pytest.mark.asyncio async def test_expired_token_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -178,7 +369,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_invalid_issuer_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -192,7 +382,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_invalid_audience_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -206,7 +395,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio 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( @@ -221,7 +409,6 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - @pytest.mark.asyncio 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( @@ -239,7 +426,6 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - @pytest.mark.asyncio async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair): """Test validation with multiple audiences in token.""" provider = BearerAuthProvider( @@ -259,7 +445,6 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - @pytest.mark.asyncio async def test_scope_extraction_string( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -276,7 +461,6 @@ class TestBearerToken: assert access_token is not None assert set(access_token.scopes) == {"read", "write", "admin"} - @pytest.mark.asyncio async def test_scope_extraction_list( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -293,7 +477,6 @@ class TestBearerToken: assert access_token is not None assert set(access_token.scopes) == {"read", "write"} - @pytest.mark.asyncio async def test_no_scopes( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -310,7 +493,6 @@ class TestBearerToken: assert access_token is not None assert access_token.scopes == [] - @pytest.mark.asyncio async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider): """Test rejection of malformed tokens.""" malformed_tokens = [ @@ -325,7 +507,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_invalid_signature_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -341,7 +522,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_client_id_fallback( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -367,9 +547,10 @@ class TestFastMCPBearerAuth: assert isinstance(mcp.auth, BearerAuthProvider) async def test_unauthorized_access(self, mcp_server_url: str): - with pytest.raises(httpx.HTTPStatusError, match="401"): + 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): @@ -378,9 +559,10 @@ class TestFastMCPBearerAuth: assert tools async def test_invalid_token_raises_401(self, mcp_server_url: str): - with pytest.raises(httpx.HTTPStatusError, match="401"): + 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): @@ -391,16 +573,62 @@ class TestFastMCPBearerAuth: expires_in_seconds=-3600, ) - with pytest.raises(httpx.HTTPStatusError, match="401"): + 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, match="401"): + 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 diff --git a/uv.lock b/uv.lock index e1cd22726..5097b2bcc 100644 --- a/uv.lock +++ b/uv.lock @@ -456,6 +456,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-flakefinder" }, + { name = "pytest-httpx" }, { name = "pytest-report" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, @@ -490,6 +491,7 @@ dev = [ { 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" }, @@ -1160,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" From c6d168ac2072fd9024ad00777b827dd045a54590 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 12:49:33 -0400 Subject: [PATCH 35/38] update typing --- src/fastmcp/server/auth/providers/bearer.py | 2 +- tests/auth/providers/test_bearer.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index 419ed255c..9c7c34512 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -264,7 +264,7 @@ class BearerAuthProvider(OAuthProvider): 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() + public_key = jwk.get_public_key() # type: ignore if key_kid: self._jwks_cache[key_kid] = public_key diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index a54f4d416..ff127f727 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -9,6 +9,7 @@ from fastmcp import Client, FastMCP from fastmcp.client.auth import BearerAuth from fastmcp.server.auth.providers.bearer import ( BearerAuthProvider, + JWKData, JWKSData, RSAKeyPair, ) @@ -126,8 +127,8 @@ class TestBearerTokenJWKS: from authlib.jose import JsonWebKey # Create JWK from the RSA public key - jwk = JsonWebKey.import_key(rsa_key_pair.public_key) - jwk_data = jwk.as_dict() + 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" From db18baa24fa20e49b223a6735d31e9bab10c8354 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:24:45 -0400 Subject: [PATCH 36/38] Support configuring bearer auth from env vars --- src/fastmcp/server/auth/auth.py | 2 +- src/fastmcp/server/auth/providers/bearer.py | 34 ++------ .../server/auth/providers/bearer_env.py | 54 ++++++++++++ src/fastmcp/server/http.py | 10 +-- src/fastmcp/server/server.py | 3 + src/fastmcp/settings.py | 8 +- tests/auth/providers/test_bearer_env.py | 83 +++++++++++++++++++ 7 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 src/fastmcp/server/auth/providers/bearer_env.py create mode 100644 tests/auth/providers/test_bearer_env.py diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index b5f07c523..d4ae8c821 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -39,7 +39,7 @@ class OAuthProvider( if isinstance(service_documentation_url, str): service_documentation_url = AnyHttpUrl(service_documentation_url) - self.settings = AuthSettings( + self.auth_settings = AuthSettings( issuer_url=issuer_url, service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index 9c7c34512..a463817e6 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -1,25 +1,3 @@ -""" -Simple JWT Bearer Token validation for hosted MCP servers. - -Uses RS256 (asymmetric) where your control plane signs with a private key -and hosted MCP servers validate with the corresponding public key. - -Example usage: -# Static public key -provider = BearerAuthProvider( - public_key='''-----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... - -----END PUBLIC KEY-----''', - issuer="https://auth.yourservice.com" -) - -# Or JWKS URI (recommended for production - allows key rotation) -provider = Bear( - jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", - issuer="https://auth.yourservice.com" -) -""" - import time from dataclasses import dataclass from typing import Any, TypedDict @@ -165,7 +143,6 @@ class RSAKeyPair: payload, key=self.private_key.get_secret_value(), ) - return token_bytes.decode("utf-8") @@ -174,25 +151,28 @@ 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, - issuer: str | None = None, 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. + Initialize the provider. Either public_key or jwks_uri must be provided. Args: - issuer: Expected issuer claim (your control plane) 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 + 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") diff --git a/src/fastmcp/server/auth/providers/bearer_env.py b/src/fastmcp/server/auth/providers/bearer_env.py new file mode 100644 index 000000000..63eb8c725 --- /dev/null +++ b/src/fastmcp/server/auth/providers/bearer_env.py @@ -0,0 +1,54 @@ +from enum import Enum + +from pydantic_settings import BaseSettings, SettingsConfigDict + +from fastmcp.server.auth.providers.bearer import BearerAuthProvider + + +class NotSet(Enum): + sentinel = 0 + + +NOTSET = NotSet.sentinel + + +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. + """ + + def __init__( + self, + public_key: str | None | NotSet = NOTSET, + jwks_uri: str | None | NotSet = NOTSET, + issuer: str | None | NotSet = NOTSET, + audience: str | None | NotSet = NOTSET, + required_scopes: list[str] | None | NotSet = NOTSET, + ): + 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()) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index d0501431f..d9530adb1 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -91,15 +91,15 @@ def setup_auth_middleware_and_routes( Middleware(AuthContextMiddleware), ] - required_scopes = auth.settings.required_scopes or [] + required_scopes = auth.auth_settings.required_scopes or [] auth_routes.extend( create_auth_routes( provider=auth, - 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, + issuer_url=auth.auth_settings.issuer_url, + service_documentation_url=auth.auth_settings.service_documentation_url, + client_registration_options=auth.auth_settings.client_registration_options, + revocation_options=auth.auth_settings.revocation_options, ) ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 5af2b596d..fe95ec6e1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -48,6 +48,7 @@ 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, @@ -186,6 +187,8 @@ class FastMCP(Generic[LifespanResultT]): lifespan=_lifespan_wrapper(self, lifespan), ) + if auth is None and self.settings.auth_provider == "bearer_env": + auth = EnvBearerAuthProvider() self.auth = auth if tools: diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 405dc7ce8..6a2075b91 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -5,7 +5,10 @@ from pathlib import Path from typing import Annotated, Literal from pydantic import Field, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import ( + BaseSettings, + SettingsConfigDict, +) from typing_extensions import Self LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] @@ -176,5 +179,8 @@ class ServerSettings(BaseSettings): False # If True, uses true stateless mode (new transport per request) ) + # Auth settings + auth_provider: Literal["bearer_env"] | None = None + settings = Settings() diff --git a/tests/auth/providers/test_bearer_env.py b/tests/auth/providers/test_bearer_env.py new file mode 100644 index 000000000..7c81ba737 --- /dev/null +++ b/tests/auth/providers/test_bearer_env.py @@ -0,0 +1,83 @@ +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_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_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_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 == "http://test-issuer" + assert mcp.auth.auth_settings.issuer_url == AnyHttpUrl("http://test-issuer") + assert mcp.auth.audience == "test-audience" + assert mcp.auth.auth_settings.required_scopes == ["test-scope1", "test-scope2"] + + +def test_list_of_scopes_must_be_a_list(monkeypatch): + monkeypatch.setenv("FASTMCP_SERVER_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_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_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_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" From 0824b3783d5edf697a4f747b4bb67d5eca6921ec Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:37:50 -0400 Subject: [PATCH 37/38] Clean up settings --- .../server/auth/providers/bearer_env.py | 21 +++++++------------ src/fastmcp/server/server.py | 2 +- src/fastmcp/settings.py | 18 +++++++++++++++- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer_env.py b/src/fastmcp/server/auth/providers/bearer_env.py index 63eb8c725..ff02af62f 100644 --- a/src/fastmcp/server/auth/providers/bearer_env.py +++ b/src/fastmcp/server/auth/providers/bearer_env.py @@ -1,15 +1,10 @@ -from enum import Enum - from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.providers.bearer import BearerAuthProvider -class NotSet(Enum): - sentinel = 0 - - -NOTSET = NotSet.sentinel +class NotSet: + pass class EnvBearerAuthProviderSettings(BaseSettings): @@ -35,11 +30,11 @@ class EnvBearerAuthProvider(BearerAuthProvider): def __init__( self, - public_key: str | None | NotSet = NOTSET, - jwks_uri: str | None | NotSet = NOTSET, - issuer: str | None | NotSet = NOTSET, - audience: str | None | NotSet = NOTSET, - required_scopes: list[str] | None | NotSet = NOTSET, + 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, ): kwargs = { "public_key": public_key, @@ -49,6 +44,6 @@ class EnvBearerAuthProvider(BearerAuthProvider): "required_scopes": required_scopes, } settings = EnvBearerAuthProviderSettings( - **{k: v for k, v in kwargs.items() if v is not NOTSET} + **{k: v for k, v in kwargs.items() if v is not NotSet} ) super().__init__(**settings.model_dump()) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index fe95ec6e1..a092ed3b8 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -187,7 +187,7 @@ class FastMCP(Generic[LifespanResultT]): lifespan=_lifespan_wrapper(self, lifespan), ) - if auth is None and self.settings.auth_provider == "bearer_env": + if auth is None and self.settings.default_auth_provider == "bearer_env": auth = EnvBearerAuthProvider() self.auth = auth diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 6a2075b91..96939d11c 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -180,7 +180,23 @@ class ServerSettings(BaseSettings): ) # Auth settings - auth_provider: Literal["bearer_env"] | None = None + 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() From 0d8bb8cdd638d2fef3e360d974224a7b38f43659 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:45:31 -0400 Subject: [PATCH 38/38] flatten auth settings --- src/fastmcp/server/auth/auth.py | 13 ++++----- src/fastmcp/server/auth/providers/bearer.py | 3 +- .../server/auth/providers/bearer_env.py | 29 ++++++++++++++----- src/fastmcp/server/http.py | 10 +++---- tests/auth/providers/test_bearer_env.py | 19 ++++++------ 5 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index d4ae8c821..42d2919b8 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -5,7 +5,6 @@ from mcp.server.auth.provider import ( RefreshToken, ) from mcp.server.auth.settings import ( - AuthSettings, ClientRegistrationOptions, RevocationOptions, ) @@ -39,10 +38,8 @@ class OAuthProvider( if isinstance(service_documentation_url, str): service_documentation_url = AnyHttpUrl(service_documentation_url) - self.auth_settings = AuthSettings( - issuer_url=issuer_url, - service_documentation_url=service_documentation_url, - client_registration_options=client_registration_options, - revocation_options=revocation_options, - required_scopes=required_scopes, - ) + 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 diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index a463817e6..763f90f4f 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -295,7 +295,8 @@ class BearerAuthProvider(OAuthProvider): if exp and exp < time.time(): return None - # Validate issuer + # 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 diff --git a/src/fastmcp/server/auth/providers/bearer_env.py b/src/fastmcp/server/auth/providers/bearer_env.py index ff02af62f..96cf15cfa 100644 --- a/src/fastmcp/server/auth/providers/bearer_env.py +++ b/src/fastmcp/server/auth/providers/bearer_env.py @@ -3,7 +3,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.providers.bearer import BearerAuthProvider -class NotSet: +# Sentinel object to indicate that a setting is not set +class _NotSet: pass @@ -25,17 +26,29 @@ class EnvBearerAuthProviderSettings(BaseSettings): class EnvBearerAuthProvider(BearerAuthProvider): """ - A BearerAuthProvider that loads settings from environment variables. + 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, + 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, @@ -44,6 +57,6 @@ class EnvBearerAuthProvider(BearerAuthProvider): "required_scopes": required_scopes, } settings = EnvBearerAuthProviderSettings( - **{k: v for k, v in kwargs.items() if v is not NotSet} + **{k: v for k, v in kwargs.items() if v is not _NotSet} ) super().__init__(**settings.model_dump()) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index d9530adb1..2b5381437 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -91,15 +91,15 @@ def setup_auth_middleware_and_routes( Middleware(AuthContextMiddleware), ] - required_scopes = auth.auth_settings.required_scopes or [] + required_scopes = auth.required_scopes or [] auth_routes.extend( create_auth_routes( provider=auth, - issuer_url=auth.auth_settings.issuer_url, - service_documentation_url=auth.auth_settings.service_documentation_url, - client_registration_options=auth.auth_settings.client_registration_options, - revocation_options=auth.auth_settings.revocation_options, + issuer_url=auth.issuer_url, + service_documentation_url=auth.service_documentation_url, + client_registration_options=auth.client_registration_options, + revocation_options=auth.revocation_options, ) ) diff --git a/tests/auth/providers/test_bearer_env.py b/tests/auth/providers/test_bearer_env.py index 7c81ba737..cadf7075a 100644 --- a/tests/auth/providers/test_bearer_env.py +++ b/tests/auth/providers/test_bearer_env.py @@ -10,7 +10,7 @@ def test_load_bearer_env_from_env_var(monkeypatch): mcp = FastMCP() assert mcp.auth is None - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") mcp_with_auth = FastMCP() @@ -21,7 +21,7 @@ def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatc mcp = FastMCP() assert mcp.auth is None - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") with pytest.raises( ValueError, match="Either public_key or jwks_uri must be provided" @@ -30,7 +30,7 @@ def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatc def test_configure_bearer_env_from_env_var(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + 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") @@ -41,14 +41,13 @@ def test_configure_bearer_env_from_env_var(monkeypatch): mcp = FastMCP() assert isinstance(mcp.auth, EnvBearerAuthProvider) assert mcp.auth.public_key == "test-public-key" - assert mcp.auth.issuer == "http://test-issuer" - assert mcp.auth.auth_settings.issuer_url == AnyHttpUrl("http://test-issuer") + assert mcp.auth.issuer_url == AnyHttpUrl("http://test-issuer") assert mcp.auth.audience == "test-audience" - assert mcp.auth.auth_settings.required_scopes == ["test-scope1", "test-scope2"] + assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"] def test_list_of_scopes_must_be_a_list(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + 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"): @@ -56,7 +55,7 @@ def test_list_of_scopes_must_be_a_list(monkeypatch): def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri") mcp = FastMCP() @@ -65,7 +64,7 @@ def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch): def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + 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") @@ -74,7 +73,7 @@ def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch): def test_provided_auth_takes_precedence_over_env_vars(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + 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"))