diff --git a/src/lampyrid/server.py b/src/lampyrid/server.py index 4b1682c..fad1cb4 100644 --- a/src/lampyrid/server.py +++ b/src/lampyrid/server.py @@ -8,7 +8,11 @@ from fastmcp.server.auth.providers.google import GoogleProvider from fastmcp.utilities.logging import configure_logging from fastmcp.utilities.types import Image -from key_value.aio.stores.filetree import FileTreeStore +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1CollectionSanitizationStrategy, + FileTreeV1KeySanitizationStrategy, +) from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from mcp.types import Icon @@ -35,8 +39,20 @@ def _create_auth_provider() -> Optional[AuthProvider]: # Create storage directory if it doesn't exist settings.oauth_storage_path.mkdir(parents=True, exist_ok=True) - # Initialize file-tree storage with Fernet encryption - file_tree_store = FileTreeStore(data_directory=settings.oauth_storage_path) + # Initialize file-tree storage with Fernet encryption. + # Sanitize keys/collections so URL-based client_ids (e.g. Goose's CIMD + # client_id `https://.../client-metadata.json`) don't get interpreted as + # nested directory paths, which fails with FileNotFoundError. The default + # PassthroughStrategy leaves slashes untouched. + file_tree_store = FileTreeStore( + data_directory=settings.oauth_storage_path, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy( + directory=settings.oauth_storage_path + ), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy( + directory=settings.oauth_storage_path + ), + ) client_storage = FernetEncryptionWrapper( key_value=file_tree_store, fernet=Fernet(settings.oauth_storage_encryption_key), # ty:ignore[invalid-argument-type] diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 7f568ef..39d0a4b 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -63,10 +63,16 @@ def test_create_auth_provider_with_persistence(self): with ( patch('lampyrid.server.GoogleProvider') as mock_google_provider, patch('lampyrid.server.FileTreeStore') as mock_file_tree_store, + patch('lampyrid.server.FileTreeV1KeySanitizationStrategy') as mock_key_strategy, + patch( + 'lampyrid.server.FileTreeV1CollectionSanitizationStrategy' + ) as mock_collection_strategy, patch('lampyrid.server.FernetEncryptionWrapper') as mock_encryption_wrapper, ): mock_google_provider.return_value = MagicMock() mock_file_tree_store.return_value = MagicMock() + mock_key_strategy.return_value = MagicMock() + mock_collection_strategy.return_value = MagicMock() mock_encryption_wrapper.return_value = MagicMock() result = _create_auth_provider() @@ -78,7 +84,17 @@ def test_create_auth_provider_with_persistence(self): ) # Verify file tree store and encryption wrapper were initialized mock_file_tree_store.assert_called_once_with( - data_directory=mock_settings.oauth_storage_path + data_directory=mock_settings.oauth_storage_path, + key_sanitization_strategy=mock_key_strategy.return_value, + collection_sanitization_strategy=mock_collection_strategy.return_value, + ) + # Sanitization strategies must be built against the storage path so that + # URL-based client_ids (Goose CIMD) don't break filesystem persistence + mock_key_strategy.assert_called_once_with( + directory=mock_settings.oauth_storage_path + ) + mock_collection_strategy.assert_called_once_with( + directory=mock_settings.oauth_storage_path ) mock_encryption_wrapper.assert_called_once() # Verify GoogleProvider was called with client_storage