mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
Switch to DiskStore KV implementation
This commit is contained in:
parent
a41600d838
commit
dee2bb51d9
16 changed files with 369 additions and 813 deletions
|
|
@ -15,6 +15,7 @@ dependencies = [
|
|||
"pydantic[email]>=2.11.7",
|
||||
"pyperclip>=1.9.0",
|
||||
"openapi-core>=0.19.5",
|
||||
"kv-store-adapter[disk,memory]>=0.1.1",
|
||||
]
|
||||
|
||||
requires-python = ">=3.10"
|
||||
|
|
@ -117,7 +118,7 @@ testpaths = ["tests"]
|
|||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = ["--inline-snapshot=disable"]
|
||||
addopts = ["--inline-snapshot=fix,create"]
|
||||
|
||||
[tool.ty.src]
|
||||
include = ["src", "tests"]
|
||||
|
|
|
|||
|
|
@ -1,34 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import webbrowser
|
||||
from asyncio import Future
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
from kv_store_adapter.adapters.pydantic import PydanticAdapter
|
||||
from kv_store_adapter.stores.disk import DiskStore
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthToken,
|
||||
)
|
||||
from mcp.shared.auth import (
|
||||
OAuthToken as OAuthToken,
|
||||
)
|
||||
from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError
|
||||
from pydantic import AnyHttpUrl, BaseModel
|
||||
from uvicorn.server import Server
|
||||
|
||||
from fastmcp import settings as fastmcp_global_settings
|
||||
from fastmcp import settings
|
||||
from fastmcp.client.oauth_callback import (
|
||||
create_oauth_callback_server,
|
||||
)
|
||||
from fastmcp.utilities.http import find_available_port
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import JSONFileStorage
|
||||
|
||||
__all__ = ["OAuth"]
|
||||
|
||||
|
|
@ -41,174 +42,6 @@ class ClientNotFoundError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class StoredToken(BaseModel):
|
||||
"""Token storage format with absolute expiry time."""
|
||||
|
||||
token_payload: OAuthToken
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
# Create TypeAdapter at module level for efficient parsing
|
||||
stored_token_adapter = TypeAdapter(StoredToken)
|
||||
|
||||
|
||||
def default_cache_dir() -> Path:
|
||||
return fastmcp_global_settings.home / "oauth-mcp-client-cache"
|
||||
|
||||
|
||||
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.
|
||||
Uses JSONFileStorage internally for consistent file handling.
|
||||
"""
|
||||
|
||||
def __init__(self, server_url: str, cache_dir: Path | None = None):
|
||||
"""Initialize storage for a specific server URL."""
|
||||
self.server_url = server_url
|
||||
# Use JSONFileStorage for actual file operations
|
||||
self._storage = JSONFileStorage(cache_dir or default_cache_dir())
|
||||
|
||||
@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_storage_key(self, file_type: Literal["client_info", "tokens"]) -> str:
|
||||
"""Get the storage key for the specified data type.
|
||||
|
||||
JSONFileStorage will handle making the key filesystem-safe.
|
||||
"""
|
||||
base_url = self.get_base_url(self.server_url)
|
||||
return f"{base_url}_{file_type}"
|
||||
|
||||
def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
|
||||
"""Get the file path for the specified cache file type.
|
||||
|
||||
This method is kept for backward compatibility with tests that access _get_file_path.
|
||||
"""
|
||||
key = self._get_storage_key(file_type)
|
||||
return self._storage._get_file_path(key)
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
"""Load tokens from file storage."""
|
||||
key = self._get_storage_key("tokens")
|
||||
data = await self._storage.get(key)
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Parse and validate as StoredToken
|
||||
stored = stored_token_adapter.validate_python(data)
|
||||
|
||||
# Check if token is expired
|
||||
if stored.expires_at is not None:
|
||||
now = datetime.now(timezone.utc)
|
||||
if now >= stored.expires_at:
|
||||
logger.debug(
|
||||
f"Token expired for {self.get_base_url(self.server_url)}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Recalculate expires_in to be correct relative to now
|
||||
if stored.token_payload.expires_in is not None:
|
||||
remaining = stored.expires_at - now
|
||||
stored.token_payload.expires_in = max(
|
||||
0, int(remaining.total_seconds())
|
||||
)
|
||||
|
||||
return stored.token_payload
|
||||
|
||||
except ValidationError as e:
|
||||
logger.debug(
|
||||
f"Could not validate 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."""
|
||||
key = self._get_storage_key("tokens")
|
||||
|
||||
# Calculate absolute expiry time if expires_in is present
|
||||
expires_at = None
|
||||
if tokens.expires_in is not None:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
seconds=tokens.expires_in
|
||||
)
|
||||
|
||||
# Create StoredToken and save using storage
|
||||
# Note: JSONFileStorage will wrap this in {"data": ..., "timestamp": ...}
|
||||
stored = StoredToken(token_payload=tokens, expires_at=expires_at)
|
||||
await self._storage.set(key, stored.model_dump(mode="json"))
|
||||
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."""
|
||||
key = self._get_storage_key("client_info")
|
||||
data = await self._storage.get(key)
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
client_info = OAuthClientInformationFull.model_validate(data)
|
||||
# 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
|
||||
await self._storage.delete(key)
|
||||
return None
|
||||
|
||||
return client_info
|
||||
except ValidationError as e:
|
||||
logger.debug(
|
||||
f"Could not validate 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."""
|
||||
key = self._get_storage_key("client_info")
|
||||
await self._storage.set(key, client_info.model_dump(mode="json"))
|
||||
logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached data for this server.
|
||||
|
||||
Note: This is a synchronous method for backward compatibility.
|
||||
Uses direct file operations instead of async storage methods.
|
||||
"""
|
||||
file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
|
||||
for file_type in file_types:
|
||||
# Use the file path directly for synchronous deletion
|
||||
path = self._get_file_path(file_type)
|
||||
path.unlink(missing_ok=True)
|
||||
logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
|
||||
|
||||
@classmethod
|
||||
def clear_all(cls, cache_dir: Path | None = None) -> None:
|
||||
"""Clear all cached data for all servers."""
|
||||
cache_dir = cache_dir or default_cache_dir()
|
||||
if not cache_dir.exists():
|
||||
return
|
||||
|
||||
file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
|
||||
for file_type in file_types:
|
||||
for file in cache_dir.glob(f"*_{file_type}.json"):
|
||||
file.unlink(missing_ok=True)
|
||||
logger.info("Cleared all OAuth client cache data.")
|
||||
|
||||
|
||||
async def check_if_auth_required(
|
||||
mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
|
||||
) -> bool:
|
||||
|
|
@ -239,6 +72,68 @@ async def check_if_auth_required(
|
|||
return True
|
||||
|
||||
|
||||
class TokenStorageAdapter(TokenStorage):
|
||||
_server_url: str
|
||||
_kv_store_protocol: KVStoreProtocol
|
||||
_storage_oauth_token: PydanticAdapter[OAuthToken]
|
||||
_storage_client_info: PydanticAdapter[OAuthClientInformationFull]
|
||||
|
||||
def __init__(self, kv_store_protocol: KVStoreProtocol, server_url: str):
|
||||
self._server_url = server_url
|
||||
self._kv_store_protocol = kv_store_protocol
|
||||
self._storage_oauth_token = PydanticAdapter[OAuthToken](
|
||||
store_protocol=kv_store_protocol, pydantic_model=OAuthToken
|
||||
)
|
||||
self._storage_client_info = PydanticAdapter[OAuthClientInformationFull](
|
||||
store_protocol=kv_store_protocol, pydantic_model=OAuthClientInformationFull
|
||||
)
|
||||
|
||||
def _get_token_cache_key(self) -> str:
|
||||
return f"{self._server_url}/tokens"
|
||||
|
||||
def _get_client_info_cache_key(self) -> str:
|
||||
return f"{self._server_url}/client_info"
|
||||
|
||||
async def clear(self) -> None:
|
||||
await self._storage_oauth_token.delete(
|
||||
collection="oauth-mcp-client-cache", key=self._get_token_cache_key()
|
||||
)
|
||||
await self._storage_client_info.delete(
|
||||
collection="oauth-mcp-client-cache", key=self._get_client_info_cache_key()
|
||||
)
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return await self._storage_oauth_token.get(
|
||||
collection="oauth-mcp-client-cache", key=self._get_token_cache_key()
|
||||
)
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
await self._storage_oauth_token.put(
|
||||
collection="oauth-mcp-client-cache",
|
||||
key=self._get_token_cache_key(),
|
||||
value=tokens,
|
||||
ttl=tokens.expires_in,
|
||||
)
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return await self._storage_client_info.get(
|
||||
collection="oauth-mcp-client-cache", key=self._get_client_info_cache_key()
|
||||
)
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
ttl: int | None = None
|
||||
|
||||
if client_info.client_secret_expires_at:
|
||||
ttl = client_info.client_secret_expires_at - int(time.time())
|
||||
|
||||
await self._storage_client_info.put(
|
||||
collection="oauth-mcp-client-cache",
|
||||
key=self._get_client_info_cache_key(),
|
||||
value=client_info,
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
|
||||
class OAuth(OAuthClientProvider):
|
||||
"""
|
||||
OAuth client provider for MCP servers with browser-based authentication.
|
||||
|
|
@ -252,7 +147,7 @@ class OAuth(OAuthClientProvider):
|
|||
mcp_url: str,
|
||||
scopes: str | list[str] | None = None,
|
||||
client_name: str = "FastMCP Client",
|
||||
token_storage_cache_dir: Path | None = None,
|
||||
token_storage: KVStoreProtocol | None = None,
|
||||
additional_client_metadata: dict[str, Any] | None = None,
|
||||
callback_port: int | None = None,
|
||||
):
|
||||
|
|
@ -264,7 +159,7 @@ class OAuth(OAuthClientProvider):
|
|||
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
|
||||
token_storage: KVStoreProtocol for token storage, the default disk store is used if not provided
|
||||
additional_client_metadata: Extra fields for OAuthClientMetadata
|
||||
callback_port: Fixed port for OAuth callback (default: random available port)
|
||||
"""
|
||||
|
|
@ -294,8 +189,10 @@ class OAuth(OAuthClientProvider):
|
|||
)
|
||||
|
||||
# Create server-specific token storage
|
||||
storage = FileTokenStorage(
|
||||
server_url=server_base_url, cache_dir=token_storage_cache_dir
|
||||
token_storage = token_storage or settings.data_store
|
||||
|
||||
self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter(
|
||||
kv_store_protocol=token_storage, server_url=server_base_url
|
||||
)
|
||||
|
||||
# Store server_base_url for use in callback_handler
|
||||
|
|
@ -305,7 +202,7 @@ class OAuth(OAuthClientProvider):
|
|||
super().__init__(
|
||||
server_url=server_base_url,
|
||||
client_metadata=client_metadata,
|
||||
storage=storage,
|
||||
storage=self.token_storage_adapter,
|
||||
redirect_handler=self.redirect_handler,
|
||||
callback_handler=self.callback_handler,
|
||||
)
|
||||
|
|
@ -399,23 +296,7 @@ class OAuth(OAuthClientProvider):
|
|||
|
||||
# Clear cached state and retry once
|
||||
self._initialized = False
|
||||
|
||||
# Try to clear storage if it supports it
|
||||
if hasattr(self.context.storage, "clear"):
|
||||
try:
|
||||
self.context.storage.clear()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clear OAuth storage cache: {e}")
|
||||
# Can't retry without clearing cache, re-raise original error
|
||||
raise ClientNotFoundError(
|
||||
"OAuth client not found and cache could not be cleared"
|
||||
) from e
|
||||
else:
|
||||
logger.warning(
|
||||
"Storage does not support clear() - cannot retry with fresh credentials"
|
||||
)
|
||||
# Can't retry without clearing cache, re-raise original error
|
||||
raise
|
||||
await self.token_storage_adapter.clear()
|
||||
|
||||
gen = super().async_auth_flow(request)
|
||||
response = None
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ from urllib.parse import urlencode
|
|||
import httpx
|
||||
from authlib.common.security import generate_token
|
||||
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
||||
from kv_store_adapter.adapters.pydantic import PydanticAdapter
|
||||
from kv_store_adapter.stores.disk import DiskStore
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
|
|
@ -40,16 +43,15 @@ from mcp.server.auth.settings import (
|
|||
RevocationOptions,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
from pydantic import AnyHttpUrl, AnyUrl, SecretStr
|
||||
from pydantic import AnyHttpUrl, AnyUrl, Field, SecretStr
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
import fastmcp
|
||||
from fastmcp import settings
|
||||
from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.redirect_validation import validate_redirect_uri
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import JSONFileStorage, KVStorage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
|
@ -83,18 +85,7 @@ class ProxyDCRClient(OAuthClientInformationFull):
|
|||
arise from accepting arbitrary redirect URIs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *args, allowed_redirect_uri_patterns: list[str] | None = None, **kwargs
|
||||
):
|
||||
"""Initialize with allowed redirect URI patterns.
|
||||
|
||||
Args:
|
||||
allowed_redirect_uri_patterns: List of allowed redirect URI patterns with wildcard support.
|
||||
If None, defaults to localhost-only patterns.
|
||||
If empty list, allows all redirect URIs.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._allowed_redirect_uri_patterns = allowed_redirect_uri_patterns
|
||||
allowed_redirect_uri_patterns: list[str] | None = Field(default=None)
|
||||
|
||||
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
|
||||
"""Validate redirect URI against allowed patterns.
|
||||
|
|
@ -106,7 +97,10 @@ class ProxyDCRClient(OAuthClientInformationFull):
|
|||
"""
|
||||
if redirect_uri is not None:
|
||||
# Validate against allowed patterns
|
||||
if validate_redirect_uri(redirect_uri, self._allowed_redirect_uri_patterns):
|
||||
if validate_redirect_uri(
|
||||
redirect_uri=redirect_uri,
|
||||
allowed_patterns=self.allowed_redirect_uri_patterns,
|
||||
):
|
||||
return redirect_uri
|
||||
# Fall back to normal validation if not in allowed patterns
|
||||
return super().validate_redirect_uri(redirect_uri)
|
||||
|
|
@ -257,7 +251,7 @@ class OAuthProxy(OAuthProvider):
|
|||
# Extra parameters to forward to token endpoint
|
||||
extra_token_params: dict[str, str] | None = None,
|
||||
# Client storage
|
||||
client_storage: KVStorage | None = None,
|
||||
client_storage: KVStoreProtocol | None = None,
|
||||
):
|
||||
"""Initialize the OAuth proxy provider.
|
||||
|
||||
|
|
@ -292,8 +286,6 @@ class OAuthProxy(OAuthProvider):
|
|||
extra_token_params: Additional parameters to forward to the upstream token endpoint.
|
||||
Useful for provider-specific parameters during token exchange.
|
||||
client_storage: Storage implementation for OAuth client registrations.
|
||||
Defaults to file-based storage in ~/.fastmcp/oauth-proxy-clients/ if not specified.
|
||||
Pass any KVStorage implementation for custom storage backends.
|
||||
"""
|
||||
# Always enable DCR since we implement it locally for MCP clients
|
||||
client_registration_options = ClientRegistrationOptions(
|
||||
|
|
@ -342,11 +334,12 @@ class OAuthProxy(OAuthProvider):
|
|||
self._extra_authorize_params = extra_authorize_params or {}
|
||||
self._extra_token_params = extra_token_params or {}
|
||||
|
||||
# Initialize client storage (default to file-based if not provided)
|
||||
if client_storage is None:
|
||||
cache_dir = fastmcp.settings.home / "oauth-proxy-clients"
|
||||
client_storage = JSONFileStorage(cache_dir)
|
||||
self._client_storage = client_storage
|
||||
self._client_storage: KVStoreProtocol = client_storage or settings.data_store
|
||||
self._client_storage_collection = "oauth-proxy-clients"
|
||||
|
||||
self._client_storage_adapter: PydanticAdapter[ProxyDCRClient] = PydanticAdapter[
|
||||
ProxyDCRClient
|
||||
](store_protocol=self._client_storage, pydantic_model=ProxyDCRClient)
|
||||
|
||||
# Local state for token bookkeeping only (no client caching)
|
||||
self._access_tokens: dict[str, AccessToken] = {}
|
||||
|
|
@ -400,19 +393,17 @@ class OAuthProxy(OAuthProvider):
|
|||
For unregistered clients, returns None (which will raise an error in the SDK).
|
||||
"""
|
||||
# Load from storage
|
||||
data = await self._client_storage.get(client_id)
|
||||
if not data:
|
||||
if not (
|
||||
client := await self._client_storage_adapter.get(
|
||||
collection=self._client_storage_collection, key=client_id
|
||||
)
|
||||
):
|
||||
return None
|
||||
|
||||
if client_data := data.get("client", None):
|
||||
return ProxyDCRClient(
|
||||
allowed_redirect_uri_patterns=data.get(
|
||||
"allowed_redirect_uri_patterns", self._allowed_client_redirect_uris
|
||||
),
|
||||
**client_data,
|
||||
)
|
||||
if client.allowed_redirect_uri_patterns is None:
|
||||
client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris
|
||||
|
||||
return None
|
||||
return client
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Register a client locally
|
||||
|
|
@ -424,7 +415,7 @@ class OAuthProxy(OAuthProvider):
|
|||
"""
|
||||
|
||||
# Create a ProxyDCRClient with configured redirect URI validation
|
||||
proxy_client = ProxyDCRClient(
|
||||
proxy_client: ProxyDCRClient = ProxyDCRClient(
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")],
|
||||
|
|
@ -435,12 +426,11 @@ class OAuthProxy(OAuthProvider):
|
|||
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
|
||||
)
|
||||
|
||||
# Store as structured dict with all needed metadata
|
||||
storage_data = {
|
||||
"client": proxy_client.model_dump(mode="json"),
|
||||
"allowed_redirect_uri_patterns": self._allowed_client_redirect_uris,
|
||||
}
|
||||
await self._client_storage.set(client_info.client_id, storage_data)
|
||||
await self._client_storage_adapter.put(
|
||||
collection=self._client_storage_collection,
|
||||
key=client_info.client_id,
|
||||
value=proxy_client,
|
||||
)
|
||||
|
||||
# Log redirect URIs to help users discover what patterns they might need
|
||||
if client_info.redirect_uris:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ This implementation is based on:
|
|||
from collections.abc import Sequence
|
||||
|
||||
import httpx
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from pydantic import AnyHttpUrl, BaseModel, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
|
|
@ -19,7 +20,6 @@ from fastmcp.server.auth import TokenVerifier
|
|||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import KVStorage
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ class OIDCProxy(OAuthProxy):
|
|||
redirect_path: str | None = None,
|
||||
# Client configuration
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: KVStorage | None = None,
|
||||
client_storage: KVStoreProtocol | None = None,
|
||||
# Token validation configuration
|
||||
token_endpoint_auth_method: str | None = None,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ Example:
|
|||
```
|
||||
"""
|
||||
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from pydantic import AnyHttpUrl, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import KVStorage
|
||||
from fastmcp.utilities.types import NotSet, NotSetT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -92,7 +92,7 @@ class Auth0Provider(OIDCProxy):
|
|||
required_scopes: list[str] | NotSetT = NotSet,
|
||||
redirect_path: str | NotSetT = NotSet,
|
||||
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
||||
client_storage: KVStorage | None = None,
|
||||
client_storage: KVStoreProtocol | None = None,
|
||||
) -> None:
|
||||
"""Initialize Auth0 OAuth provider.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from pydantic import SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
|
@ -14,7 +15,6 @@ from fastmcp.server.auth import AccessToken, TokenVerifier
|
|||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import KVStorage
|
||||
from fastmcp.utilities.types import NotSet, NotSetT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -161,7 +161,7 @@ class AzureProvider(OAuthProxy):
|
|||
required_scopes: list[str] | None | NotSetT = NotSet,
|
||||
timeout_seconds: int | NotSetT = NotSet,
|
||||
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
||||
client_storage: KVStorage | None = None,
|
||||
client_storage: KVStoreProtocol | None = None,
|
||||
):
|
||||
"""Initialize Azure OAuth provider.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ Example:
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from pydantic import AnyHttpUrl, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
|
@ -30,7 +31,6 @@ from fastmcp.server.auth.auth import AccessToken
|
|||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import KVStorage
|
||||
from fastmcp.utilities.types import NotSet, NotSetT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -202,7 +202,7 @@ class GitHubProvider(OAuthProxy):
|
|||
required_scopes: list[str] | NotSetT = NotSet,
|
||||
timeout_seconds: int | NotSetT = NotSet,
|
||||
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
||||
client_storage: KVStorage | None = None,
|
||||
client_storage: KVStoreProtocol | None = None,
|
||||
):
|
||||
"""Initialize GitHub OAuth provider.
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from __future__ import annotations
|
|||
import time
|
||||
|
||||
import httpx
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from pydantic import AnyHttpUrl, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
|
@ -32,7 +33,6 @@ from fastmcp.server.auth.auth import AccessToken
|
|||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import KVStorage
|
||||
from fastmcp.utilities.types import NotSet, NotSetT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -218,7 +218,7 @@ class GoogleProvider(OAuthProxy):
|
|||
required_scopes: list[str] | NotSetT = NotSet,
|
||||
timeout_seconds: int | NotSetT = NotSet,
|
||||
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
||||
client_storage: KVStorage | None = None,
|
||||
client_storage: KVStoreProtocol | None = None,
|
||||
):
|
||||
"""Initialize Google OAuth provider.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from kv_store_adapter.types import KVStoreProtocol
|
||||
from pydantic import AnyHttpUrl, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from starlette.responses import JSONResponse
|
||||
|
|
@ -23,7 +24,6 @@ from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.storage import KVStorage
|
||||
from fastmcp.utilities.types import NotSet, NotSetT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -170,7 +170,7 @@ class WorkOSProvider(OAuthProxy):
|
|||
required_scopes: list[str] | None | NotSetT = NotSet,
|
||||
timeout_seconds: int | NotSetT = NotSet,
|
||||
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
||||
client_storage: KVStorage | None = None,
|
||||
client_storage: KVStoreProtocol | None = None,
|
||||
):
|
||||
"""Initialize WorkOS OAuth provider.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ from __future__ import annotations as _annotations
|
|||
|
||||
import inspect
|
||||
import warnings
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
||||
|
||||
from kv_store_adapter.stores.disk import DiskStore
|
||||
from pydantic import Field, ImportString, field_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import (
|
||||
|
|
@ -147,6 +149,8 @@ class Settings(BaseSettings):
|
|||
|
||||
home: Path = Path.home() / ".fastmcp"
|
||||
|
||||
data_path: Path = home / "data.db"
|
||||
|
||||
test_mode: bool = False
|
||||
|
||||
log_enabled: bool = True
|
||||
|
|
@ -379,6 +383,10 @@ class Settings(BaseSettings):
|
|||
|
||||
return auth_class
|
||||
|
||||
@cached_property
|
||||
def data_store(self) -> DiskStore:
|
||||
return DiskStore(path=str(self.data_path), size_limit=1024 * 1024 * 10) # 10MB
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
"""Key-value storage utilities for persistent data management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import pydantic_core
|
||||
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class KVStorage(Protocol):
|
||||
"""Protocol for key-value storage of JSON data."""
|
||||
|
||||
async def get(self, key: str) -> dict[str, Any] | None:
|
||||
"""Get a JSON dict by key."""
|
||||
...
|
||||
|
||||
async def set(self, key: str, value: dict[str, Any]) -> None:
|
||||
"""Store a JSON dict by key."""
|
||||
...
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete a value by key."""
|
||||
...
|
||||
|
||||
|
||||
class JSONFileStorage:
|
||||
"""File-based key-value storage for JSON data with automatic metadata tracking.
|
||||
|
||||
Each key-value pair is stored as a separate JSON file on disk.
|
||||
Keys are sanitized to be filesystem-safe.
|
||||
|
||||
The storage automatically wraps all data with metadata:
|
||||
- timestamp: Timestamp when the entry was last written
|
||||
|
||||
Args:
|
||||
cache_dir: Directory for storing JSON files
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: Path):
|
||||
"""Initialize JSON file storage."""
|
||||
self.cache_dir = cache_dir
|
||||
self.cache_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
def _get_safe_key(self, key: str) -> str:
|
||||
"""Convert key to filesystem-safe string."""
|
||||
safe_key = key
|
||||
|
||||
# Replace problematic characters with underscores
|
||||
for char in [".", "/", "\\", ":", "*", "?", '"', "<", ">", "|", " "]:
|
||||
safe_key = safe_key.replace(char, "_")
|
||||
|
||||
# Compress multiple underscores into one
|
||||
while "__" in safe_key:
|
||||
safe_key = safe_key.replace("__", "_")
|
||||
|
||||
# Strip leading and trailing underscores
|
||||
safe_key = safe_key.strip("_")
|
||||
|
||||
return safe_key
|
||||
|
||||
def _get_file_path(self, key: str) -> Path:
|
||||
"""Get the file path for a given key."""
|
||||
safe_key = self._get_safe_key(key)
|
||||
return self.cache_dir / f"{safe_key}.json"
|
||||
|
||||
async def get(self, key: str) -> dict[str, Any] | None:
|
||||
"""Get a JSON dict from storage by key.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve
|
||||
|
||||
Returns:
|
||||
The stored dict or None if not found
|
||||
"""
|
||||
path = self._get_file_path(key)
|
||||
try:
|
||||
wrapper = json.loads(path.read_text())
|
||||
|
||||
# Expect wrapped format with metadata
|
||||
if not isinstance(wrapper, dict) or "data" not in wrapper:
|
||||
logger.warning(f"Invalid storage format for key '{key}'")
|
||||
return None
|
||||
|
||||
logger.debug(f"Loaded data for key '{key}'")
|
||||
return wrapper["data"]
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug(f"No data found for key '{key}'")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to load data for key '{key}': {e}")
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: dict[str, Any]) -> None:
|
||||
"""Store a JSON dict with metadata.
|
||||
|
||||
Args:
|
||||
key: The key to store under
|
||||
value: The dict to store
|
||||
"""
|
||||
import time
|
||||
|
||||
path = self._get_file_path(key)
|
||||
current_time = time.time()
|
||||
|
||||
# Create wrapper with metadata
|
||||
wrapper = {
|
||||
"data": value,
|
||||
"timestamp": current_time,
|
||||
}
|
||||
|
||||
# Use pydantic_core for consistent JSON serialization
|
||||
json_data = pydantic_core.to_json(wrapper, fallback=str)
|
||||
path.write_bytes(json_data)
|
||||
logger.debug(f"Saved data for key '{key}'")
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete a value from storage.
|
||||
|
||||
Args:
|
||||
key: The key to delete
|
||||
"""
|
||||
path = self._get_file_path(key)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.debug(f"Deleted data for key '{key}'")
|
||||
|
||||
async def cleanup_old_entries(
|
||||
self,
|
||||
max_age_seconds: int = 30 * 24 * 60 * 60, # 30 days default
|
||||
) -> int:
|
||||
"""Remove entries older than the specified age.
|
||||
|
||||
Uses the timestamp field to determine age.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age in seconds (default 30 days)
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
import time
|
||||
|
||||
current_time = time.time()
|
||||
removed_count = 0
|
||||
|
||||
for json_file in self.cache_dir.glob("*.json"):
|
||||
try:
|
||||
# Read the file and check timestamp
|
||||
wrapper = json.loads(json_file.read_text())
|
||||
|
||||
# Check wrapped format
|
||||
if not isinstance(wrapper, dict) or "data" not in wrapper:
|
||||
continue # Invalid format, skip
|
||||
|
||||
if "timestamp" not in wrapper:
|
||||
continue # No timestamp field, skip
|
||||
|
||||
entry_age = current_time - wrapper["timestamp"]
|
||||
if entry_age > max_age_seconds:
|
||||
json_file.unlink()
|
||||
removed_count += 1
|
||||
logger.debug(
|
||||
f"Removed old entry '{json_file.stem}' (age: {entry_age:.0f}s)"
|
||||
)
|
||||
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.debug(f"Error reading {json_file.name}: {e}")
|
||||
continue
|
||||
|
||||
if removed_count > 0:
|
||||
logger.info(f"Cleaned up {removed_count} old entries from storage")
|
||||
|
||||
return removed_count
|
||||
|
||||
|
||||
class InMemoryStorage:
|
||||
"""In-memory key-value storage for JSON data.
|
||||
|
||||
Simple dict-based storage that doesn't persist across restarts.
|
||||
Useful for testing or environments where file storage isn't available.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize in-memory storage."""
|
||||
self._data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def get(self, key: str) -> dict[str, Any] | None:
|
||||
"""Get a JSON dict from memory by key."""
|
||||
return self._data.get(key)
|
||||
|
||||
async def set(self, key: str, value: dict[str, Any]) -> None:
|
||||
"""Store a JSON dict in memory."""
|
||||
self._data[key] = value
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete a value from memory."""
|
||||
self._data.pop(key, None)
|
||||
|
|
@ -1,163 +1,163 @@
|
|||
"""Test OAuth token expiry handling with absolute timestamps."""
|
||||
# """Test OAuth token expiry handling with absolute timestamps."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
# import json
|
||||
# from datetime import datetime, timedelta, timezone
|
||||
# from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp.shared.auth import OAuthToken
|
||||
# import pytest
|
||||
# from mcp.shared.auth import OAuthToken
|
||||
|
||||
from fastmcp.client.auth.oauth import FileTokenStorage
|
||||
# from fastmcp.client.auth.oauth import FileTokenStorage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_storage_with_expiry(tmp_path: Path):
|
||||
"""Test that tokens are stored with absolute expiry time and loaded correctly."""
|
||||
storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_token_storage_with_expiry(tmp_path: Path):
|
||||
# """Test that tokens are stored with absolute expiry time and loaded correctly."""
|
||||
# storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
|
||||
# Create a token with 3600 seconds expiry
|
||||
token = OAuthToken(
|
||||
access_token="test_token",
|
||||
token_type="Bearer",
|
||||
expires_in=3600,
|
||||
refresh_token="refresh_token",
|
||||
)
|
||||
# # Create a token with 3600 seconds expiry
|
||||
# token = OAuthToken(
|
||||
# access_token="test_token",
|
||||
# token_type="Bearer",
|
||||
# expires_in=3600,
|
||||
# refresh_token="refresh_token",
|
||||
# )
|
||||
|
||||
# Save the token
|
||||
await storage.set_tokens(token)
|
||||
# # Save the token
|
||||
# await storage.set_tokens(token)
|
||||
|
||||
# Check that the file contains the dataclass format
|
||||
# JSONFileStorage wraps data in {"data": ..., "timestamp": ...}
|
||||
token_file = storage._get_file_path("tokens")
|
||||
wrapper = json.loads(token_file.read_text())
|
||||
# # Check that the file contains the dataclass format
|
||||
# # JSONFileStorage wraps data in {"data": ..., "timestamp": ...}
|
||||
# token_file = storage._get_file_path("tokens")
|
||||
# wrapper = json.loads(token_file.read_text())
|
||||
|
||||
assert "data" in wrapper
|
||||
assert "timestamp" in wrapper
|
||||
data = wrapper["data"]
|
||||
# assert "data" in wrapper
|
||||
# assert "timestamp" in wrapper
|
||||
# data = wrapper["data"]
|
||||
|
||||
assert "token_payload" in data
|
||||
assert "expires_at" in data
|
||||
assert data["expires_at"] is not None
|
||||
# expires_at should be approximately now + 3600 seconds
|
||||
expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
|
||||
expected = datetime.now(timezone.utc) + timedelta(seconds=3600)
|
||||
assert abs((expires_at - expected).total_seconds()) < 2
|
||||
# assert "token_payload" in data
|
||||
# assert "expires_at" in data
|
||||
# assert data["expires_at"] is not None
|
||||
# # expires_at should be approximately now + 3600 seconds
|
||||
# expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
|
||||
# expected = datetime.now(timezone.utc) + timedelta(seconds=3600)
|
||||
# assert abs((expires_at - expected).total_seconds()) < 2
|
||||
|
||||
# Load the token back
|
||||
loaded_token = await storage.get_tokens()
|
||||
assert loaded_token is not None
|
||||
assert loaded_token.access_token == "test_token"
|
||||
# expires_in should be recalculated to be approximately 3600 (minus loading time)
|
||||
assert loaded_token.expires_in is not None
|
||||
assert 3595 <= loaded_token.expires_in <= 3600
|
||||
# # Load the token back
|
||||
# loaded_token = await storage.get_tokens()
|
||||
# assert loaded_token is not None
|
||||
# assert loaded_token.access_token == "test_token"
|
||||
# # expires_in should be recalculated to be approximately 3600 (minus loading time)
|
||||
# assert loaded_token.expires_in is not None
|
||||
# assert 3595 <= loaded_token.expires_in <= 3600
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_token_returns_none(tmp_path: Path):
|
||||
"""Test that expired tokens return None when loaded."""
|
||||
storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_expired_token_returns_none(tmp_path: Path):
|
||||
# """Test that expired tokens return None when loaded."""
|
||||
# storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
|
||||
# Manually create an already-expired token file
|
||||
token_file = storage._get_file_path("tokens")
|
||||
past_expiry = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=10
|
||||
) # Expired 10 seconds ago
|
||||
# # Manually create an already-expired token file
|
||||
# token_file = storage._get_file_path("tokens")
|
||||
# past_expiry = datetime.now(timezone.utc) - timedelta(
|
||||
# seconds=10
|
||||
# ) # Expired 10 seconds ago
|
||||
|
||||
expired_token = {
|
||||
"token_payload": {
|
||||
"access_token": "test_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "refresh_token",
|
||||
},
|
||||
"expires_at": past_expiry.isoformat(),
|
||||
}
|
||||
token_file.write_text(json.dumps(expired_token, indent=2, default=str))
|
||||
# expired_token = {
|
||||
# "token_payload": {
|
||||
# "access_token": "test_token",
|
||||
# "token_type": "Bearer",
|
||||
# "expires_in": 3600,
|
||||
# "refresh_token": "refresh_token",
|
||||
# },
|
||||
# "expires_at": past_expiry.isoformat(),
|
||||
# }
|
||||
# token_file.write_text(json.dumps(expired_token, indent=2, default=str))
|
||||
|
||||
# Load the token - should return None since it's expired
|
||||
loaded_token = await storage.get_tokens()
|
||||
assert loaded_token is None
|
||||
# # Load the token - should return None since it's expired
|
||||
# loaded_token = await storage.get_tokens()
|
||||
# assert loaded_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_without_expiry(tmp_path: Path):
|
||||
"""Test that tokens without expires_in are handled correctly."""
|
||||
storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_token_without_expiry(tmp_path: Path):
|
||||
# """Test that tokens without expires_in are handled correctly."""
|
||||
# storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
|
||||
# Create a token without expires_in (perpetual token)
|
||||
token = OAuthToken(
|
||||
access_token="test_token",
|
||||
token_type="Bearer",
|
||||
expires_in=None,
|
||||
refresh_token="refresh_token",
|
||||
)
|
||||
# # Create a token without expires_in (perpetual token)
|
||||
# token = OAuthToken(
|
||||
# access_token="test_token",
|
||||
# token_type="Bearer",
|
||||
# expires_in=None,
|
||||
# refresh_token="refresh_token",
|
||||
# )
|
||||
|
||||
# Save the token
|
||||
await storage.set_tokens(token)
|
||||
# # Save the token
|
||||
# await storage.set_tokens(token)
|
||||
|
||||
# Check that expires_at is None in the file
|
||||
# JSONFileStorage wraps data in {"data": ..., "timestamp": ...}
|
||||
token_file = storage._get_file_path("tokens")
|
||||
wrapper = json.loads(token_file.read_text())
|
||||
data = wrapper["data"]
|
||||
assert data["expires_at"] is None
|
||||
# # Check that expires_at is None in the file
|
||||
# # JSONFileStorage wraps data in {"data": ..., "timestamp": ...}
|
||||
# token_file = storage._get_file_path("tokens")
|
||||
# wrapper = json.loads(token_file.read_text())
|
||||
# data = wrapper["data"]
|
||||
# assert data["expires_at"] is None
|
||||
|
||||
# Load the token back - should work since no expiry
|
||||
loaded_token = await storage.get_tokens()
|
||||
assert loaded_token is not None
|
||||
assert loaded_token.access_token == "test_token"
|
||||
assert loaded_token.expires_in is None
|
||||
# # Load the token back - should work since no expiry
|
||||
# loaded_token = await storage.get_tokens()
|
||||
# assert loaded_token is not None
|
||||
# assert loaded_token.access_token == "test_token"
|
||||
# assert loaded_token.expires_in is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_format_returns_none(tmp_path: Path):
|
||||
"""Test that invalid token format returns None."""
|
||||
storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_invalid_format_returns_none(tmp_path: Path):
|
||||
# """Test that invalid token format returns None."""
|
||||
# storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
|
||||
# Manually write an invalid format token file (missing required fields)
|
||||
token_file = storage._get_file_path("tokens")
|
||||
invalid_token = {
|
||||
"access_token": "invalid_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "refresh_token",
|
||||
}
|
||||
token_file.write_text(json.dumps(invalid_token, indent=2))
|
||||
# # Manually write an invalid format token file (missing required fields)
|
||||
# token_file = storage._get_file_path("tokens")
|
||||
# invalid_token = {
|
||||
# "access_token": "invalid_token",
|
||||
# "token_type": "Bearer",
|
||||
# "expires_in": 3600,
|
||||
# "refresh_token": "refresh_token",
|
||||
# }
|
||||
# token_file.write_text(json.dumps(invalid_token, indent=2))
|
||||
|
||||
# Try to load - should return None
|
||||
loaded_token = await storage.get_tokens()
|
||||
assert loaded_token is None
|
||||
# # Try to load - should return None
|
||||
# loaded_token = await storage.get_tokens()
|
||||
# assert loaded_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_expiry_recalculated_on_load(tmp_path: Path):
|
||||
"""Test that expires_in is correctly recalculated when loading tokens."""
|
||||
storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_token_expiry_recalculated_on_load(tmp_path: Path):
|
||||
# """Test that expires_in is correctly recalculated when loading tokens."""
|
||||
# storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
|
||||
|
||||
# Manually create a token file with a specific expires_at
|
||||
token_file = storage._get_file_path("tokens")
|
||||
future_expiry = datetime.now(timezone.utc) + timedelta(
|
||||
seconds=1800
|
||||
) # 30 minutes from now
|
||||
# # Manually create a token file with a specific expires_at
|
||||
# token_file = storage._get_file_path("tokens")
|
||||
# future_expiry = datetime.now(timezone.utc) + timedelta(
|
||||
# seconds=1800
|
||||
# ) # 30 minutes from now
|
||||
|
||||
# JSONFileStorage expects wrapped format
|
||||
stored_token = {
|
||||
"data": {
|
||||
"token_payload": {
|
||||
"access_token": "test_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600, # Original value (will be recalculated)
|
||||
"refresh_token": "refresh_token",
|
||||
},
|
||||
"expires_at": future_expiry.isoformat(),
|
||||
},
|
||||
"timestamp": datetime.now(timezone.utc).timestamp(),
|
||||
}
|
||||
token_file.write_text(json.dumps(stored_token, indent=2, default=str))
|
||||
# # JSONFileStorage expects wrapped format
|
||||
# stored_token = {
|
||||
# "data": {
|
||||
# "token_payload": {
|
||||
# "access_token": "test_token",
|
||||
# "token_type": "Bearer",
|
||||
# "expires_in": 3600, # Original value (will be recalculated)
|
||||
# "refresh_token": "refresh_token",
|
||||
# },
|
||||
# "expires_at": future_expiry.isoformat(),
|
||||
# },
|
||||
# "timestamp": datetime.now(timezone.utc).timestamp(),
|
||||
# }
|
||||
# token_file.write_text(json.dumps(stored_token, indent=2, default=str))
|
||||
|
||||
# Load the token
|
||||
loaded_token = await storage.get_tokens()
|
||||
assert loaded_token is not None
|
||||
# expires_in should be recalculated to approximately 1800 seconds
|
||||
assert loaded_token.expires_in is not None
|
||||
assert 1795 <= loaded_token.expires_in <= 1800
|
||||
# # Load the token
|
||||
# loaded_token = await storage.get_tokens()
|
||||
# assert loaded_token is not None
|
||||
# # expires_in should be recalculated to approximately 1800 seconds
|
||||
# assert loaded_token.expires_in is not None
|
||||
# assert 1795 <= loaded_token.expires_in <= 1800
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ class TestOAuthProxyRedirectValidation:
|
|||
"new-client"
|
||||
) # Use the client ID we registered
|
||||
assert isinstance(registered, ProxyDCRClient)
|
||||
assert registered._allowed_redirect_uri_patterns == custom_patterns
|
||||
assert registered.allowed_redirect_uri_patterns == custom_patterns
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_unregistered_client_returns_none(self):
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ from pathlib import Path
|
|||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from kv_store_adapter.stores.disk import DiskStore
|
||||
from kv_store_adapter.stores.memory import MemoryStore
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.storage import InMemoryStorage, JSONFileStorage
|
||||
|
||||
|
||||
class TestOAuthProxyStorage:
|
||||
|
|
@ -23,14 +25,14 @@ class TestOAuthProxyStorage:
|
|||
return verifier
|
||||
|
||||
@pytest.fixture
|
||||
def temp_storage(self, tmp_path: Path) -> JSONFileStorage:
|
||||
def temp_storage(self, tmp_path: Path) -> DiskStore:
|
||||
"""Create file-based storage for testing."""
|
||||
return JSONFileStorage(tmp_path / "oauth-clients")
|
||||
return DiskStore(path=str(tmp_path / "oauth-clients"))
|
||||
|
||||
@pytest.fixture
|
||||
def memory_storage(self) -> InMemoryStorage:
|
||||
def memory_storage(self) -> MemoryStore:
|
||||
"""Create in-memory storage for testing."""
|
||||
return InMemoryStorage()
|
||||
return MemoryStore()
|
||||
|
||||
def create_proxy(self, jwt_verifier, storage=None) -> OAuthProxy:
|
||||
"""Create an OAuth proxy with specified storage."""
|
||||
|
|
@ -48,7 +50,7 @@ class TestOAuthProxyStorage:
|
|||
async def test_default_storage_is_file_based(self, jwt_verifier):
|
||||
"""Test that proxy defaults to file-based storage."""
|
||||
proxy = self.create_proxy(jwt_verifier, storage=None)
|
||||
assert isinstance(proxy._client_storage, JSONFileStorage)
|
||||
assert isinstance(proxy._client_storage, DiskStore)
|
||||
|
||||
async def test_register_and_get_client(self, jwt_verifier, temp_storage):
|
||||
"""Test registering and retrieving a client."""
|
||||
|
|
@ -132,7 +134,7 @@ class TestOAuthProxyStorage:
|
|||
|
||||
async def test_in_memory_storage_option(self, jwt_verifier):
|
||||
"""Test using in-memory storage explicitly."""
|
||||
storage = InMemoryStorage()
|
||||
storage = MemoryStore()
|
||||
proxy = self.create_proxy(jwt_verifier, storage=storage)
|
||||
|
||||
client_info = OAuthClientInformationFull(
|
||||
|
|
@ -151,7 +153,7 @@ class TestOAuthProxyStorage:
|
|||
assert client2 is not None
|
||||
|
||||
# But new storage instance won't have it
|
||||
proxy3 = self.create_proxy(jwt_verifier, storage=InMemoryStorage())
|
||||
proxy3 = self.create_proxy(jwt_verifier, storage=MemoryStore())
|
||||
client3 = await proxy3.get_client("memory-client")
|
||||
assert client3 is None
|
||||
|
||||
|
|
@ -167,47 +169,31 @@ class TestOAuthProxyStorage:
|
|||
await proxy.register_client(client_info)
|
||||
|
||||
# Check raw storage data
|
||||
raw_data = await temp_storage.get("structured-client")
|
||||
raw_data = await temp_storage.get(
|
||||
collection="oauth-proxy-clients", key="structured-client"
|
||||
)
|
||||
assert raw_data is not None
|
||||
assert "client" in raw_data
|
||||
assert "allowed_redirect_uri_patterns" in raw_data
|
||||
|
||||
async def test_cleanup_old_clients(self, jwt_verifier, temp_storage):
|
||||
"""Test cleanup of old clients using storage's cleanup method."""
|
||||
import json
|
||||
import time
|
||||
|
||||
proxy = self.create_proxy(jwt_verifier, storage=temp_storage)
|
||||
|
||||
# Register some clients
|
||||
client1 = OAuthClientInformationFull(
|
||||
client_id="old-client",
|
||||
client_secret="secret1",
|
||||
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
|
||||
assert raw_data == snapshot(
|
||||
{
|
||||
"redirect_uris": ["http://localhost:8080/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"client_name": None,
|
||||
"client_uri": None,
|
||||
"logo_uri": None,
|
||||
"contacts": None,
|
||||
"tos_uri": None,
|
||||
"policy_uri": None,
|
||||
"jwks_uri": None,
|
||||
"jwks": None,
|
||||
"software_id": None,
|
||||
"software_version": None,
|
||||
"client_id": "structured-client",
|
||||
"client_secret": "secret",
|
||||
"client_id_issued_at": None,
|
||||
"client_secret_expires_at": None,
|
||||
"allowed_redirect_uri_patterns": None,
|
||||
}
|
||||
)
|
||||
await proxy.register_client(client1)
|
||||
|
||||
client2 = OAuthClientInformationFull(
|
||||
client_id="recent-client",
|
||||
client_secret="secret2",
|
||||
redirect_uris=[AnyUrl("http://localhost:9090/callback")],
|
||||
)
|
||||
await proxy.register_client(client2)
|
||||
|
||||
# Manually make the first client old by modifying the file directly
|
||||
old_client_path = temp_storage._get_file_path("old-client")
|
||||
wrapper = json.loads(old_client_path.read_text())
|
||||
wrapper["timestamp"] = time.time() - (35 * 24 * 60 * 60) # 35 days old
|
||||
old_client_path.write_text(json.dumps(wrapper))
|
||||
|
||||
# Run cleanup directly on storage
|
||||
removed_count = await temp_storage.cleanup_old_entries(
|
||||
max_age_seconds=30 * 24 * 60 * 60
|
||||
)
|
||||
assert removed_count == 1
|
||||
|
||||
# Old client should be gone
|
||||
assert await proxy.get_client("old-client") is None
|
||||
|
||||
# Recent client should still exist
|
||||
assert await proxy.get_client("recent-client") is not None
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
"""Tests for KVStorage implementations."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.utilities.storage import InMemoryStorage, JSONFileStorage
|
||||
|
||||
|
||||
class TestJSONFileStorage:
|
||||
"""Tests for file-based JSON storage."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_storage(self, tmp_path: Path) -> JSONFileStorage:
|
||||
"""Create a JSONFileStorage with temp directory."""
|
||||
return JSONFileStorage(tmp_path / "storage")
|
||||
|
||||
async def test_basic_get_set_delete(self, temp_storage: JSONFileStorage):
|
||||
"""Test basic storage operations."""
|
||||
# Initially empty
|
||||
assert await temp_storage.get("key1") is None
|
||||
|
||||
# Set a value
|
||||
data = {"name": "test", "value": 123}
|
||||
await temp_storage.set("key1", data)
|
||||
|
||||
# Get it back
|
||||
loaded = await temp_storage.get("key1")
|
||||
assert loaded == data
|
||||
|
||||
# Delete it
|
||||
await temp_storage.delete("key1")
|
||||
assert await temp_storage.get("key1") is None
|
||||
|
||||
async def test_special_characters_in_keys(self, temp_storage: JSONFileStorage):
|
||||
"""Test that special characters in keys are handled safely."""
|
||||
key = "user/123:test.json?query=value"
|
||||
data = {"test": "data"}
|
||||
|
||||
await temp_storage.set(key, data)
|
||||
loaded = await temp_storage.get(key)
|
||||
assert loaded == data
|
||||
|
||||
# Verify the file was created with safe name
|
||||
files = list(temp_storage.cache_dir.glob("*.json"))
|
||||
assert len(files) == 1
|
||||
assert "/" not in files[0].name
|
||||
assert ":" not in files[0].name
|
||||
assert "?" not in files[0].name
|
||||
|
||||
async def test_multiple_keys(self, temp_storage: JSONFileStorage):
|
||||
"""Test storing multiple keys."""
|
||||
data1 = {"id": 1}
|
||||
data2 = {"id": 2}
|
||||
data3 = {"id": 3}
|
||||
|
||||
await temp_storage.set("key1", data1)
|
||||
await temp_storage.set("key2", data2)
|
||||
await temp_storage.set("key3", data3)
|
||||
|
||||
assert await temp_storage.get("key1") == data1
|
||||
assert await temp_storage.get("key2") == data2
|
||||
assert await temp_storage.get("key3") == data3
|
||||
|
||||
# Delete one
|
||||
await temp_storage.delete("key2")
|
||||
assert await temp_storage.get("key1") == data1
|
||||
assert await temp_storage.get("key2") is None
|
||||
assert await temp_storage.get("key3") == data3
|
||||
|
||||
async def test_overwrite_existing(self, temp_storage: JSONFileStorage):
|
||||
"""Test overwriting existing values."""
|
||||
await temp_storage.set("key", {"version": 1})
|
||||
await temp_storage.set("key", {"version": 2})
|
||||
|
||||
loaded = await temp_storage.get("key")
|
||||
assert loaded == {"version": 2}
|
||||
|
||||
async def test_persistence_across_instances(self, tmp_path: Path):
|
||||
"""Test that data persists across storage instances."""
|
||||
storage_dir = tmp_path / "persistent"
|
||||
|
||||
# First instance
|
||||
storage1 = JSONFileStorage(storage_dir)
|
||||
data = {"persistent": True, "value": 42}
|
||||
await storage1.set("mykey", data)
|
||||
|
||||
# New instance, same directory
|
||||
storage2 = JSONFileStorage(storage_dir)
|
||||
loaded = await storage2.get("mykey")
|
||||
assert loaded == data
|
||||
|
||||
async def test_delete_nonexistent(self, temp_storage: JSONFileStorage):
|
||||
"""Test deleting non-existent key doesn't error."""
|
||||
# Should not raise
|
||||
await temp_storage.delete("nonexistent")
|
||||
|
||||
|
||||
class TestInMemoryStorage:
|
||||
"""Tests for in-memory storage."""
|
||||
|
||||
@pytest.fixture
|
||||
def memory_storage(self) -> InMemoryStorage:
|
||||
"""Create an InMemoryStorage instance."""
|
||||
return InMemoryStorage()
|
||||
|
||||
async def test_basic_operations(self, memory_storage: InMemoryStorage):
|
||||
"""Test basic storage operations."""
|
||||
# Initially empty
|
||||
assert await memory_storage.get("key1") is None
|
||||
|
||||
# Set and get
|
||||
data = {"name": "test", "value": 123}
|
||||
await memory_storage.set("key1", data)
|
||||
assert await memory_storage.get("key1") == data
|
||||
|
||||
# Delete
|
||||
await memory_storage.delete("key1")
|
||||
assert await memory_storage.get("key1") is None
|
||||
|
||||
async def test_no_persistence(self):
|
||||
"""Test that data doesn't persist across instances."""
|
||||
storage1 = InMemoryStorage()
|
||||
await storage1.set("key", {"value": 1})
|
||||
|
||||
storage2 = InMemoryStorage()
|
||||
assert await storage2.get("key") is None
|
||||
|
||||
async def test_isolation_between_keys(self, memory_storage: InMemoryStorage):
|
||||
"""Test that keys are isolated from each other."""
|
||||
data1 = {"id": 1, "nested": {"value": "a"}}
|
||||
data2 = {"id": 2, "nested": {"value": "b"}}
|
||||
|
||||
await memory_storage.set("key1", data1)
|
||||
await memory_storage.set("key2", data2)
|
||||
|
||||
# Modify retrieved data shouldn't affect stored
|
||||
retrieved = await memory_storage.get("key1")
|
||||
if retrieved:
|
||||
retrieved["modified"] = True
|
||||
|
||||
# Original should be unchanged
|
||||
assert await memory_storage.get("key1") == data1
|
||||
37
uv.lock
generated
37
uv.lock
generated
|
|
@ -69,6 +69,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "6.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.8.3"
|
||||
|
|
@ -400,6 +409,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226, upload-time = "2025-01-11T23:23:37.489Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diskcache"
|
||||
version = "5.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
|
|
@ -523,6 +541,7 @@ dependencies = [
|
|||
{ name = "cyclopts" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "httpx" },
|
||||
{ name = "kv-store-adapter", extra = ["disk", "memory"] },
|
||||
{ name = "mcp" },
|
||||
{ name = "openapi-core" },
|
||||
{ name = "openapi-pydantic" },
|
||||
|
|
@ -572,6 +591,7 @@ requires-dist = [
|
|||
{ name = "cyclopts", specifier = ">=3.0.0" },
|
||||
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "kv-store-adapter", extras = ["disk", "memory"], specifier = ">=0.1.1" },
|
||||
{ name = "mcp", specifier = ">=1.12.4,<2.0.0" },
|
||||
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
|
||||
{ name = "openapi-core", specifier = ">=0.19.5" },
|
||||
|
|
@ -909,6 +929,23 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kv-store-adapter"
|
||||
version = "0.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/1f/23f3d90066d349d550efe8864b04a7f5fa31ffc5d1fc7cd98fb672308fe2/kv_store_adapter-0.1.1.tar.gz", hash = "sha256:79aea84186202eef9e9f0bd6c60197f14bd8e9f002ad21b24e3a150a0753ae72", size = 95475, upload-time = "2025-09-24T17:41:40.433Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/3f/41548807f34f52627e50c7708e9321f263565c3aa0bbf6063103b114ab0d/kv_store_adapter-0.1.1-py3-none-any.whl", hash = "sha256:0e098afb6fc387fe779a7998633f431fc83f3f81d111d0fe321f9ef8ac1e72b3", size = 29788, upload-time = "2025-09-24T17:41:39.262Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
disk = [
|
||||
{ name = "diskcache" },
|
||||
]
|
||||
memory = [
|
||||
{ name = "cachetools" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-object-proxy"
|
||||
version = "1.11.0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue