Switch to DiskStore KV implementation

This commit is contained in:
William Easton 2025-09-24 18:07:57 -05:00
commit dee2bb51d9
No known key found for this signature in database
16 changed files with 369 additions and 813 deletions

View file

@ -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"]

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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):
"""

View file

@ -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)

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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
View file

@ -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"