diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx index e20f2c76c..d67e20123 100644 --- a/docs/development/upgrade-guide.mdx +++ b/docs/development/upgrade-guide.mdx @@ -14,6 +14,38 @@ Most servers need only one change: update your import from `from mcp.server.fast ### Breaking Changes +#### OAuth Storage Backend Changed (diskcache CVE) + +The default OAuth storage has moved from `DiskStore` to `FileTreeStore` to address a pickle deserialization vulnerability in diskcache ([CVE-2025-69872](https://github.com/jlowin/fastmcp/issues/3166)). + +If you were using the default storage (i.e., not passing an explicit `client_storage`), clients will need to re-register on their first connection after upgrading. This happens automatically — no user action required, and it's the same flow that already occurs whenever a server restarts with in-memory storage. No code changes needed. + +If you were passing a `DiskStore` explicitly, you have two options: + +1. **Keep using DiskStore** by adding the dependency yourself. This re-introduces the vulnerable `diskcache` package into your dependency tree: + +```bash +pip install 'py-key-value-aio[disk]' +``` + +2. **Switch to FileTreeStore** (recommended) or any other [storage backend](/servers/storage-backends): + +```python +from pathlib import Path +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1KeySanitizationStrategy, + FileTreeV1CollectionSanitizationStrategy, +) + +storage_dir = Path("/var/lib/fastmcp/oauth") +store = FileTreeStore( + data_directory=storage_dir, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(storage_dir), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(storage_dir), +) +``` + #### WSTransport Removed Use `StreamableHttpTransport` instead. diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx index b89d05edc..d7f692aec 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx @@ -26,7 +26,7 @@ production use with enterprise identity providers. ## Classes -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -140,7 +140,7 @@ Handles provider-specific requirements: **Methods:** -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -157,7 +157,7 @@ this specific MCP endpoint. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `jwt_issuer` +#### `jwt_issuer` ```python jwt_issuer(self) -> JWTIssuer @@ -169,7 +169,7 @@ The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -182,7 +182,7 @@ For unregistered clients, returns None (which will raise an error in the SDK). CIMD clients (URL-based client IDs) are looked up and cached automatically. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -196,7 +196,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -214,7 +214,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -226,7 +226,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -244,7 +244,7 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -256,7 +256,7 @@ Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -273,7 +273,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -292,7 +292,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -305,7 +305,7 @@ For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index f60ea1821..1bdb19b20 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -38,39 +38,38 @@ cache_store = MemoryStore() - ❌ Data lost on restart - ❌ Not suitable for multi-process deployments -### Disk Storage +### File Storage **Best for:** Single-server production deployments, persistent caching -Disk storage persists data to the filesystem, allowing it to survive server restarts. +File storage persists data to the filesystem as one JSON file per key, allowing it to survive server restarts. This is the default backend for OAuth storage on Mac and Windows. ```python -from key_value.aio.stores.disk import DiskStore +from pathlib import Path +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1KeySanitizationStrategy, + FileTreeV1CollectionSanitizationStrategy, +) from fastmcp.server.middleware.caching import ResponseCachingMiddleware +storage_dir = Path("/var/cache/fastmcp") +store = FileTreeStore( + data_directory=storage_dir, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(storage_dir), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(storage_dir), +) + # Persistent response cache -middleware = ResponseCachingMiddleware( - cache_storage=DiskStore(directory="/var/cache/fastmcp") -) +middleware = ResponseCachingMiddleware(cache_storage=store) ``` -Or with OAuth token storage: - -```python -from fastmcp.server.auth.providers.github import GitHubProvider -from key_value.aio.stores.disk import DiskStore - -auth = GitHubProvider( - client_id="your-id", - client_secret="your-secret", - base_url="https://your-server.com", - client_storage=DiskStore(directory="/var/lib/fastmcp/oauth") -) -``` +The sanitization strategies ensure keys and collection names are safe for the filesystem — alphanumeric names pass through as-is for readability, while special characters are hashed to prevent path traversal. **Characteristics:** - ✅ Data persists across restarts -- ✅ Good performance for moderate load +- ✅ No external dependencies +- ✅ Human-readable files on disk - ❌ Not suitable for distributed deployments - ❌ Filesystem access required @@ -203,16 +202,26 @@ Both parameters are required for production. **Wrap your storage in `FernetEncry The [Response Caching Middleware](/servers/middleware#caching-middleware) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter: ```python +from pathlib import Path from fastmcp import FastMCP from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from key_value.aio.stores.disk import DiskStore +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1KeySanitizationStrategy, + FileTreeV1CollectionSanitizationStrategy, +) mcp = FastMCP("My Server") +cache_dir = Path("cache") +cache_store = FileTreeStore( + data_directory=cache_dir, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(cache_dir), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(cache_dir), +) + # Cache to disk instead of memory -mcp.add_middleware(ResponseCachingMiddleware( - cache_storage=DiskStore(directory="cache") -)) +mcp.add_middleware(ResponseCachingMiddleware(cache_storage=cache_store)) ``` For multi-server deployments sharing a Redis instance: @@ -236,11 +245,21 @@ middleware = ResponseCachingMiddleware(cache_storage=namespaced_store) The [FastMCP Client](/clients/client) uses storage for persisting OAuth tokens locally. By default, tokens are stored in memory: ```python +from pathlib import Path from fastmcp.client.auth import OAuthClientProvider -from key_value.aio.stores.disk import DiskStore +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1KeySanitizationStrategy, + FileTreeV1CollectionSanitizationStrategy, +) # Store tokens on disk for persistence across restarts -token_storage = DiskStore(directory="~/.local/share/fastmcp/tokens") +token_dir = Path("~/.local/share/fastmcp/tokens").expanduser() +token_storage = FileTreeStore( + data_directory=token_dir, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(token_dir), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(token_dir), +) oauth_provider = OAuthClientProvider( mcp_url="https://your-mcp-server.com/mcp/sse", @@ -255,7 +274,7 @@ This allows clients to reconnect without re-authenticating after restarts. | Backend | Development | Single Server | Multi-Server | Cloud Native | |---------|-------------|---------------|--------------|--------------| | Memory | ✅ Best | ⚠️ Limited | ❌ | ❌ | -| Disk | ✅ Good | ✅ Recommended | ❌ | ⚠️ | +| File | ✅ Good | ✅ Recommended | ❌ | ⚠️ | | Redis | ⚠️ Overkill | ✅ Good | ✅ Best | ✅ Best | | DynamoDB | ❌ | ⚠️ | ✅ | ✅ Best (AWS) | | MongoDB | ❌ | ⚠️ | ✅ | ✅ Good | @@ -263,7 +282,7 @@ This allows clients to reconnect without re-authenticating after restarts. **Decision tree:** 1. **Just starting?** Use **Memory** (default) - no configuration needed -2. **Single server, needs persistence?** Use **Disk** +2. **Single server, needs persistence?** Use **File** 3. **Multiple servers or cloud deployment?** Use **Redis** or **DynamoDB** 4. **Existing infrastructure?** Look for a matching py-key-value-aio backend diff --git a/pyproject.toml b/pyproject.toml index 57954634e..059eafb05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyyaml>=6.0,<7.0", "pyperclip>=1.9.0", - "py-key-value-aio[disk,keyring,memory]>=0.4.0,<0.5.0", + "py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0", "uvicorn>=0.35", "websockets>=15.0.1", "jsonschema-path>=0.3.4", diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index 27773ef25..927e4f1db 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -31,6 +31,11 @@ from authlib.integrations.httpx_client import AsyncOAuth2Client from cryptography.fernet import Fernet from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1CollectionSanitizationStrategy, + FileTreeV1KeySanitizationStrategy, +) from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.provider import ( @@ -292,7 +297,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): extra_token_params: Additional parameters to forward to the upstream token endpoint. Useful for provider-specific parameters during token exchange. client_storage: Storage backend for OAuth state (client registrations, tokens). - If None, an encrypted DiskStore will be created in the data directory. + If None, an encrypted file store will be created in the data directory. jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as-is. If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1.2M iterations). @@ -413,18 +418,35 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # JWTIssuer will be created in set_mcp_path() with correct audience self._jwt_issuer: JWTIssuer | None = None - # If the user does not provide a store, we will provide an encrypted disk store + # If the user does not provide a store, we will provide an encrypted file store. + # The storage directory is derived from the encryption key so that different + # keys get isolated directories (e.g. two servers on the same machine with + # different keys won't collide). Decryption errors are treated as cache misses + # rather than hard failures, so key rotation just causes re-registration. if client_storage is None: - # Import lazily to avoid sqlite3 dependency when not using OAuthProxy - from key_value.aio.stores.disk import DiskStore - storage_encryption_key = derive_jwt_key( high_entropy_material=jwt_signing_key.decode(), salt="fastmcp-storage-encryption-key", ) + + key_fingerprint = hashlib.sha256(storage_encryption_key).hexdigest()[:12] + storage_dir = settings.home / "oauth-proxy" / key_fingerprint + storage_dir.mkdir(parents=True, exist_ok=True) + + file_store = FileTreeStore( + data_directory=storage_dir, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy( + storage_dir + ), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy( + storage_dir + ), + ) + client_storage = FernetEncryptionWrapper( - key_value=DiskStore(directory=settings.home / "oauth-proxy"), + key_value=file_store, fernet=Fernet(key=storage_encryption_key), + raise_on_decryption_error=False, ) self._client_storage: AsyncKeyValue = client_storage diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index d89ac0756..3a000129f 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -255,8 +255,8 @@ class OIDCProxy(OAuthProxy): If empty list, no redirect URIs are allowed. These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 42e51ceb9..46c4f70c9 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -86,8 +86,8 @@ class Auth0Provider(OIDCProxy): allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 19f2908aa..d684ee82f 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -125,8 +125,8 @@ class AWSCognitoProvider(OIDCProxy): allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index b5974d887..b651b74ed 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -140,8 +140,8 @@ class AzureProvider(OAuthProxy): allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index a6882be1e..4fb5ebb53 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -201,8 +201,8 @@ class DiscordProvider(OAuthProxy): allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index ee04a466e..abaaa439a 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -196,8 +196,8 @@ class GitHubProvider(OAuthProxy): allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 3badddc3b..80deac6e3 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -216,8 +216,8 @@ class GoogleProvider(OAuthProxy): allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 5c0ba73e1..f34f77029 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -162,8 +162,8 @@ class WorkOSProvider(OAuthProxy): allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The - disk store will be encrypted using a key derived from the JWT Signing Key. + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. diff --git a/tests/conftest.py b/tests/conftest.py index ae3061fe5..1401d4141 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,8 +58,8 @@ def enable_fastmcp_logger_propagation(caplog): def isolate_settings_home(tmp_path: Path): """Ensure each test uses an isolated settings.home directory. - This prevents SQLite database locking issues on Windows when multiple - tests share the same DiskStore directory in settings.home / "oauth-proxy". + This prevents file locking issues when multiple tests share the same + storage directory in settings.home / "oauth-proxy". """ test_home = tmp_path / "fastmcp-test-home" test_home.mkdir(exist_ok=True) diff --git a/tests/server/auth/test_oauth_proxy_storage.py b/tests/server/auth/test_oauth_proxy_storage.py index 7c898823e..0273a368c 100644 --- a/tests/server/auth/test_oauth_proxy_storage.py +++ b/tests/server/auth/test_oauth_proxy_storage.py @@ -1,14 +1,15 @@ """Tests for OAuth proxy with persistent storage.""" +import tempfile +import warnings from collections.abc import AsyncGenerator from pathlib import Path from unittest.mock import AsyncMock, Mock import pytest -from diskcache.core import tempfile from inline_snapshot import snapshot from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.disk import MultiDiskStore +from key_value.aio.stores.filetree import FileTreeStore from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl @@ -29,12 +30,12 @@ class TestOAuthProxyStorage: return verifier @pytest.fixture - async def temp_storage(self) -> AsyncGenerator[MultiDiskStore, None]: + async def temp_storage(self) -> AsyncGenerator[FileTreeStore, None]: """Create file-based storage for testing.""" with tempfile.TemporaryDirectory() as temp_dir: - disk_store = MultiDiskStore(base_directory=Path(temp_dir)) - yield disk_store - await disk_store.close() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + yield FileTreeStore(data_directory=Path(temp_dir)) @pytest.fixture def memory_storage(self) -> MemoryStore: diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 52e5c90c9..858089251 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -2,12 +2,18 @@ import sys import tempfile +import warnings +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import mcp.types import pytest from inline_snapshot import snapshot -from key_value.aio.stores.disk import DiskStore +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1CollectionSanitizationStrategy, + FileTreeV1KeySanitizationStrategy, +) from key_value.aio.stores.memory import MemoryStore from key_value.aio.wrappers.statistics.wrapper import ( GetStatistics, @@ -281,7 +287,7 @@ class TestResponseCachingMiddleware: class TestResponseCachingMiddlewareIntegration: """Integration tests with real FastMCP server.""" - @pytest.fixture(params=["memory", "disk"]) + @pytest.fixture(params=["memory", "filetree"]) async def caching_server( self, tracking_calculator: TrackingCalculator, @@ -291,9 +297,21 @@ class TestResponseCachingMiddlewareIntegration: mcp = FastMCP("CachingTestServer", dereference_schemas=False) with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: - disk_store: DiskStore = DiskStore(directory=temp_dir) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + file_store = FileTreeStore( + data_directory=Path(temp_dir), + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy( + Path(temp_dir) + ), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy( + Path(temp_dir) + ), + ) response_caching_middleware = ResponseCachingMiddleware( - cache_storage=disk_store if request.param == "disk" else MemoryStore(), + cache_storage=file_store + if request.param == "filetree" + else MemoryStore(), ) mcp.add_middleware(middleware=response_caching_middleware) @@ -304,8 +322,6 @@ class TestResponseCachingMiddlewareIntegration: yield mcp - await disk_store.close() - @pytest.fixture def non_caching_server(self, tracking_calculator: TrackingCalculator): """Create a FastMCP server for non-caching tests.""" diff --git a/uv.lock b/uv.lock index 7fbe94211..7e7976c22 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,18 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -162,6 +174,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -598,15 +629,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051, upload-time = "2025-11-17T01:51:22.849Z" }, ] -[[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 = "distro" version = "1.9.0" @@ -749,7 +771,7 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "packaging" }, { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, { name = "python-dotenv" }, @@ -820,7 +842,7 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.0.0" }, - { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.4.0,<0.5.0" }, + { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.17.2" }, { name = "pyperclip", specifier = ">=1.9.0" }, @@ -1641,15 +1663,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] -[[package]] -name = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, -] - [[package]] name = "pdbpp" version = "0.12.0.post1" @@ -1801,21 +1814,21 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.4.3" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ee/6a1a37ec67d670e65b349a06089589ddcca93fb0613c316e14b850113074/py_key_value_aio-0.4.3.tar.gz", hash = "sha256:ca1872c19cd84822b45129d66d87f708b116d08cf9e81006196878600eae21a2", size = 92289, upload-time = "2026-02-16T20:55:35.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/2c/9e3b718d679e2716ac5dc0ba06cb3dfee42056fbab780b720065bc197b9b/py_key_value_aio-0.4.3-py3-none-any.whl", hash = "sha256:fbf71b193126e136cdfc095fd92ca75e611a1442bad6eb591b8b4fdb36a901c2", size = 152292, upload-time = "2026-02-16T20:55:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, ] keyring = [ { name = "keyring" }, @@ -2700,8 +2713,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup" }, - { name = "typing-extensions" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [