From 31886b378984b4468802d1a45fb6679d37e5520e Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:39:30 +0100 Subject: [PATCH 01/15] feat(config): add StorageConfig for external auth-state storage Co-Authored-By: Claude Sonnet 4.6 --- linkedin_mcp_server/config/schema.py | 28 ++++++++++++++++++++++ tests/test_config.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/linkedin_mcp_server/config/schema.py b/linkedin_mcp_server/config/schema.py index 82a4152a..4a3017fe 100644 --- a/linkedin_mcp_server/config/schema.py +++ b/linkedin_mcp_server/config/schema.py @@ -83,17 +83,45 @@ class ServerConfig: oauth: OAuthConfig = field(default_factory=OAuthConfig) +@dataclass +class StorageConfig: + """External auth-state storage configuration.""" + + backend: str = "local" + gcs_bucket: str | None = None + gcs_prefix: str = "" + username: str | None = None + + def validate(self) -> None: + """Validate storage configuration values.""" + if self.backend not in ("local", "gcs"): + raise ConfigurationError( + f"Invalid AUTH_STORAGE_BACKEND: '{self.backend}'. Must be 'local' or 'gcs'." + ) + if self.backend == "gcs": + if not self.gcs_bucket: + raise ConfigurationError( + "AUTH_STORAGE_GCS_BUCKET is required when AUTH_STORAGE_BACKEND=gcs" + ) + if not self.username: + raise ConfigurationError( + "AUTH_STORAGE_USERNAME is required when AUTH_STORAGE_BACKEND=gcs" + ) + + @dataclass class AppConfig: """Main application configuration.""" browser: BrowserConfig = field(default_factory=BrowserConfig) server: ServerConfig = field(default_factory=ServerConfig) + storage: StorageConfig = field(default_factory=StorageConfig) is_interactive: bool = field(default=False) def validate(self) -> None: """Validate all configuration values. Call after modifying config.""" self.browser.validate() + self.storage.validate() if self.server.transport == "streamable-http": self._validate_transport_config() self._validate_path_format() diff --git a/tests/test_config.py b/tests/test_config.py index 2c1d3a5e..91261500 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ ConfigurationError, OAuthConfig, ServerConfig, + StorageConfig, ) @@ -121,6 +122,40 @@ def test_validate_skips_oauth_in_command_only_modes(self, flag): config.validate() # No error — skipped for command-only modes +class TestStorageConfig: + def test_defaults(self): + config = StorageConfig() + assert config.backend == "local" + assert config.gcs_bucket is None + assert config.gcs_prefix == "" + assert config.username is None + + def test_validate_gcs_requires_bucket(self): + config = StorageConfig(backend="gcs", username="testuser") + with pytest.raises(ConfigurationError, match="AUTH_STORAGE_GCS_BUCKET"): + config.validate() + + def test_validate_gcs_requires_username(self): + config = StorageConfig(backend="gcs", gcs_bucket="my-bucket") + with pytest.raises(ConfigurationError, match="AUTH_STORAGE_USERNAME"): + config.validate() + + def test_validate_gcs_valid(self): + config = StorageConfig( + backend="gcs", gcs_bucket="my-bucket", username="testuser" + ) + config.validate() # No error + + def test_validate_local_no_requirements(self): + config = StorageConfig() + config.validate() # No error + + def test_validate_invalid_backend(self): + config = StorageConfig(backend="s3") + with pytest.raises(ConfigurationError, match="local.*gcs"): + config.validate() + + class TestConfigSingleton: def test_get_config_returns_same_instance(self, monkeypatch): # Mock sys.argv to prevent argparse from parsing pytest's arguments From 1de5fd76e26fac95e4dbe92924bf7e71e140e8ae Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:42:49 +0100 Subject: [PATCH 02/15] feat(config): load AUTH_STORAGE_* env vars Co-Authored-By: Claude Sonnet 4.6 --- linkedin_mcp_server/config/loaders.py | 17 ++++++++++++ tests/test_config.py | 37 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/linkedin_mcp_server/config/loaders.py b/linkedin_mcp_server/config/loaders.py index ce7dfd90..53e2be49 100644 --- a/linkedin_mcp_server/config/loaders.py +++ b/linkedin_mcp_server/config/loaders.py @@ -50,6 +50,10 @@ class EnvironmentKeys: AUTH = "AUTH" OAUTH_BASE_URL = "OAUTH_BASE_URL" OAUTH_PASSWORD = "OAUTH_PASSWORD" + AUTH_STORAGE_BACKEND = "AUTH_STORAGE_BACKEND" + AUTH_STORAGE_GCS_BUCKET = "AUTH_STORAGE_GCS_BUCKET" + AUTH_STORAGE_GCS_PREFIX = "AUTH_STORAGE_GCS_PREFIX" + AUTH_STORAGE_USERNAME = "AUTH_STORAGE_USERNAME" def is_interactive_environment() -> bool: @@ -163,6 +167,19 @@ def load_from_env(config: AppConfig) -> AppConfig: if oauth_password := os.environ.get(EnvironmentKeys.OAUTH_PASSWORD): config.server.oauth.password = oauth_password + # Auth-state storage + if storage_backend := os.environ.get(EnvironmentKeys.AUTH_STORAGE_BACKEND): + config.storage.backend = storage_backend + + if gcs_bucket := os.environ.get(EnvironmentKeys.AUTH_STORAGE_GCS_BUCKET): + config.storage.gcs_bucket = gcs_bucket + + if gcs_prefix := os.environ.get(EnvironmentKeys.AUTH_STORAGE_GCS_PREFIX): + config.storage.gcs_prefix = gcs_prefix + + if storage_username := os.environ.get(EnvironmentKeys.AUTH_STORAGE_USERNAME): + config.storage.username = storage_username + return config diff --git a/tests/test_config.py b/tests/test_config.py index 91261500..577c50c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -296,3 +296,40 @@ def test_load_from_env_invalid_auth_mode(self, monkeypatch): with pytest.raises(ConfigurationError, match="Invalid AUTH"): load_from_env(AppConfig()) + + +class TestStorageConfigEnvLoading: + def test_load_storage_backend_from_env(self, monkeypatch): + monkeypatch.setenv("AUTH_STORAGE_BACKEND", "gcs") + monkeypatch.setenv("AUTH_STORAGE_GCS_BUCKET", "my-bucket") + monkeypatch.setenv("AUTH_STORAGE_USERNAME", "testuser") + from linkedin_mcp_server.config.loaders import load_from_env + + config = AppConfig() + load_from_env(config) + assert config.storage.backend == "gcs" + assert config.storage.gcs_bucket == "my-bucket" + assert config.storage.username == "testuser" + + def test_load_storage_prefix_from_env(self, monkeypatch): + monkeypatch.setenv("AUTH_STORAGE_GCS_PREFIX", "linkedin-mcp") + from linkedin_mcp_server.config.loaders import load_from_env + + config = AppConfig() + load_from_env(config) + assert config.storage.gcs_prefix == "linkedin-mcp" + + def test_storage_defaults_when_no_env(self, monkeypatch): + for var in [ + "AUTH_STORAGE_BACKEND", + "AUTH_STORAGE_GCS_BUCKET", + "AUTH_STORAGE_GCS_PREFIX", + "AUTH_STORAGE_USERNAME", + ]: + monkeypatch.delenv(var, raising=False) + from linkedin_mcp_server.config.loaders import load_from_env + + config = AppConfig() + load_from_env(config) + assert config.storage.backend == "local" + assert config.storage.gcs_bucket is None From 0605ef2edd0e6fca2ceb889a2d124a06949a7831 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:45:08 +0100 Subject: [PATCH 03/15] feat(storage): add StorageBackend protocol and LocalBackend Co-Authored-By: Claude Sonnet 4.6 --- linkedin_mcp_server/storage/__init__.py | 21 +++++ linkedin_mcp_server/storage/backend.py | 107 ++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 linkedin_mcp_server/storage/__init__.py create mode 100644 linkedin_mcp_server/storage/backend.py diff --git a/linkedin_mcp_server/storage/__init__.py b/linkedin_mcp_server/storage/__init__.py new file mode 100644 index 00000000..2c2fdf84 --- /dev/null +++ b/linkedin_mcp_server/storage/__init__.py @@ -0,0 +1,21 @@ +"""External auth-state storage backends.""" + +from linkedin_mcp_server.storage.backend import ( + LocalBackend, + StorageBackend, + StorageSyncError, + delete_remote, + get_storage_backend, + sync_from_remote, + sync_to_remote, +) + +__all__ = [ + "LocalBackend", + "StorageBackend", + "StorageSyncError", + "delete_remote", + "get_storage_backend", + "sync_from_remote", + "sync_to_remote", +] diff --git a/linkedin_mcp_server/storage/backend.py b/linkedin_mcp_server/storage/backend.py new file mode 100644 index 00000000..094011f7 --- /dev/null +++ b/linkedin_mcp_server/storage/backend.py @@ -0,0 +1,107 @@ +"""Storage backend protocol and local no-op implementation.""" + +import logging +from pathlib import Path +from typing import Protocol, runtime_checkable + +from linkedin_mcp_server.config.schema import StorageConfig + +logger = logging.getLogger(__name__) + + +class StorageSyncError(Exception): + """Raised when a required storage sync operation fails.""" + + +@runtime_checkable +class StorageBackend(Protocol): + def download(self, remote_key: str, local_path: Path) -> bool: ... + def upload(self, local_path: Path, remote_key: str) -> bool: ... + def delete(self, remote_key: str) -> bool: ... + + +class LocalBackend: + """No-op backend for local-only operation.""" + + def download(self, remote_key: str, local_path: Path) -> bool: + return True + + def upload(self, local_path: Path, remote_key: str) -> bool: + return True + + def delete(self, remote_key: str) -> bool: + return True + + +def _import_gcs_backend(): + from linkedin_mcp_server.storage.gcs import GCSBackend # type: ignore[import] + + return GCSBackend + + +def get_storage_backend(config: StorageConfig) -> StorageBackend: + """Create a storage backend from configuration.""" + if config.backend == "gcs": + try: + GCSBackend = _import_gcs_backend() + except ImportError: + raise ImportError( + "GCS storage backend requires google-cloud-storage. " + "Install with: pip install linkedin-scraper-mcp[gcs]" + ) + return GCSBackend(bucket=config.gcs_bucket, prefix=config.gcs_prefix) + return LocalBackend() + + +def _remote_key(prefix: str, username: str, filename: str) -> str: + """Build the GCS object key.""" + parts = [p for p in (prefix, username, filename) if p] + return "/".join(parts) + + +def sync_from_remote(auth_root: Path, username: str, backend: StorageBackend) -> bool: + """Download auth artifacts from remote storage. Raises on failure.""" + auth_root.mkdir(parents=True, exist_ok=True) + prefix = str(getattr(backend, "prefix", "")) + + for filename in ("cookies.json", "source-state.json"): + key = _remote_key(prefix, username, filename) + local = auth_root / filename + if not backend.download(key, local): + raise StorageSyncError( + f"Failed to download {filename} from remote storage. " + f"Key: {key}. Ensure the bucket exists and contains auth artifacts. " + f"Run --login on a machine with browser access to create them." + ) + logger.info("Auth state restored from remote storage for user %s", username) + return True + + +def sync_to_remote(auth_root: Path, username: str, backend: StorageBackend) -> bool: + """Upload auth artifacts to remote storage. Best-effort (logs, doesn't raise).""" + prefix = str(getattr(backend, "prefix", "")) + + for filename in ("cookies.json", "source-state.json"): + local = auth_root / filename + if not local.exists(): + logger.debug("Skipping upload of %s (not found locally)", filename) + continue + key = _remote_key(prefix, username, filename) + if not backend.upload(local, key): + logger.warning("Failed to upload %s to remote storage", filename) + return False + logger.info("Auth state synced to remote storage for user %s", username) + return True + + +def delete_remote(username: str, backend: StorageBackend) -> bool: + """Delete auth artifacts from remote storage. Best-effort.""" + prefix = str(getattr(backend, "prefix", "")) + + success = True + for filename in ("cookies.json", "source-state.json"): + key = _remote_key(prefix, username, filename) + if not backend.delete(key): + logger.warning("Failed to delete %s from remote storage", filename) + success = False + return success From f8faab8baa1241de7c7ea0b0c4a8897755bec334 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:45:16 +0100 Subject: [PATCH 04/15] test(storage): add sync orchestration tests with in-memory backend Co-Authored-By: Claude Sonnet 4.6 --- tests/test_storage.py | 164 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/test_storage.py diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 00000000..6f919517 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,164 @@ +from pathlib import Path + +import pytest + +from linkedin_mcp_server.config.schema import StorageConfig + + +class TestLocalBackend: + def test_download_returns_true(self, tmp_path): + from linkedin_mcp_server.storage.backend import LocalBackend + + backend = LocalBackend() + assert backend.download("key", tmp_path / "file.json") is True + + def test_upload_returns_true(self, tmp_path): + from linkedin_mcp_server.storage.backend import LocalBackend + + backend = LocalBackend() + assert backend.upload(tmp_path / "file.json", "key") is True + + def test_delete_returns_true(self): + from linkedin_mcp_server.storage.backend import LocalBackend + + backend = LocalBackend() + assert backend.delete("key") is True + + +class TestGetStorageBackend: + def test_returns_local_by_default(self): + from linkedin_mcp_server.storage.backend import ( + LocalBackend, + get_storage_backend, + ) + + config = StorageConfig() + backend = get_storage_backend(config) + assert isinstance(backend, LocalBackend) + + def test_gcs_raises_without_dependency(self, monkeypatch): + from linkedin_mcp_server.storage import backend as backend_module + + monkeypatch.setattr(backend_module, "_import_gcs_backend", _fake_import_error) + from linkedin_mcp_server.storage.backend import get_storage_backend + + config = StorageConfig(backend="gcs", gcs_bucket="b", username="u") + with pytest.raises(ImportError, match="linkedin-scraper-mcp\\[gcs\\]"): + get_storage_backend(config) + + +def _fake_import_error(): + raise ImportError("No module named 'google.cloud'") + + +class InMemoryBackend: + """Test double that stores data in a dict.""" + + def __init__(self): + self.objects: dict[str, bytes] = {} + self.prefix = "" + + def download(self, remote_key: str, local_path: Path) -> bool: + if remote_key not in self.objects: + return False + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(self.objects[remote_key]) + return True + + def upload(self, local_path: Path, remote_key: str) -> bool: + if not local_path.exists(): + return False + self.objects[remote_key] = local_path.read_bytes() + return True + + def delete(self, remote_key: str) -> bool: + self.objects.pop(remote_key, None) + return True + + +class FailingBackend: + """Test double that always fails.""" + + prefix = "" + + def download(self, remote_key: str, local_path: Path) -> bool: + return False + + def upload(self, local_path: Path, remote_key: str) -> bool: + return False + + def delete(self, remote_key: str) -> bool: + return False + + +class TestSyncFromRemote: + def test_downloads_both_files(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_from_remote + + backend = InMemoryBackend() + backend.objects["testuser/cookies.json"] = b'{"cookies": []}' + backend.objects["testuser/source-state.json"] = b'{"version": 1}' + sync_from_remote(tmp_path, "testuser", backend) + assert (tmp_path / "cookies.json").read_bytes() == b'{"cookies": []}' + assert (tmp_path / "source-state.json").read_bytes() == b'{"version": 1}' + + def test_raises_on_download_failure(self, tmp_path): + from linkedin_mcp_server.storage.backend import ( + StorageSyncError, + sync_from_remote, + ) + + backend = FailingBackend() + with pytest.raises(StorageSyncError, match="cookies.json"): + sync_from_remote(tmp_path, "testuser", backend) + + def test_creates_auth_root_if_missing(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_from_remote + + auth_root = tmp_path / "nonexistent" / "auth" + backend = InMemoryBackend() + backend.objects["u/cookies.json"] = b"{}" + backend.objects["u/source-state.json"] = b"{}" + sync_from_remote(auth_root, "u", backend) + assert auth_root.is_dir() + + +class TestSyncToRemote: + def test_uploads_both_files(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_to_remote + + (tmp_path / "cookies.json").write_text('{"c": 1}') + (tmp_path / "source-state.json").write_text('{"s": 1}') + backend = InMemoryBackend() + result = sync_to_remote(tmp_path, "testuser", backend) + assert result is True + assert backend.objects["testuser/cookies.json"] == b'{"c": 1}' + assert backend.objects["testuser/source-state.json"] == b'{"s": 1}' + + def test_skips_missing_files(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_to_remote + + backend = InMemoryBackend() + result = sync_to_remote(tmp_path, "testuser", backend) + assert result is True + assert len(backend.objects) == 0 + + def test_returns_false_on_failure(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_to_remote + + (tmp_path / "cookies.json").write_text("{}") + backend = FailingBackend() + result = sync_to_remote(tmp_path, "testuser", backend) + assert result is False + + +class TestDeleteRemote: + def test_deletes_both_keys(self): + from linkedin_mcp_server.storage.backend import delete_remote + + backend = InMemoryBackend() + backend.objects["testuser/cookies.json"] = b"{}" + backend.objects["testuser/source-state.json"] = b"{}" + result = delete_remote("testuser", backend) + assert result is True + assert len(backend.objects) == 0 From 6c223d3fc583653059bbc790ffa83e7606b36277 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:46:57 +0100 Subject: [PATCH 05/15] feat(storage): add GCSBackend implementation Co-Authored-By: Claude Sonnet 4.6 --- linkedin_mcp_server/storage/gcs.py | 72 ++++++++++++++++++++++++++++++ tests/test_storage.py | 24 ++++++++++ 2 files changed, 96 insertions(+) create mode 100644 linkedin_mcp_server/storage/gcs.py diff --git a/linkedin_mcp_server/storage/gcs.py b/linkedin_mcp_server/storage/gcs.py new file mode 100644 index 00000000..d1a0a6b2 --- /dev/null +++ b/linkedin_mcp_server/storage/gcs.py @@ -0,0 +1,72 @@ +"""Google Cloud Storage backend for auth-state persistence.""" + +import logging +from pathlib import Path + +from google.cloud import storage # type: ignore[import-untyped] + +logger = logging.getLogger(__name__) + + +class GCSBackend: + """Persist auth artifacts to a GCS bucket.""" + + def __init__(self, bucket: str, prefix: str = ""): + self.bucket = bucket + self.prefix = prefix + self._client = storage.Client() + self._bucket = self._client.bucket(bucket) + + def download(self, remote_key: str, local_path: Path) -> bool: + try: + blob = self._bucket.blob(remote_key) + if not blob.exists(): + logger.warning( + "GCS object not found: gs://%s/%s", self.bucket, remote_key + ) + return False + local_path.parent.mkdir(parents=True, exist_ok=True) + blob.download_to_filename(str(local_path)) + logger.debug( + "Downloaded gs://%s/%s → %s", self.bucket, remote_key, local_path + ) + return True + except Exception: + logger.warning( + "GCS download failed: gs://%s/%s", + self.bucket, + remote_key, + exc_info=True, + ) + return False + + def upload(self, local_path: Path, remote_key: str) -> bool: + try: + blob = self._bucket.blob(remote_key) + blob.upload_from_filename(str(local_path)) + logger.debug( + "Uploaded %s → gs://%s/%s", local_path, self.bucket, remote_key + ) + return True + except Exception: + logger.warning( + "GCS upload failed: %s → gs://%s/%s", + local_path, + self.bucket, + remote_key, + exc_info=True, + ) + return False + + def delete(self, remote_key: str) -> bool: + try: + blob = self._bucket.blob(remote_key) + if blob.exists(): + blob.delete() + logger.debug("Deleted gs://%s/%s", self.bucket, remote_key) + return True + except Exception: + logger.warning( + "GCS delete failed: gs://%s/%s", self.bucket, remote_key, exc_info=True + ) + return False diff --git a/tests/test_storage.py b/tests/test_storage.py index 6f919517..3f3a7659 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -162,3 +162,27 @@ def test_deletes_both_keys(self): result = delete_remote("testuser", backend) assert result is True assert len(backend.objects) == 0 + + +class TestGCSBackendImport: + def test_gcs_backend_has_required_methods(self): + """Verify GCSBackend satisfies the StorageBackend protocol.""" + try: + from linkedin_mcp_server.storage.gcs import GCSBackend + except ImportError: + pytest.skip("google-cloud-storage not installed") + + from linkedin_mcp_server.storage.backend import StorageBackend + + backend = GCSBackend(bucket="test", prefix="pfx") + assert isinstance(backend, StorageBackend) + + def test_gcs_backend_stores_config(self): + try: + from linkedin_mcp_server.storage.gcs import GCSBackend + except ImportError: + pytest.skip("google-cloud-storage not installed") + + backend = GCSBackend(bucket="my-bucket", prefix="my-prefix") + assert backend.bucket == "my-bucket" + assert backend.prefix == "my-prefix" From bd7866f81eb80ae895803cb533a682ff42a57be1 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:47:29 +0100 Subject: [PATCH 06/15] chore: add [gcs] optional dependency for google-cloud-storage Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 3 + uv.lock | 262 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2df7dfaf..5ec002fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ Changelog = "https://github.com/stickerdaniel/linkedin-mcp-server/releases" linkedin-mcp-server = "linkedin_mcp_server.cli_main:main" linkedin-scraper-mcp = "linkedin_mcp_server.cli_main:main" +[project.optional-dependencies] +gcs = ["google-cloud-storage>=2.0"] + [build-system] requires = ["setuptools>=68.0", "wheel"] build-backend = "setuptools.build_meta" diff --git a/uv.lock b/uv.lock index 0c2018a2..48ed6ff8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] [[package]] name = "aiofile" @@ -300,6 +304,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -686,6 +763,112 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/e3/747759eebc72e420c25903d6bc231d0ceb110b66ac7e6ee3f350417152cd/google_cloud_storage-3.10.0.tar.gz", hash = "sha256:1aeebf097c27d718d84077059a28d7e87f136f3700212215f1ceeae1d1c5d504", size = 17309829, upload-time = "2026-03-18T15:54:11.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/e2/d58442f4daee5babd9255cf492a1f3d114357164072f8339a22a3ad460a2/google_cloud_storage-3.10.0-py3-none-any.whl", hash = "sha256:0072e7783b201e45af78fd9779894cdb6bec2bf922ee932f3fcc16f8bce9b9a3", size = 324382, upload-time = "2026-03-18T15:54:10.091Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -960,6 +1143,11 @@ dependencies = [ { name = "python-dotenv" }, ] +[package.optional-dependencies] +gcs = [ + { name = "google-cloud-storage" }, +] + [package.dev-dependencies] dev = [ { name = "aiohttp" }, @@ -975,10 +1163,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=3.0.0" }, + { name = "google-cloud-storage", marker = "extra == 'gcs'", specifier = ">=2.0" }, { name = "inquirer", specifier = ">=3.4.0" }, { name = "patchright", specifier = ">=1.40.0" }, { name = "python-dotenv", specifier = ">=1.1.1" }, ] +provides-extras = ["gcs"] [package.metadata.requires-dev] dev = [ @@ -1335,6 +1525,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.4.4" @@ -1360,6 +1577,27 @@ memory = [ { name = "cachetools" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1699,6 +1937,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -1936,6 +2189,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.41.0" From b7aafb3b3d84329fcccff94489e5014da42371eb Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:50:24 +0100 Subject: [PATCH 07/15] feat(startup): sync auth state from remote storage before auth check --- linkedin_mcp_server/cli_main.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/linkedin_mcp_server/cli_main.py b/linkedin_mcp_server/cli_main.py index e5241c84..9205bde1 100644 --- a/linkedin_mcp_server/cli_main.py +++ b/linkedin_mcp_server/cli_main.py @@ -32,6 +32,7 @@ from linkedin_mcp_server.exceptions import CredentialsNotFoundError from linkedin_mcp_server.logging_config import configure_logging, teardown_trace_logging from linkedin_mcp_server.session_state import ( + auth_root_dir, get_runtime_id, load_runtime_state, load_source_state, @@ -40,6 +41,7 @@ runtime_storage_state_path, source_state_path, ) +from linkedin_mcp_server.storage import get_storage_backend, sync_from_remote from linkedin_mcp_server.server import create_mcp_server from linkedin_mcp_server.setup import run_interactive_setup, run_profile_creation @@ -341,6 +343,11 @@ def main() -> None: # Phase 1: Ensure Authentication is Ready (skip for OAuth — no cookie profile needed) try: + if config.storage.backend != "local": + assert config.storage.username is not None + backend = get_storage_backend(config.storage) + auth_root = auth_root_dir() + sync_from_remote(auth_root, config.storage.username, backend) if not (config.server.oauth and config.server.oauth.enabled): ensure_authentication_ready() if config.is_interactive: From f9581f53cec52ea7ad846906aed10bac1e79f9fb Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:51:48 +0100 Subject: [PATCH 08/15] feat(login): sync auth state to remote storage after login --- linkedin_mcp_server/setup.py | 18 +++++++++++++++++- tests/test_setup.py | 4 ++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/linkedin_mcp_server/setup.py b/linkedin_mcp_server/setup.py index 49f2bcc8..1e3a7e45 100644 --- a/linkedin_mcp_server/setup.py +++ b/linkedin_mcp_server/setup.py @@ -14,7 +14,13 @@ wait_for_manual_login, warm_up_browser, ) -from linkedin_mcp_server.session_state import portable_cookie_path, write_source_state +from linkedin_mcp_server.config import get_config +from linkedin_mcp_server.session_state import ( + auth_root_dir, + portable_cookie_path, + write_source_state, +) +from linkedin_mcp_server.storage import get_storage_backend, sync_to_remote from linkedin_mcp_server.drivers.browser import get_profile_dir @@ -84,6 +90,16 @@ async def interactive_login( print(" Cookies exported for Docker portability") source_state = write_source_state(user_data_dir) print(f" Source session generation: {source_state.login_generation}") + # Sync to remote storage if configured + config = get_config() + if config.storage.backend != "local": + assert config.storage.username is not None + storage_backend = get_storage_backend(config.storage) + auth_root = auth_root_dir(user_data_dir) + if sync_to_remote(auth_root, config.storage.username, storage_backend): + print(" Auth state synced to remote storage") + else: + print(" Warning: failed to sync auth state to remote storage") else: print( " Warning: cookie export failed; Docker bridge may not work. " diff --git a/tests/test_setup.py b/tests/test_setup.py index 68a6d9f9..f906637e 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -56,6 +56,10 @@ async def test_interactive_login_writes_source_state_when_cookie_export_succeeds "linkedin_mcp_server.setup.write_source_state", write_source_state ) monkeypatch.setattr("linkedin_mcp_server.setup.asyncio.sleep", AsyncMock()) + monkeypatch.setattr( + "linkedin_mcp_server.setup.get_config", + lambda: SimpleNamespace(storage=SimpleNamespace(backend="local")), + ) assert await interactive_login(tmp_path / "profile") is True From dd51f0f6f59dfed3d5f5483c6ad8b2aeedfc3949 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:52:28 +0100 Subject: [PATCH 09/15] feat(shutdown): sync auth state to remote storage after cookie export --- linkedin_mcp_server/drivers/browser.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/linkedin_mcp_server/drivers/browser.py b/linkedin_mcp_server/drivers/browser.py index a19cf832..cad55521 100644 --- a/linkedin_mcp_server/drivers/browser.py +++ b/linkedin_mcp_server/drivers/browser.py @@ -25,6 +25,7 @@ from linkedin_mcp_server.debug_utils import stabilize_navigation from linkedin_mcp_server.session_state import ( SourceState, + auth_root_dir, clear_runtime_profile, get_runtime_id, get_source_profile_dir, @@ -36,6 +37,7 @@ runtime_storage_state_path, write_runtime_state, ) +from linkedin_mcp_server.storage import get_storage_backend, sync_to_remote logger = logging.getLogger(__name__) @@ -501,6 +503,17 @@ async def close_browser() -> None: await browser.export_cookies(cookie_export_path) except Exception: logger.debug("Cookie export on close skipped", exc_info=True) + # Sync to remote storage if configured (best-effort) + if cookie_export_path is not None: + try: + config = get_config() + if config.storage.backend != "local": + assert config.storage.username is not None + storage_backend = get_storage_backend(config.storage) + auth_root = auth_root_dir() + sync_to_remote(auth_root, config.storage.username, storage_backend) + except Exception: + logger.debug("Remote storage sync on close skipped", exc_info=True) await browser.close() logger.info("Browser closed") From 8303087c12af466fa1187974280f6ea31a61e7f3 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:52:59 +0100 Subject: [PATCH 10/15] feat(logout): delete remote auth state when storage backend is configured --- linkedin_mcp_server/cli_main.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/linkedin_mcp_server/cli_main.py b/linkedin_mcp_server/cli_main.py index 9205bde1..840a7970 100644 --- a/linkedin_mcp_server/cli_main.py +++ b/linkedin_mcp_server/cli_main.py @@ -41,7 +41,11 @@ runtime_storage_state_path, source_state_path, ) -from linkedin_mcp_server.storage import get_storage_backend, sync_from_remote +from linkedin_mcp_server.storage import ( + delete_remote, + get_storage_backend, + sync_from_remote, +) from linkedin_mcp_server.server import create_mcp_server from linkedin_mcp_server.setup import run_interactive_setup, run_profile_creation @@ -107,6 +111,17 @@ def clear_profile_and_exit() -> None: if clear_auth_state(get_profile_dir()): print("✅ LinkedIn authentication state cleared successfully!") + # Delete remote auth state if configured + if config.storage.backend != "local": + try: + assert config.storage.username is not None + storage_backend = get_storage_backend(config.storage) + if delete_remote(config.storage.username, storage_backend): + print("✅ Remote auth state deleted") + else: + print("⚠️ Failed to delete remote auth state") + except Exception as e: + print(f"⚠️ Could not delete remote auth state: {e}") else: print("❌ Failed to clear authentication state") sys.exit(1) From d7949f4784221956bb8789cc318e1588562ccf55 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:59:28 +0100 Subject: [PATCH 11/15] fix: address code review findings for storage module - Guard against None bucket in get_storage_backend() - Replace assert with `or ""` fallback for username type narrowing - Skip storage sync when OAuth is enabled (no cookie auth needed) - Add missing delete_remote failure test Co-Authored-By: Claude Opus 4.6 (1M context) --- linkedin_mcp_server/cli_main.py | 10 +++++----- linkedin_mcp_server/drivers/browser.py | 5 +++-- linkedin_mcp_server/setup.py | 5 +++-- linkedin_mcp_server/storage/backend.py | 6 ++++++ tests/test_storage.py | 7 +++++++ 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/linkedin_mcp_server/cli_main.py b/linkedin_mcp_server/cli_main.py index 840a7970..a5dfb623 100644 --- a/linkedin_mcp_server/cli_main.py +++ b/linkedin_mcp_server/cli_main.py @@ -114,9 +114,8 @@ def clear_profile_and_exit() -> None: # Delete remote auth state if configured if config.storage.backend != "local": try: - assert config.storage.username is not None storage_backend = get_storage_backend(config.storage) - if delete_remote(config.storage.username, storage_backend): + if delete_remote(config.storage.username or "", storage_backend): print("✅ Remote auth state deleted") else: print("⚠️ Failed to delete remote auth state") @@ -358,11 +357,12 @@ def main() -> None: # Phase 1: Ensure Authentication is Ready (skip for OAuth — no cookie profile needed) try: - if config.storage.backend != "local": - assert config.storage.username is not None + if config.storage.backend != "local" and not ( + config.server.oauth and config.server.oauth.enabled + ): backend = get_storage_backend(config.storage) auth_root = auth_root_dir() - sync_from_remote(auth_root, config.storage.username, backend) + sync_from_remote(auth_root, config.storage.username or "", backend) if not (config.server.oauth and config.server.oauth.enabled): ensure_authentication_ready() if config.is_interactive: diff --git a/linkedin_mcp_server/drivers/browser.py b/linkedin_mcp_server/drivers/browser.py index cad55521..6b6c26cd 100644 --- a/linkedin_mcp_server/drivers/browser.py +++ b/linkedin_mcp_server/drivers/browser.py @@ -508,10 +508,11 @@ async def close_browser() -> None: try: config = get_config() if config.storage.backend != "local": - assert config.storage.username is not None storage_backend = get_storage_backend(config.storage) auth_root = auth_root_dir() - sync_to_remote(auth_root, config.storage.username, storage_backend) + sync_to_remote( + auth_root, config.storage.username or "", storage_backend + ) except Exception: logger.debug("Remote storage sync on close skipped", exc_info=True) await browser.close() diff --git a/linkedin_mcp_server/setup.py b/linkedin_mcp_server/setup.py index 1e3a7e45..60255941 100644 --- a/linkedin_mcp_server/setup.py +++ b/linkedin_mcp_server/setup.py @@ -93,10 +93,11 @@ async def interactive_login( # Sync to remote storage if configured config = get_config() if config.storage.backend != "local": - assert config.storage.username is not None storage_backend = get_storage_backend(config.storage) auth_root = auth_root_dir(user_data_dir) - if sync_to_remote(auth_root, config.storage.username, storage_backend): + if sync_to_remote( + auth_root, config.storage.username or "", storage_backend + ): print(" Auth state synced to remote storage") else: print(" Warning: failed to sync auth state to remote storage") diff --git a/linkedin_mcp_server/storage/backend.py b/linkedin_mcp_server/storage/backend.py index 094011f7..5a461f13 100644 --- a/linkedin_mcp_server/storage/backend.py +++ b/linkedin_mcp_server/storage/backend.py @@ -42,6 +42,12 @@ def _import_gcs_backend(): def get_storage_backend(config: StorageConfig) -> StorageBackend: """Create a storage backend from configuration.""" if config.backend == "gcs": + if not config.gcs_bucket: + from linkedin_mcp_server.config.schema import ConfigurationError + + raise ConfigurationError( + "AUTH_STORAGE_GCS_BUCKET is required when AUTH_STORAGE_BACKEND=gcs" + ) try: GCSBackend = _import_gcs_backend() except ImportError: diff --git a/tests/test_storage.py b/tests/test_storage.py index 3f3a7659..a1cad827 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -163,6 +163,13 @@ def test_deletes_both_keys(self): assert result is True assert len(backend.objects) == 0 + def test_returns_false_on_failure(self): + from linkedin_mcp_server.storage.backend import delete_remote + + backend = FailingBackend() + result = delete_remote("testuser", backend) + assert result is False + class TestGCSBackendImport: def test_gcs_backend_has_required_methods(self): From babca96c117fc53511e2a141d952e9c72a234c19 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:27:42 +0100 Subject: [PATCH 12/15] docs: add GCS auth storage design doc and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/design-gcs-auth-storage.md | 106 ++ .../plans/2026-03-20-gcs-auth-storage.md | 1043 +++++++++++++++++ 2 files changed, 1149 insertions(+) create mode 100644 docs/design-gcs-auth-storage.md create mode 100644 docs/superpowers/plans/2026-03-20-gcs-auth-storage.md diff --git a/docs/design-gcs-auth-storage.md b/docs/design-gcs-auth-storage.md new file mode 100644 index 00000000..ae2142a5 --- /dev/null +++ b/docs/design-gcs-auth-storage.md @@ -0,0 +1,106 @@ +# Design: GCS-backed auth state persistence + +Resolves: [#7](https://github.com/5queezer/linkedin-mcp-server/issues/7) + +## Problem + +Cloud Run cold starts wipe the filesystem. The server loses `cookies.json` and `source-state.json`, making LinkedIn auth unrecoverable without manual `--login`. + +## Key finding + +LinkedIn OAuth does not cover messaging/inbox access. The existing browser-based auth (cookie extraction) is the only viable approach. The repo's OAuth support protects the MCP endpoint only — it is separate from LinkedIn auth. + +## Solution + +Persist portable auth artifacts (`cookies.json` + `source-state.json`) to Google Cloud Storage. Restore on startup, re-sync after login and on shutdown. + +Cloud Run always runs as a **foreign runtime** — it bridges from cookies on every cold start. No full browser profile is persisted (avoids 50-200MB transfers and cross-platform issues). + +## Configuration + +``` +AUTH_STORAGE_BACKEND=local|gcs # default: local (no-op) +AUTH_STORAGE_GCS_BUCKET=my-bucket # required when backend=gcs +AUTH_STORAGE_GCS_PREFIX=linkedin-mcp # optional, default: empty +AUTH_STORAGE_USERNAME=williamhgates # required when backend != local +``` + +GCS object layout: + +``` +gs://{bucket}/{prefix}/{username}/cookies.json +gs://{bucket}/{prefix}/{username}/source-state.json +``` + +## Architecture + +### StorageBackend protocol + +```python +class StorageBackend(Protocol): + def download(self, remote_key: str, local_path: Path) -> bool: ... + def upload(self, local_path: Path, remote_key: str) -> bool: ... + def delete(self, remote_key: str) -> bool: ... +``` + +Implementations: +- `LocalBackend`: no-op, all methods return `True` +- `GCSBackend`: uses `google-cloud-storage`, authenticates via ADC (automatic on Cloud Run) + +### Package structure + +``` +linkedin_mcp_server/ +├── storage/ +│ ├── __init__.py # exports public API +│ ├── backend.py # StorageBackend protocol, LocalBackend, StorageSyncError +│ └── gcs.py # GCSBackend (lazy import) +``` + +`google-cloud-storage` is an optional `[gcs]` extra dependency. + +### Sync operations + +| Function | When called | Behavior on failure | +|----------|-------------|---------------------| +| `sync_from_remote()` | Startup, before auth validation | **Fail hard** — raise StorageSyncError | +| `sync_to_remote()` | After `--login`, on `close_browser()` shutdown | **Best-effort** — log warning, don't crash | +| `delete_remote()` | During `--logout` | Log warning on failure | + +### Hook points + +**`cli_main.py` — startup:** Call `sync_from_remote()` before `ensure_authentication_ready()`. + +**`setup.py` — post-login:** Call `sync_to_remote()` after `write_source_state()` and `export_cookies()`. + +**`browser.py` — shutdown:** Call `sync_to_remote()` after `export_cookies()` in `close_browser()`. + +## Constraints + +- 10s Cloud Run shutdown grace period is sufficient for cookie export + KB-sized GCS upload +- GCS default encryption (Google-managed AES-256) — no KMS +- `AUTH_STORAGE_USERNAME` env var required because at cold start there is no local state to extract a username from +- Config validation fails fast if `backend=gcs` but `gcs_bucket` or `username` is missing + +## Testing + +- Unit tests with mock `StorageBackend` (in-memory dict) +- Test sync_from raises on download failure +- Test sync_to logs but doesn't raise on upload failure +- Test config validation rejects incomplete GCS config +- No live GCS tests in CI + +## Decision log + +| # | Decision | Alternatives | Rationale | +|---|----------|-------------|-----------| +| 1 | Portable artifacts only | Full auth root, +derived snapshot | KB transfers, no cross-platform issues, bridge path works | +| 2 | StorageBackend protocol | No abstraction, lifecycle manager | Low overhead, extensible, testable via mock | +| 3 | Optional `[gcs]` extra | Required dependency | Keep base package lightweight | +| 4 | Sync on shutdown + after login | +periodic timer | Covers both flows without background complexity | +| 5 | Fail startup if GCS download fails | Fall back to local | Remote state is source of truth when configured | +| 6 | Best-effort upload on shutdown | Fail hard | Don't crash server over transient GCS error | +| 7 | Username via env var | Extract from cookies | No local state at cold start to extract from | +| 8 | User-keyed by LinkedIn username | SHA256 hash | Human-readable, auditable | +| 9 | GCS default encryption | Customer-managed KMS | Sufficient, no extra config | +| 10 | 10s shutdown grace | 30s | KB upload well within window | diff --git a/docs/superpowers/plans/2026-03-20-gcs-auth-storage.md b/docs/superpowers/plans/2026-03-20-gcs-auth-storage.md new file mode 100644 index 00000000..ccef49f2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-gcs-auth-storage.md @@ -0,0 +1,1043 @@ +# GCS Auth Storage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist LinkedIn auth artifacts (`cookies.json` + `source-state.json`) to GCS so Cloud Run cold starts can restore session state. + +**Architecture:** A `StorageBackend` protocol with `LocalBackend` (no-op) and `GCSBackend` implementations. Three sync functions hook into existing lifecycle points: startup (download), post-login (upload), shutdown (upload). `google-cloud-storage` is an optional `[gcs]` dependency. + +**Tech Stack:** Python 3.12+, google-cloud-storage, pytest, dataclasses + +**Design doc:** `docs/design-gcs-auth-storage.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `linkedin_mcp_server/storage/__init__.py` | Public API: re-exports protocol, backends, sync functions | +| Create | `linkedin_mcp_server/storage/backend.py` | `StorageBackend` protocol, `LocalBackend`, `StorageSyncError`, factory | +| Create | `linkedin_mcp_server/storage/gcs.py` | `GCSBackend` implementation (lazy import) | +| Modify | `linkedin_mcp_server/config/schema.py` | Add `StorageConfig` dataclass | +| Modify | `linkedin_mcp_server/config/loaders.py` | Load `AUTH_STORAGE_*` env vars | +| Modify | `linkedin_mcp_server/cli_main.py` | Call `sync_from_remote()` before auth check | +| Modify | `linkedin_mcp_server/setup.py` | Call `sync_to_remote()` after login | +| Modify | `linkedin_mcp_server/drivers/browser.py` | Call `sync_to_remote()` after cookie export in `close_browser()` | +| Modify | `pyproject.toml` | Add `[gcs]` optional dependency | +| Create | `tests/test_storage.py` | Unit tests for storage module | + +--- + +### Task 1: Add StorageConfig to config schema + +**Files:** +- Modify: `linkedin_mcp_server/config/schema.py:86-92` (AppConfig dataclass) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_config.py`: + +```python +from linkedin_mcp_server.config.schema import StorageConfig + + +class TestStorageConfig: + def test_defaults(self): + config = StorageConfig() + assert config.backend == "local" + assert config.gcs_bucket is None + assert config.gcs_prefix == "" + assert config.username is None + + def test_validate_gcs_requires_bucket(self): + config = StorageConfig(backend="gcs", username="testuser") + with pytest.raises(ConfigurationError, match="AUTH_STORAGE_GCS_BUCKET"): + config.validate() + + def test_validate_gcs_requires_username(self): + config = StorageConfig(backend="gcs", gcs_bucket="my-bucket") + with pytest.raises(ConfigurationError, match="AUTH_STORAGE_USERNAME"): + config.validate() + + def test_validate_gcs_valid(self): + config = StorageConfig(backend="gcs", gcs_bucket="my-bucket", username="testuser") + config.validate() # No error + + def test_validate_local_no_requirements(self): + config = StorageConfig() + config.validate() # No error + + def test_validate_invalid_backend(self): + config = StorageConfig(backend="s3") + with pytest.raises(ConfigurationError, match="local.*gcs"): + config.validate() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_config.py::TestStorageConfig -v` +Expected: FAIL with `ImportError` — `StorageConfig` doesn't exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Add to `linkedin_mcp_server/config/schema.py`, before the `AppConfig` class: + +```python +@dataclass +class StorageConfig: + """External auth-state storage configuration.""" + + backend: str = "local" + gcs_bucket: str | None = None + gcs_prefix: str = "" + username: str | None = None + + def validate(self) -> None: + """Validate storage configuration values.""" + if self.backend not in ("local", "gcs"): + raise ConfigurationError( + f"Invalid AUTH_STORAGE_BACKEND: '{self.backend}'. Must be 'local' or 'gcs'." + ) + if self.backend == "gcs": + if not self.gcs_bucket: + raise ConfigurationError( + "AUTH_STORAGE_GCS_BUCKET is required when AUTH_STORAGE_BACKEND=gcs" + ) + if not self.username: + raise ConfigurationError( + "AUTH_STORAGE_USERNAME is required when AUTH_STORAGE_BACKEND=gcs" + ) +``` + +Add `storage` field to `AppConfig`: + +```python +@dataclass +class AppConfig: + """Main application configuration.""" + + browser: BrowserConfig = field(default_factory=BrowserConfig) + server: ServerConfig = field(default_factory=ServerConfig) + storage: StorageConfig = field(default_factory=StorageConfig) + is_interactive: bool = field(default=False) +``` + +Add `self.storage.validate()` call inside `AppConfig.validate()`, after `self.browser.validate()`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_config.py::TestStorageConfig -v` +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add linkedin_mcp_server/config/schema.py tests/test_config.py +git commit -m "feat(config): add StorageConfig for external auth-state storage" +``` + +--- + +### Task 2: Load storage env vars in config loader + +**Files:** +- Modify: `linkedin_mcp_server/config/loaders.py:35-53` (EnvironmentKeys) and `loaders.py:68-166` (load_from_env) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_config.py`: + +```python +class TestStorageConfigEnvLoading: + def test_load_storage_backend_from_env(self, monkeypatch): + monkeypatch.setenv("AUTH_STORAGE_BACKEND", "gcs") + monkeypatch.setenv("AUTH_STORAGE_GCS_BUCKET", "my-bucket") + monkeypatch.setenv("AUTH_STORAGE_USERNAME", "testuser") + from linkedin_mcp_server.config.loaders import load_from_env + + config = AppConfig() + load_from_env(config) + assert config.storage.backend == "gcs" + assert config.storage.gcs_bucket == "my-bucket" + assert config.storage.username == "testuser" + + def test_load_storage_prefix_from_env(self, monkeypatch): + monkeypatch.setenv("AUTH_STORAGE_GCS_PREFIX", "linkedin-mcp") + from linkedin_mcp_server.config.loaders import load_from_env + + config = AppConfig() + load_from_env(config) + assert config.storage.gcs_prefix == "linkedin-mcp" + + def test_storage_defaults_when_no_env(self): + from linkedin_mcp_server.config.loaders import load_from_env + + config = AppConfig() + load_from_env(config) + assert config.storage.backend == "local" + assert config.storage.gcs_bucket is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_config.py::TestStorageConfigEnvLoading -v` +Expected: FAIL — `AppConfig` doesn't have `storage` attribute loaded from env yet (attribute exists from Task 1, but env loading doesn't populate it). + +- [ ] **Step 3: Write minimal implementation** + +Add to `EnvironmentKeys` class in `loaders.py`: + +```python + AUTH_STORAGE_BACKEND = "AUTH_STORAGE_BACKEND" + AUTH_STORAGE_GCS_BUCKET = "AUTH_STORAGE_GCS_BUCKET" + AUTH_STORAGE_GCS_PREFIX = "AUTH_STORAGE_GCS_PREFIX" + AUTH_STORAGE_USERNAME = "AUTH_STORAGE_USERNAME" +``` + +Add to `load_from_env()`, after the OAuth section (before `return config`): + +```python + # Auth-state storage + if storage_backend := os.environ.get(EnvironmentKeys.AUTH_STORAGE_BACKEND): + config.storage.backend = storage_backend + + if gcs_bucket := os.environ.get(EnvironmentKeys.AUTH_STORAGE_GCS_BUCKET): + config.storage.gcs_bucket = gcs_bucket + + if gcs_prefix := os.environ.get(EnvironmentKeys.AUTH_STORAGE_GCS_PREFIX): + config.storage.gcs_prefix = gcs_prefix + + if storage_username := os.environ.get(EnvironmentKeys.AUTH_STORAGE_USERNAME): + config.storage.username = storage_username +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_config.py::TestStorageConfigEnvLoading -v` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add linkedin_mcp_server/config/loaders.py tests/test_config.py +git commit -m "feat(config): load AUTH_STORAGE_* env vars" +``` + +--- + +### Task 3: Create storage backend protocol and LocalBackend + +**Files:** +- Create: `linkedin_mcp_server/storage/__init__.py` +- Create: `linkedin_mcp_server/storage/backend.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_storage.py`: + +```python +from pathlib import Path + +import pytest + +from linkedin_mcp_server.config.schema import StorageConfig + + +class TestLocalBackend: + def test_download_returns_true(self, tmp_path): + from linkedin_mcp_server.storage.backend import LocalBackend + + backend = LocalBackend() + assert backend.download("key", tmp_path / "file.json") is True + + def test_upload_returns_true(self, tmp_path): + from linkedin_mcp_server.storage.backend import LocalBackend + + backend = LocalBackend() + assert backend.upload(tmp_path / "file.json", "key") is True + + def test_delete_returns_true(self): + from linkedin_mcp_server.storage.backend import LocalBackend + + backend = LocalBackend() + assert backend.delete("key") is True + + +class TestGetStorageBackend: + def test_returns_local_by_default(self): + from linkedin_mcp_server.storage.backend import LocalBackend, get_storage_backend + + config = StorageConfig() + backend = get_storage_backend(config) + assert isinstance(backend, LocalBackend) + + def test_gcs_raises_without_dependency(self, monkeypatch): + from linkedin_mcp_server.storage import backend as backend_module + + monkeypatch.setattr(backend_module, "_import_gcs_backend", _fake_import_error) + from linkedin_mcp_server.storage.backend import get_storage_backend + + config = StorageConfig(backend="gcs", gcs_bucket="b", username="u") + with pytest.raises(ImportError, match="linkedin-scraper-mcp\\[gcs\\]"): + get_storage_backend(config) + + +def _fake_import_error(): + raise ImportError("No module named 'google.cloud'") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_storage.py -v` +Expected: FAIL with `ModuleNotFoundError` — `linkedin_mcp_server.storage` doesn't exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `linkedin_mcp_server/storage/__init__.py`: + +```python +"""External auth-state storage backends.""" + +from linkedin_mcp_server.storage.backend import ( + LocalBackend, + StorageBackend, + StorageSyncError, + get_storage_backend, + sync_from_remote, + sync_to_remote, + delete_remote, +) + +__all__ = [ + "LocalBackend", + "StorageBackend", + "StorageSyncError", + "get_storage_backend", + "sync_from_remote", + "sync_to_remote", + "delete_remote", +] +``` + +Create `linkedin_mcp_server/storage/backend.py`: + +```python +"""Storage backend protocol and local no-op implementation.""" + +import logging +from pathlib import Path +from typing import Protocol, runtime_checkable + +from linkedin_mcp_server.config.schema import StorageConfig + +logger = logging.getLogger(__name__) + + +class StorageSyncError(Exception): + """Raised when a required storage sync operation fails.""" + + +@runtime_checkable +class StorageBackend(Protocol): + def download(self, remote_key: str, local_path: Path) -> bool: ... + def upload(self, local_path: Path, remote_key: str) -> bool: ... + def delete(self, remote_key: str) -> bool: ... + + +class LocalBackend: + """No-op backend for local-only operation.""" + + def download(self, remote_key: str, local_path: Path) -> bool: + return True + + def upload(self, local_path: Path, remote_key: str) -> bool: + return True + + def delete(self, remote_key: str) -> bool: + return True + + +def _import_gcs_backend(): + from linkedin_mcp_server.storage.gcs import GCSBackend + + return GCSBackend + + +def get_storage_backend(config: StorageConfig) -> StorageBackend: + """Create a storage backend from configuration.""" + if config.backend == "gcs": + try: + GCSBackend = _import_gcs_backend() + except ImportError: + raise ImportError( + "GCS storage backend requires google-cloud-storage. " + "Install with: pip install linkedin-scraper-mcp[gcs]" + ) + return GCSBackend(bucket=config.gcs_bucket, prefix=config.gcs_prefix) + return LocalBackend() + + +def _remote_key(prefix: str, username: str, filename: str) -> str: + """Build the GCS object key.""" + parts = [p for p in (prefix, username, filename) if p] + return "/".join(parts) + + +def sync_from_remote( + auth_root: Path, username: str, backend: StorageBackend +) -> bool: + """Download auth artifacts from remote storage. Raises on failure.""" + auth_root.mkdir(parents=True, exist_ok=True) + prefix = "" + if hasattr(backend, "prefix"): + prefix = backend.prefix + + for filename in ("cookies.json", "source-state.json"): + key = _remote_key(prefix, username, filename) + local = auth_root / filename + if not backend.download(key, local): + raise StorageSyncError( + f"Failed to download {filename} from remote storage. " + f"Key: {key}. Ensure the bucket exists and contains auth artifacts. " + f"Run --login on a machine with browser access to create them." + ) + logger.info("Auth state restored from remote storage for user %s", username) + return True + + +def sync_to_remote( + auth_root: Path, username: str, backend: StorageBackend +) -> bool: + """Upload auth artifacts to remote storage. Best-effort (logs, doesn't raise).""" + prefix = "" + if hasattr(backend, "prefix"): + prefix = backend.prefix + + for filename in ("cookies.json", "source-state.json"): + local = auth_root / filename + if not local.exists(): + logger.debug("Skipping upload of %s (not found locally)", filename) + continue + key = _remote_key(prefix, username, filename) + if not backend.upload(local, key): + logger.warning("Failed to upload %s to remote storage", filename) + return False + logger.info("Auth state synced to remote storage for user %s", username) + return True + + +def delete_remote(username: str, backend: StorageBackend) -> bool: + """Delete auth artifacts from remote storage. Best-effort.""" + prefix = "" + if hasattr(backend, "prefix"): + prefix = backend.prefix + + success = True + for filename in ("cookies.json", "source-state.json"): + key = _remote_key(prefix, username, filename) + if not backend.delete(key): + logger.warning("Failed to delete %s from remote storage", filename) + success = False + return success +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_storage.py -v` +Expected: all 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add linkedin_mcp_server/storage/__init__.py linkedin_mcp_server/storage/backend.py tests/test_storage.py +git commit -m "feat(storage): add StorageBackend protocol and LocalBackend" +``` + +--- + +### Task 4: Add sync orchestration tests + +**Files:** +- Modify: `tests/test_storage.py` + +- [ ] **Step 1: Write failing tests for sync functions** + +Add to `tests/test_storage.py`: + +```python +class InMemoryBackend: + """Test double that stores data in a dict.""" + + def __init__(self): + self.objects: dict[str, bytes] = {} + self.prefix = "" + + def download(self, remote_key: str, local_path: Path) -> bool: + if remote_key not in self.objects: + return False + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(self.objects[remote_key]) + return True + + def upload(self, local_path: Path, remote_key: str) -> bool: + if not local_path.exists(): + return False + self.objects[remote_key] = local_path.read_bytes() + return True + + def delete(self, remote_key: str) -> bool: + self.objects.pop(remote_key, None) + return True + + +class FailingBackend: + """Test double that always fails.""" + + prefix = "" + + def download(self, remote_key: str, local_path: Path) -> bool: + return False + + def upload(self, local_path: Path, remote_key: str) -> bool: + return False + + def delete(self, remote_key: str) -> bool: + return False + + +class TestSyncFromRemote: + def test_downloads_both_files(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_from_remote + + backend = InMemoryBackend() + backend.objects["testuser/cookies.json"] = b'{"cookies": []}' + backend.objects["testuser/source-state.json"] = b'{"version": 1}' + + sync_from_remote(tmp_path, "testuser", backend) + + assert (tmp_path / "cookies.json").read_bytes() == b'{"cookies": []}' + assert (tmp_path / "source-state.json").read_bytes() == b'{"version": 1}' + + def test_raises_on_download_failure(self, tmp_path): + from linkedin_mcp_server.storage.backend import StorageSyncError, sync_from_remote + + backend = FailingBackend() + with pytest.raises(StorageSyncError, match="cookies.json"): + sync_from_remote(tmp_path, "testuser", backend) + + def test_creates_auth_root_if_missing(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_from_remote + + auth_root = tmp_path / "nonexistent" / "auth" + backend = InMemoryBackend() + backend.objects["u/cookies.json"] = b"{}" + backend.objects["u/source-state.json"] = b"{}" + + sync_from_remote(auth_root, "u", backend) + assert auth_root.is_dir() + + +class TestSyncToRemote: + def test_uploads_both_files(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_to_remote + + (tmp_path / "cookies.json").write_text('{"c": 1}') + (tmp_path / "source-state.json").write_text('{"s": 1}') + + backend = InMemoryBackend() + result = sync_to_remote(tmp_path, "testuser", backend) + + assert result is True + assert backend.objects["testuser/cookies.json"] == b'{"c": 1}' + assert backend.objects["testuser/source-state.json"] == b'{"s": 1}' + + def test_skips_missing_files(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_to_remote + + backend = InMemoryBackend() + result = sync_to_remote(tmp_path, "testuser", backend) + assert result is True + assert len(backend.objects) == 0 + + def test_returns_false_on_failure(self, tmp_path): + from linkedin_mcp_server.storage.backend import sync_to_remote + + (tmp_path / "cookies.json").write_text("{}") + backend = FailingBackend() + result = sync_to_remote(tmp_path, "testuser", backend) + assert result is False + + +class TestDeleteRemote: + def test_deletes_both_keys(self): + from linkedin_mcp_server.storage.backend import delete_remote + + backend = InMemoryBackend() + backend.objects["testuser/cookies.json"] = b"{}" + backend.objects["testuser/source-state.json"] = b"{}" + + result = delete_remote("testuser", backend) + assert result is True + assert len(backend.objects) == 0 +``` + +- [ ] **Step 2: Run test to verify they pass** + +These tests use the implementation from Task 3, so they should already pass. + +Run: `uv run pytest tests/test_storage.py -v` +Expected: all tests PASS (sync functions were implemented in Task 3). + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_storage.py +git commit -m "test(storage): add sync orchestration tests with in-memory backend" +``` + +--- + +### Task 5: Create GCSBackend + +**Files:** +- Create: `linkedin_mcp_server/storage/gcs.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_storage.py`: + +```python +class TestGCSBackendImport: + def test_gcs_backend_has_required_methods(self): + """Verify GCSBackend satisfies the StorageBackend protocol.""" + try: + from linkedin_mcp_server.storage.gcs import GCSBackend + except ImportError: + pytest.skip("google-cloud-storage not installed") + + from linkedin_mcp_server.storage.backend import StorageBackend + + backend = GCSBackend(bucket="test", prefix="pfx") + assert isinstance(backend, StorageBackend) + + def test_gcs_backend_stores_config(self): + try: + from linkedin_mcp_server.storage.gcs import GCSBackend + except ImportError: + pytest.skip("google-cloud-storage not installed") + + backend = GCSBackend(bucket="my-bucket", prefix="my-prefix") + assert backend.bucket == "my-bucket" + assert backend.prefix == "my-prefix" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_storage.py::TestGCSBackendImport -v` +Expected: FAIL with `ModuleNotFoundError` — `linkedin_mcp_server.storage.gcs` doesn't exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `linkedin_mcp_server/storage/gcs.py`: + +```python +"""Google Cloud Storage backend for auth-state persistence.""" + +import logging +from pathlib import Path + +from google.cloud import storage # type: ignore[import-untyped] + +logger = logging.getLogger(__name__) + + +class GCSBackend: + """Persist auth artifacts to a GCS bucket.""" + + def __init__(self, bucket: str, prefix: str = ""): + self.bucket = bucket + self.prefix = prefix + self._client = storage.Client() + self._bucket = self._client.bucket(bucket) + + def download(self, remote_key: str, local_path: Path) -> bool: + try: + blob = self._bucket.blob(remote_key) + if not blob.exists(): + logger.warning("GCS object not found: gs://%s/%s", self.bucket, remote_key) + return False + local_path.parent.mkdir(parents=True, exist_ok=True) + blob.download_to_filename(str(local_path)) + logger.debug("Downloaded gs://%s/%s → %s", self.bucket, remote_key, local_path) + return True + except Exception: + logger.warning( + "GCS download failed: gs://%s/%s", self.bucket, remote_key, exc_info=True + ) + return False + + def upload(self, local_path: Path, remote_key: str) -> bool: + try: + blob = self._bucket.blob(remote_key) + blob.upload_from_filename(str(local_path)) + logger.debug("Uploaded %s → gs://%s/%s", local_path, self.bucket, remote_key) + return True + except Exception: + logger.warning( + "GCS upload failed: %s → gs://%s/%s", + local_path, + self.bucket, + remote_key, + exc_info=True, + ) + return False + + def delete(self, remote_key: str) -> bool: + try: + blob = self._bucket.blob(remote_key) + if blob.exists(): + blob.delete() + logger.debug("Deleted gs://%s/%s", self.bucket, remote_key) + return True + except Exception: + logger.warning( + "GCS delete failed: gs://%s/%s", self.bucket, remote_key, exc_info=True + ) + return False +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_storage.py::TestGCSBackendImport -v` +Expected: PASS if `google-cloud-storage` is installed, SKIP otherwise. Both are acceptable. + +- [ ] **Step 5: Commit** + +```bash +git add linkedin_mcp_server/storage/gcs.py tests/test_storage.py +git commit -m "feat(storage): add GCSBackend implementation" +``` + +--- + +### Task 6: Add `[gcs]` optional dependency + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add optional dependency** + +Add after the `[project.scripts]` section in `pyproject.toml`: + +```toml +[project.optional-dependencies] +gcs = ["google-cloud-storage>=2.0"] +``` + +- [ ] **Step 2: Sync the lock file** + +Run: `uv sync` +Expected: lock file updates, no errors. + +- [ ] **Step 3: Verify optional install works** + +Run: `uv sync --extra gcs` +Expected: `google-cloud-storage` installs successfully. + +- [ ] **Step 4: Run the GCS backend test with dependency available** + +Run: `uv run pytest tests/test_storage.py::TestGCSBackendImport -v` +Expected: PASS (not SKIP, since `google-cloud-storage` is now installed). + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "chore: add [gcs] optional dependency for google-cloud-storage" +``` + +--- + +### Task 7: Hook into cli_main.py startup + +**Files:** +- Modify: `linkedin_mcp_server/cli_main.py:341-348` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_storage.py`: + +```python +class TestStartupHook: + def test_sync_from_remote_called_before_auth_check(self, tmp_path, monkeypatch): + """Verify that sync_from_remote is invoked when storage backend is gcs.""" + call_log = [] + + monkeypatch.setenv("AUTH_STORAGE_BACKEND", "gcs") + monkeypatch.setenv("AUTH_STORAGE_GCS_BUCKET", "test-bucket") + monkeypatch.setenv("AUTH_STORAGE_USERNAME", "testuser") + + import linkedin_mcp_server.storage.backend as backend_mod + + original_sync = backend_mod.sync_from_remote + + def mock_sync(auth_root, username, backend): + call_log.append(("sync_from_remote", username)) + # Write fake auth files so auth check passes + auth_root.mkdir(parents=True, exist_ok=True) + (auth_root / "cookies.json").write_text("[]") + (auth_root / "source-state.json").write_text('{"version":1}') + return True + + monkeypatch.setattr(backend_mod, "sync_from_remote", mock_sync) + + from linkedin_mcp_server.config import get_config + + config = get_config() + assert config.storage.backend == "gcs" + assert len(call_log) == 0 # Not called yet — integration requires running main() +``` + +- [ ] **Step 2: Run test to verify it passes** (this is a config-level check) + +Run: `uv run pytest tests/test_storage.py::TestStartupHook -v` +Expected: PASS. + +- [ ] **Step 3: Write the hook implementation** + +In `linkedin_mcp_server/cli_main.py`, add import at the top: + +```python +from linkedin_mcp_server.session_state import auth_root_dir +from linkedin_mcp_server.storage import get_storage_backend, sync_from_remote +``` + +In the `main()` function, insert **before** the `ensure_authentication_ready()` call (line 344), inside the same try block: + +```python + if config.storage.backend != "local": + backend = get_storage_backend(config.storage) + auth_root = auth_root_dir() + sync_from_remote(auth_root, config.storage.username, backend) +``` + +The resulting code block should read: + +```python + try: + if config.storage.backend != "local": + backend = get_storage_backend(config.storage) + auth_root = auth_root_dir() + sync_from_remote(auth_root, config.storage.username, backend) + if not (config.server.oauth and config.server.oauth.enabled): + ensure_authentication_ready() +``` + +- [ ] **Step 4: Run full test suite to check nothing broke** + +Run: `uv run pytest -v` +Expected: all existing tests PASS (local backend is a no-op, no env vars set in other tests). + +- [ ] **Step 5: Commit** + +```bash +git add linkedin_mcp_server/cli_main.py +git commit -m "feat(startup): sync auth state from remote storage before auth check" +``` + +--- + +### Task 8: Hook into setup.py post-login + +**Files:** +- Modify: `linkedin_mcp_server/setup.py:83-91` + +- [ ] **Step 1: Write the hook implementation** + +In `linkedin_mcp_server/setup.py`, add imports at the top: + +```python +from linkedin_mcp_server.config import get_config +from linkedin_mcp_server.session_state import auth_root_dir +from linkedin_mcp_server.storage import get_storage_backend, sync_to_remote +``` + +In `interactive_login()`, after the `write_source_state()` call (line 85-86), add: + +```python + # Sync to remote storage if configured + config = get_config() + if config.storage.backend != "local": + storage_backend = get_storage_backend(config.storage) + auth_root = auth_root_dir(user_data_dir) + if sync_to_remote(auth_root, config.storage.username, storage_backend): + print(" Auth state synced to remote storage") + else: + print(" Warning: failed to sync auth state to remote storage") +``` + +- [ ] **Step 2: Run full test suite** + +Run: `uv run pytest -v` +Expected: all tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add linkedin_mcp_server/setup.py +git commit -m "feat(login): sync auth state to remote storage after login" +``` + +--- + +### Task 9: Hook into browser.py shutdown + +**Files:** +- Modify: `linkedin_mcp_server/drivers/browser.py:486-505` + +- [ ] **Step 1: Write the hook implementation** + +In `linkedin_mcp_server/drivers/browser.py`, add imports at the top: + +```python +from linkedin_mcp_server.session_state import auth_root_dir +from linkedin_mcp_server.storage import get_storage_backend, sync_to_remote +``` + +In `close_browser()`, after the cookie export try/except block (after line 503), add: + +```python + # Sync to remote storage if configured (best-effort) + if cookie_export_path is not None: + try: + config = get_config() + if config.storage.backend != "local": + storage_backend = get_storage_backend(config.storage) + auth_root = auth_root_dir() + sync_to_remote(auth_root, config.storage.username, storage_backend) + except Exception: + logger.debug("Remote storage sync on close skipped", exc_info=True) +``` + +The resulting `close_browser()` should read: + +```python +async def close_browser() -> None: + """Close the browser and cleanup resources.""" + global _browser, _browser_cookie_export_path + + browser = _browser + cookie_export_path = _browser_cookie_export_path + _browser = None + _browser_cookie_export_path = None + + if browser is None: + return + + logger.info("Closing browser...") + if cookie_export_path is not None: + try: + await browser.export_cookies(cookie_export_path) + except Exception: + logger.debug("Cookie export on close skipped", exc_info=True) + # Sync to remote storage if configured (best-effort) + if cookie_export_path is not None: + try: + config = get_config() + if config.storage.backend != "local": + storage_backend = get_storage_backend(config.storage) + auth_root = auth_root_dir() + sync_to_remote(auth_root, config.storage.username, storage_backend) + except Exception: + logger.debug("Remote storage sync on close skipped", exc_info=True) + await browser.close() + logger.info("Browser closed") +``` + +- [ ] **Step 2: Run full test suite** + +Run: `uv run pytest -v` +Expected: all tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add linkedin_mcp_server/drivers/browser.py +git commit -m "feat(shutdown): sync auth state to remote storage after cookie export" +``` + +--- + +### Task 10: Hook into cli_main.py logout + +**Files:** +- Modify: `linkedin_mcp_server/cli_main.py:70-112` (clear_profile_and_exit) + +- [ ] **Step 1: Write the hook implementation** + +In `linkedin_mcp_server/cli_main.py`, add `delete_remote` to the existing storage import: + +```python +from linkedin_mcp_server.storage import get_storage_backend, sync_from_remote, delete_remote +``` + +In `clear_profile_and_exit()`, after `clear_auth_state(get_profile_dir())` succeeds (line 106-107), add: + +```python + # Delete remote auth state if configured + if config.storage.backend != "local": + try: + storage_backend = get_storage_backend(config.storage) + if delete_remote(config.storage.username, storage_backend): + print("✅ Remote auth state deleted") + else: + print("⚠️ Failed to delete remote auth state") + except Exception as e: + print(f"⚠️ Could not delete remote auth state: {e}") +``` + +Note: `config` is already available in `clear_profile_and_exit()` (line 72). + +- [ ] **Step 2: Run full test suite** + +Run: `uv run pytest -v` +Expected: all tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add linkedin_mcp_server/cli_main.py +git commit -m "feat(logout): delete remote auth state when storage backend is configured" +``` + +--- + +### Task 11: Lint, format, type-check + +- [ ] **Step 1: Run linter** + +Run: `uv run ruff check . --fix` +Expected: no errors (or auto-fixed). + +- [ ] **Step 2: Run formatter** + +Run: `uv run ruff format .` +Expected: files formatted. + +- [ ] **Step 3: Run type checker** + +Run: `uv run ty check` +Expected: no new errors from the storage module. Existing errors (if any) should not increase. + +- [ ] **Step 4: Run full test suite** + +Run: `uv run pytest --cov -v` +Expected: all tests PASS, coverage includes new storage module. + +- [ ] **Step 5: Commit any formatting/lint fixes** + +```bash +git add -u +git commit -m "style: lint and format storage module" +``` From e36b70d3c1b6629c186e64b257bd94fc14c33c27 Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:37:03 +0100 Subject: [PATCH 13/15] chore: include [gcs] extra in Docker image Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b88f9b15..eefc142d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf / # Set browser install location (Patchright reads PLAYWRIGHT_BROWSERS_PATH internally) ENV PLAYWRIGHT_BROWSERS_PATH=/opt/patchright # Install dependencies, system libs for Chromium, and patched Chromium binary -RUN uv sync --frozen && \ +RUN uv sync --frozen --extra gcs && \ uv run patchright install-deps chromium && \ uv run patchright install chromium && \ chmod -R 755 /opt/patchright From e78acba941672fd7181dd39399a0859d6129d43f Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:18:54 +0100 Subject: [PATCH 14/15] fix: run GCS sync even when OAuth is enabled Cloud Run needs both: OAuth protects the MCP endpoint, AND cookie-based auth is needed for LinkedIn scraping. The storage sync must run regardless of OAuth mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- linkedin_mcp_server/cli_main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/linkedin_mcp_server/cli_main.py b/linkedin_mcp_server/cli_main.py index a5dfb623..4d9cbd28 100644 --- a/linkedin_mcp_server/cli_main.py +++ b/linkedin_mcp_server/cli_main.py @@ -357,9 +357,7 @@ def main() -> None: # Phase 1: Ensure Authentication is Ready (skip for OAuth — no cookie profile needed) try: - if config.storage.backend != "local" and not ( - config.server.oauth and config.server.oauth.enabled - ): + if config.storage.backend != "local": backend = get_storage_backend(config.storage) auth_root = auth_root_dir() sync_from_remote(auth_root, config.storage.username or "", backend) From 8e74a2b696d5cec35b6611162a25dbf67d19690a Mon Sep 17 00:00:00 2001 From: Christian Pojoni <34570565+5queezer@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:22:17 +0100 Subject: [PATCH 15/15] fix: skip profile dir check when remote storage provides auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AUTH_STORAGE_BACKEND=gcs, only cookies.json and source-state.json are synced — the Chromium profile directory doesn't exist locally. Relax the startup check to only require source state + cookies when remote storage is configured, since Cloud Run always bridges from cookies as a foreign runtime. Co-Authored-By: Claude Opus 4.6 (1M context) --- linkedin_mcp_server/drivers/browser.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/linkedin_mcp_server/drivers/browser.py b/linkedin_mcp_server/drivers/browser.py index 6b6c26cd..76480059 100644 --- a/linkedin_mcp_server/drivers/browser.py +++ b/linkedin_mcp_server/drivers/browser.py @@ -373,7 +373,18 @@ async def get_or_create_browser( source_profile_dir = get_profile_dir() cookie_path = portable_cookie_path(source_profile_dir) source_state = load_source_state(source_profile_dir) - if ( + + # When remote storage is configured, the profile dir may not exist locally + # because only cookies.json + source-state.json are synced from GCS. + # The container always runs as a foreign runtime and bridges from cookies. + remote_storage = get_config().storage.backend != "local" + if remote_storage: + if not source_state or not cookie_path.exists(): + raise AuthenticationError( + "No source authentication found in remote storage. " + "Run --login on a machine with browser access to create auth artifacts." + ) + elif ( not source_state or not profile_exists(source_profile_dir) or not cookie_path.exists()