From f30730aea6d6e3cdaaf4a1a56dbbbf59a7ed8016 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:36:51 -0400 Subject: [PATCH 1/8] =?UTF-8?q?Rename=20package=20synology-mcp=20=E2=86=92?= =?UTF-8?q?=20mcp-synology,=20add=20transfer=20module=20and=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename Python package from synology_mcp to mcp_synology to match PyPI project name (mcp-synology). Includes all import updates across source, tests, docs, and config. New features: - File transfer tools (upload_file, download_file) with progress callbacks, disk space preflight, and partial download cleanup - Project icons (light/dark SVGs, PNGs at 16-256px, favicon.ico) exposed via MCP interface - Dedicated test-publish.yml workflow for TestPyPI - Migration script for existing users (scripts/migrate-from-synology-mcp.py) - Virtual DSM test framework (tests/vdsm/) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 24 +- .github/workflows/test-publish.yml | 49 ++ .gitignore | 5 +- CHANGELOG.md | 30 +- CLAUDE.md | 20 +- DEVELOPMENT.md | 69 ++- LICENSE | 211 ++++++- README.md | 64 +- docs/credentials.md | 36 +- docs/specs/architecture.md | 32 +- docs/specs/config-schema-spec.md | 50 +- docs/specs/filestation-module-spec.md | 97 ++- docs/specs/project-scaffolding-spec.md | 44 +- examples/config-minimal.yaml | 2 +- examples/config-power-user.yaml | 8 +- pyproject.toml | 38 +- scripts/migrate-from-synology-mcp.py | 210 +++++++ scripts/vdsm_setup.py | 152 +++++ src/mcp_synology/__init__.py | 5 + src/mcp_synology/__main__.py | 5 + .../cli/__init__.py | 6 +- .../cli/check.py | 12 +- .../cli/logging_.py | 0 .../cli/main.py | 20 +- .../cli/setup.py | 34 +- .../cli/version.py | 10 +- .../core/__init__.py | 0 .../core/auth.py | 22 +- .../core/client.py | 265 +++++++- .../core/config.py | 22 +- .../core/errors.py | 6 +- .../core/formatting.py | 0 .../core/state.py | 6 +- src/mcp_synology/favicon.ico | Bin 0 -> 15086 bytes .../icons/mcp-synology-logo-dark-128.png | Bin 0 -> 8228 bytes .../icons/mcp-synology-logo-dark-16.png | Bin 0 -> 650 bytes .../icons/mcp-synology-logo-dark-256.png | Bin 0 -> 15761 bytes .../icons/mcp-synology-logo-dark-32.png | Bin 0 -> 1656 bytes .../icons/mcp-synology-logo-dark-48.png | Bin 0 -> 2779 bytes .../icons/mcp-synology-logo-dark-64.png | Bin 0 -> 3810 bytes .../icons/mcp-synology-logo-dark.svg | 42 ++ .../icons/mcp-synology-logo-light-128.png | Bin 0 -> 8035 bytes .../icons/mcp-synology-logo-light-16.png | Bin 0 -> 648 bytes .../icons/mcp-synology-logo-light-256.png | Bin 0 -> 15378 bytes .../icons/mcp-synology-logo-light-32.png | Bin 0 -> 1632 bytes .../icons/mcp-synology-logo-light-48.png | Bin 0 -> 2723 bytes .../icons/mcp-synology-logo-light-64.png | Bin 0 -> 3759 bytes .../icons/mcp-synology-logo-light.svg | 42 ++ .../instructions/server.md | 18 +- .../modules/__init__.py | 2 +- .../modules/filestation/__init__.py | 136 ++++- .../modules/filestation/helpers.py | 2 +- .../modules/filestation/listing.py | 8 +- .../modules/filestation/metadata.py | 8 +- .../modules/filestation/operations.py | 8 +- .../modules/filestation/search.py | 8 +- .../modules/filestation/transfer.py | 197 ++++++ .../modules/system/__init__.py | 8 +- .../modules/system/info.py | 6 +- .../modules/system/utilization.py | 6 +- src/{synology_mcp => mcp_synology}/py.typed | 0 src/{synology_mcp => mcp_synology}/server.py | 53 +- src/synology_mcp/__init__.py | 5 - src/synology_mcp/__main__.py | 5 - tests/conftest.py | 8 +- tests/core/test_auth.py | 44 +- tests/core/test_cli.py | 116 ++-- tests/core/test_client.py | 6 +- tests/core/test_config.py | 4 +- tests/core/test_errors.py | 2 +- tests/core/test_formatting.py | 2 +- tests/core/test_server.py | 8 +- tests/core/test_state.py | 4 +- tests/integration_config.yaml.example | 8 +- tests/modules/filestation/test_helpers.py | 2 +- tests/modules/filestation/test_listing.py | 4 +- tests/modules/filestation/test_metadata.py | 4 +- tests/modules/filestation/test_operations.py | 4 +- tests/modules/filestation/test_search.py | 4 +- tests/modules/filestation/test_transfer.py | 390 ++++++++++++ tests/modules/test_module_system.py | 4 +- tests/test_e2e.py | 10 +- tests/test_integration.py | 223 ++++++- tests/vdsm/__init__.py | 0 tests/vdsm/config.py | 95 +++ tests/vdsm/conftest.py | 203 ++++++ tests/vdsm/container.py | 216 +++++++ tests/vdsm/golden_image.py | 119 ++++ tests/vdsm/setup_dsm.py | 578 ++++++++++++++++++ tests/vdsm/test_vdsm_integration.py | 46 ++ uv.lock | 381 ++++++++++-- 92 files changed, 4094 insertions(+), 501 deletions(-) create mode 100644 .github/workflows/test-publish.yml create mode 100755 scripts/migrate-from-synology-mcp.py create mode 100755 scripts/vdsm_setup.py create mode 100644 src/mcp_synology/__init__.py create mode 100644 src/mcp_synology/__main__.py rename src/{synology_mcp => mcp_synology}/cli/__init__.py (76%) rename src/{synology_mcp => mcp_synology}/cli/check.py (85%) rename src/{synology_mcp => mcp_synology}/cli/logging_.py (100%) rename src/{synology_mcp => mcp_synology}/cli/main.py (89%) rename src/{synology_mcp => mcp_synology}/cli/setup.py (93%) rename src/{synology_mcp => mcp_synology}/cli/version.py (95%) rename src/{synology_mcp => mcp_synology}/core/__init__.py (100%) rename src/{synology_mcp => mcp_synology}/core/auth.py (92%) rename src/{synology_mcp => mcp_synology}/core/client.py (53%) rename src/{synology_mcp => mcp_synology}/core/config.py (94%) rename src/{synology_mcp => mcp_synology}/core/errors.py (95%) rename src/{synology_mcp => mcp_synology}/core/formatting.py (100%) rename src/{synology_mcp => mcp_synology}/core/state.py (90%) create mode 100644 src/mcp_synology/favicon.ico create mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-128.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-16.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-256.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-32.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-48.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-64.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark.svg create mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-128.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-16.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-256.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-32.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-48.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-64.png create mode 100644 src/mcp_synology/icons/mcp-synology-logo-light.svg rename src/{synology_mcp => mcp_synology}/instructions/server.md (76%) rename src/{synology_mcp => mcp_synology}/modules/__init__.py (99%) rename src/{synology_mcp => mcp_synology}/modules/filestation/__init__.py (75%) rename src/{synology_mcp => mcp_synology}/modules/filestation/helpers.py (98%) rename src/{synology_mcp => mcp_synology}/modules/filestation/listing.py (96%) rename src/{synology_mcp => mcp_synology}/modules/filestation/metadata.py (96%) rename src/{synology_mcp => mcp_synology}/modules/filestation/operations.py (98%) rename src/{synology_mcp => mcp_synology}/modules/filestation/search.py (97%) create mode 100644 src/mcp_synology/modules/filestation/transfer.py rename src/{synology_mcp => mcp_synology}/modules/system/__init__.py (92%) rename src/{synology_mcp => mcp_synology}/modules/system/info.py (95%) rename src/{synology_mcp => mcp_synology}/modules/system/utilization.py (96%) rename src/{synology_mcp => mcp_synology}/py.typed (100%) rename src/{synology_mcp => mcp_synology}/server.py (82%) delete mode 100644 src/synology_mcp/__init__.py delete mode 100644 src/synology_mcp/__main__.py create mode 100644 tests/modules/filestation/test_transfer.py create mode 100644 tests/vdsm/__init__.py create mode 100644 tests/vdsm/config.py create mode 100644 tests/vdsm/conftest.py create mode 100644 tests/vdsm/container.py create mode 100644 tests/vdsm/golden_image.py create mode 100644 tests/vdsm/setup_dsm.py create mode 100644 tests/vdsm/test_vdsm_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a026ae6..f0ab311 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: uv sync --extra dev - - run: uv run pytest --cov=synology_mcp --cov-report=xml + - run: uv run pytest --cov=mcp_synology --cov-report=xml - uses: codecov/codecov-action@v4 if: matrix.python-version == '3.12' with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53b05a7..571fea8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,8 +3,7 @@ name: Publish on: push: tags: - - 'v*' # automatic: publishes to PyPI on version tag - workflow_dispatch: # manual: publishes to TestPyPI + - 'v*' env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -33,28 +32,8 @@ jobs: name: dist path: dist/ - publish-testpypi: - needs: build - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - environment: testpypi - permissions: - id-token: write - steps: - - name: Download distributions - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ - - - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - publish-pypi: needs: build - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: pypi permissions: @@ -71,7 +50,6 @@ jobs: github-release: needs: publish-pypi - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/test-publish.yml b/.github/workflows/test-publish.yml new file mode 100644 index 0000000..68e9272 --- /dev/null +++ b/.github/workflows/test-publish.yml @@ -0,0 +1,49 @@ +name: Test Publish + +on: + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Run tests + run: | + uv sync --extra dev + uv run pytest + + - name: Build distributions + run: uv build + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index b488746..e26182f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ -# synology-mcp +# mcp-synology tests/integration_config.yaml +# Virtual DSM test infrastructure (golden images ~2GB each) +.vdsm/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/CHANGELOG.md b/CHANGELOG.md index 5732a7a..bbaa4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.4.0 (2026-04-05) + +### Breaking Changes + +- **Package renamed** — `synology-mcp` → `mcp-synology` (distribution, CLI command, config paths, state paths, keyring service) +- **Python import renamed** — `synology_mcp` → `mcp_synology` +- **Config directory** — `~/.config/synology-mcp/` → `~/.config/mcp-synology/` +- **State directory** — `~/.local/state/synology-mcp/` → `~/.local/state/mcp-synology/` +- **Keyring service** — `synology-mcp/{instance_id}` → `mcp-synology/{instance_id}` (re-run `mcp-synology setup`) +- **DSM session/device name** — `SynologyMCP` → `MCPSynology` +- **License** — MIT → Apache 2.0 + +### Migration + +A migration script handles config, state, and keyring automatically: + +```bash +uv tool install mcp-synology +uv run python scripts/migrate-from-synology-mcp.py # dry run — preview changes +uv run python scripts/migrate-from-synology-mcp.py --apply # apply changes +``` + +Then update Claude Desktop config: change `"command"` from `"synology-mcp"` to `"mcp-synology"`. + ## 0.3.1 (2026-03-18) ### Features @@ -38,7 +62,7 @@ Major refactor: CLI split, module registration system, DSM API fixes, integratio ### Breaking Changes -- **CLI is now a package** — `src/synology_mcp/cli.py` split into `cli/` package with 6 submodules (main, setup, check, version, logging_). Backward-compatible re-exports via `cli/__init__.py` +- **CLI is now a package** — `src/mcp_synology/cli.py` split into `cli/` package with 6 submodules (main, setup, check, version, logging_). Backward-compatible re-exports via `cli/__init__.py` ### Bug Fixes @@ -83,7 +107,7 @@ Code quality fixes from second external review. ### Documentation -- README install updated to `uv tool install synology-mcp` (PyPI) instead of git URL +- README install updated to `uv tool install mcp-synology` (PyPI) instead of git URL ## 0.2.1 (2026-03-18) @@ -139,7 +163,7 @@ Initial release. - **File Station module** — 12 tools for managing files on Synology NAS: - READ: list_shares, list_files, list_recycle_bin, search_files, get_file_info, get_dir_size - WRITE: create_folder, rename, copy_files, move_files, delete_files, restore_from_recycle_bin -- **Interactive setup** — `synology-mcp setup` creates config, stores credentials, handles 2FA, emits Claude Desktop snippet +- **Interactive setup** — `mcp-synology setup` creates config, stores credentials, handles 2FA, emits Claude Desktop snippet - **2FA support** — auto-detected device token bootstrap with silent re-authentication - **Secure credentials** — OS keyring integration (macOS Keychain, Windows Credential Manager, Linux GNOME Keyring / KWallet) - **Linux D-Bus auto-detection** — keyring works from Claude Desktop without manual env var configuration diff --git a/CLAUDE.md b/CLAUDE.md index 3c83bbe..5788693 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,18 +4,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -synology-mcp is an MCP server for Synology NAS devices. It exposes Synology DSM API functionality as MCP tools that Claude can use. Modular, secure (2FA-ready), permission-tiered. Python 3.11+, async throughout, MIT licensed. +mcp-synology is an MCP server for Synology NAS devices. It exposes Synology DSM API functionality as MCP tools that Claude can use. Modular, secure (2FA-ready), permission-tiered. Python 3.11+, async throughout, Apache 2.0 licensed. -**Current status:** v0.3.x — File Station (12 tools) + System monitoring (2 tools), CLI, integration tests. +**Current status:** v0.4.x — File Station (12 tools) + System monitoring (2 tools), CLI, integration tests. ## Architecture Layered design: core → modules → server/CLI. -- **Core** (`src/synology_mcp/core/`): DSM API client (async httpx), auth manager (session lifecycle, 2FA, keyring), YAML+Pydantic config loader, shared response formatters, typed exception hierarchy -- **Modules** (`src/synology_mcp/modules/`): Feature-specific tool handlers. Each module declares `MODULE_INFO` with API requirements and tool metadata. File Station (12 tools: 6 READ + 6 WRITE), System (2 tools: get_system_info, get_resource_usage) -- **Server** (`src/synology_mcp/server.py`): FastMCP initialization, module loading, startup -- **CLI** (`src/synology_mcp/cli/`): click-based package with `serve`, `setup`, `check` subcommands +- **Core** (`src/mcp_synology/core/`): DSM API client (async httpx), auth manager (session lifecycle, 2FA, keyring), YAML+Pydantic config loader, shared response formatters, typed exception hierarchy +- **Modules** (`src/mcp_synology/modules/`): Feature-specific tool handlers. Each module declares `MODULE_INFO` with API requirements and tool metadata. File Station (12 tools: 6 READ + 6 WRITE), System (2 tools: get_system_info, get_resource_usage) +- **Server** (`src/mcp_synology/server.py`): FastMCP initialization, module loading, startup +- **CLI** (`src/mcp_synology/cli/`): click-based package with `serve`, `setup`, `check` subcommands Modules are domain-split: `listing.py`, `search.py`, `metadata.py`, `operations.py`, `helpers.py` — grouped by what they do, not permission tier. @@ -40,7 +40,7 @@ uv run pytest # Run unit + module tests uv run pytest tests/modules/filestation/test_listing.py # Single test file uv run pytest -k "test_list_shares" # Single test by name uv run pytest -m integration # Integration tests (requires real NAS) -uv run pytest --cov=synology_mcp # Tests with coverage +uv run pytest --cov=mcp_synology # Tests with coverage ``` ## Key Conventions @@ -71,7 +71,7 @@ uv run pytest --cov=synology_mcp # Tests with coverage - **DEBUG**: detailed operational trace — every DSM request/response (passwords masked), credential resolution steps, config discovery, version negotiation, API cache contents, session lifecycle, module registration - **INFO**: significant lifecycle events only — successful auth, re-auth, security config notes - **WARNING/ERROR**: configuration issues, failures -- Three ways to enable debug: `synology-mcp check -v` (flag), `SYNOLOGY_LOG_LEVEL=debug` (env var), `logging.level: debug` (config) +- Three ways to enable debug: `mcp-synology check -v` (flag), `SYNOLOGY_LOG_LEVEL=debug` (env var), `logging.level: debug` (config) - The `setup` and `check` commands accept `-v`/`--verbose`; `serve` uses config/env var (no interactive flag since it's launched by Claude Desktop) - Logging is initialized *before* config loading so config discovery is visible at debug level @@ -85,7 +85,7 @@ uv run pytest --cov=synology_mcp # Tests with coverage ### Auth - Strategy chain auto-detects 2FA vs non-2FA on login attempt - Credential lookup: keyring → env vars → config file (last resort, plaintext warning) -- DSM session name format: `SynologyMCP_{instance_id}_{unique_id}` +- DSM session name format: `MCPSynology_{instance_id}_{unique_id}` - Lazy keepalive (re-auth on next request, no proactive pings) ### DSM API Client @@ -97,7 +97,7 @@ uv run pytest --cov=synology_mcp # Tests with coverage ### Config - Config is read-only from the server's perspective — never write to it -- Runtime state goes in `~/.local/state/synology-mcp/{instance_id}/state.yaml` +- Runtime state goes in `~/.local/state/mcp-synology/{instance_id}/state.yaml` - Strict validation at top level (unknown keys = error), lenient within module settings (unknown keys = warning) - Two-phase loading: parse YAML → merge env var overrides → apply defaults → validate with Pydantic diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f3ffbae..1d26e90 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -3,8 +3,8 @@ ## Setup ```bash -git clone https://github.com/cmeans/synology-mcp.git -cd synology-mcp +git clone https://github.com/cmeans/mcp-synology.git +cd mcp-synology uv sync --extra dev # Install dependencies ``` @@ -16,7 +16,7 @@ uv run ruff format --check src/ tests/ # Format check uv run ruff format src/ tests/ # Auto-format uv run mypy src/ # Type check (strict mode) uv run pytest # Run unit tests (no NAS needed) -uv run pytest --cov=synology_mcp # Tests with coverage +uv run pytest --cov=mcp_synology # Tests with coverage ``` ## Integration Tests @@ -29,7 +29,7 @@ Integration tests run against a real Synology NAS. They verify the full stack cp tests/integration_config.yaml.example tests/integration_config.yaml ``` -Edit `integration_config.yaml` with your NAS connection details. The file is gitignored — credentials come from the OS keyring (populated by `synology-mcp setup`). For CI, use environment variables (`SYNOLOGY_HOST`, `SYNOLOGY_USERNAME`, `SYNOLOGY_PASSWORD`). +Edit `integration_config.yaml` with your NAS connection details. The file is gitignored — credentials come from the OS keyring (populated by `mcp-synology setup`). For CI, use environment variables (`SYNOLOGY_HOST`, `SYNOLOGY_USERNAME`, `SYNOLOGY_PASSWORD`). Configure `test_paths` to match folders on your NAS: @@ -44,7 +44,7 @@ test_paths: For tests requiring admin privileges (e.g., resource utilization), point `admin_config` to a config using an admin account: ```yaml -admin_config: ~/.config/synology-mcp/admin.yaml +admin_config: ~/.config/mcp-synology/admin.yaml ``` ### Running @@ -55,19 +55,74 @@ uv run pytest -m integration -v -k "TestSearch" # Just search tests uv run pytest -m integration -v -k "TestSystemInfo" # Just system info tests ``` -Integration tests are excluded from CI by default (`addopts = "-m 'not integration'"` in `pyproject.toml`). +Integration tests are excluded from CI by default (`addopts` in `pyproject.toml`). ### Known quirks - **Search service throttling** — DSM's search service on non-indexed shares can be overwhelmed by rapid-fire requests, returning 0 results or 502 errors. Allow recovery time between search-heavy test runs. - **Background task cleanup** — orphaned Search/DirSize tasks consume CPU indefinitely. The code uses `try/finally` to prevent this, but if tests are interrupted (Ctrl+C), tasks may linger. Check DSM Resource Monitor > Processes for `synoscgi_SYNO.FileStation.Search` entries. +## Virtual-DSM Tests + +Virtual-DSM tests run the same integration test suite against a Docker container +running full Synology DSM via QEMU/KVM. This enables testing across multiple DSM +versions without a physical NAS. + +### Requirements + +- Linux host with KVM support (`/dev/kvm` must exist) +- Docker installed and running +- ~2 GB disk per DSM version (golden images) + +macOS is not currently supported — KVM is Linux-only. Use the real NAS integration +tests on macOS. + +### First-Time Setup + +Each DSM version needs a one-time golden image creation (~15 min): + +```bash +uv sync --extra dev --extra vdsm +python scripts/vdsm_setup.py --version 7.2.2 +``` + +The script boots a fresh virtual-dsm container. Complete the DSM setup +wizard in your browser (set admin password, basic storage). The script then +automatically creates test users, shared folders, and seed data via the DSM API. + +Golden images are stored in `.vdsm/golden/` (~2 GB each, gitignored). + +### Running + +```bash +uv run pytest -m vdsm -v --log-cli-level=INFO # Default (7.2.2) +uv run pytest -m vdsm -v --dsm-version 7.1 # Specific version +uv run pytest -m vdsm -v -k "TestSearch" # Single test class +``` + +### Supported DSM Versions + +| Version | Build | PAT | +|---------|-------|-----| +| 7.0.1 | 42218 | VirtualDSM_42218.pat | +| 7.1 | 42661 | VirtualDSM_42661.pat | +| 7.2.1 | 69057 | VirtualDSM_69057.pat | +| 7.2.2 | 72806 | VirtualDSM_72806.pat (default) | +| 7.3.2 | 86009 | VirtualDSM_86009.pat | + +### How It Works + +1. Restores a golden image (pre-configured DSM) to a temp storage directory +2. Boots the virtual-dsm Docker container (~2 min) +3. Runs the same test functions as the real NAS integration tests +4. Container is stopped and cleaned up automatically + ## Design Docs Detailed specs live in `docs/specs/`. These were the original design documents — the code is authoritative where they diverge (e.g., DSM API version pinning, GET-only requests, and search behavior were discovered during live testing and are documented in `CLAUDE.md`). - `architecture.md` — layered architecture, auth strategy, session lifecycle -- `filestation-module-spec.md` — all 12 File Station tools +- `filestation-module-spec.md` — all 14 File Station tools (7 READ + 7 WRITE) - `config-schema-spec.md` — YAML config structure and validation - `project-scaffolding-spec.md` — repo structure, CI, testing diff --git a/LICENSE b/LICENSE index d81881f..adda382 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,190 @@ -MIT License - -Copyright (c) 2026 Chris Means - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Chris Means + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 5db4eff..ac75ed1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# synology-mcp +# mcp-synology MCP server for Synology NAS devices. Exposes Synology DSM API functionality as MCP tools that Claude can use. @@ -31,15 +31,15 @@ Monitor NAS health and resource utilization. 2 read-only tools: ### 1. Install ```bash -uv tool install synology-mcp +uv tool install mcp-synology ``` -Installs the `synology-mcp` command globally from [PyPI](https://pypi.org/project/synology-mcp/). Requires [uv](https://docs.astral.sh/uv/). +Installs the `mcp-synology` command globally from [PyPI](https://pypi.org/project/mcp-synology/). Requires [uv](https://docs.astral.sh/uv/). ### 2. Run setup ```bash -synology-mcp setup +mcp-synology setup ``` Setup will prompt for your NAS host, credentials, and preferences. If your account has 2FA enabled, it will prompt for an OTP code and store a device token for automatic future logins. @@ -54,8 +54,8 @@ Copy the snippet from setup into your `claude_desktop_config.json` and restart C { "mcpServers": { "synology-nas": { - "command": "synology-mcp", - "args": ["serve", "--config", "~/.config/synology-mcp/nas.yaml"] + "command": "mcp-synology", + "args": ["serve", "--config", "~/.config/mcp-synology/nas.yaml"] } } } @@ -68,8 +68,8 @@ On Linux, the server auto-detects the D-Bus session socket for keyring access. I ### 4. Verify ```bash -synology-mcp check # Validates credentials work -synology-mcp setup --list # Shows all configured NAS instances +mcp-synology check # Validates credentials work +mcp-synology setup --list # Shows all configured NAS instances ``` ### Alternative: run without global install @@ -81,7 +81,7 @@ If you prefer not to install globally, `uvx` downloads and runs the latest versi "mcpServers": { "synology": { "command": "uvx", - "args": ["synology-mcp", "serve", "--config", "~/.config/synology-mcp/config.yaml"] + "args": ["mcp-synology", "serve", "--config", "~/.config/mcp-synology/config.yaml"] } } } @@ -90,8 +90,8 @@ If you prefer not to install globally, `uvx` downloads and runs the latest versi You can also use `uvx` for CLI commands: ```bash -uvx synology-mcp setup -uvx synology-mcp check +uvx mcp-synology setup +uvx mcp-synology check ``` ### Alternative: env-var-only mode @@ -102,7 +102,7 @@ No config file needed if `SYNOLOGY_HOST` is set. This is useful for Docker or CI { "mcpServers": { "synology": { - "command": "synology-mcp", + "command": "mcp-synology", "args": ["serve"], "env": { "SYNOLOGY_HOST": "192.168.1.100", @@ -117,18 +117,18 @@ No config file needed if `SYNOLOGY_HOST` is set. This is useful for Docker or CI Or from the CLI: ```bash -SYNOLOGY_HOST=192.168.1.100 synology-mcp check +SYNOLOGY_HOST=192.168.1.100 mcp-synology check ``` ## 2FA Support -synology-mcp fully supports DSM accounts with two-factor authentication. It's auto-detected — you don't need to configure anything special: +mcp-synology fully supports DSM accounts with two-factor authentication. It's auto-detected — you don't need to configure anything special: -1. **Bootstrap** — `synology-mcp setup` detects 2FA, prompts for your OTP code, and stores a device token in the keyring +1. **Bootstrap** — `mcp-synology setup` detects 2FA, prompts for your OTP code, and stores a device token in the keyring 2. **Silent re-auth** — subsequent logins use the device token automatically (no OTP prompts) 3. **Per-instance** — each NAS config gets its own device token, so mixed 2FA/non-2FA setups work fine -Device tokens persist until you explicitly revoke them in DSM (Personal > Security > Sign-in Activity). They do not expire on their own. If a token is revoked, run `synology-mcp setup` again to re-bootstrap. +Device tokens persist until you explicitly revoke them in DSM (Personal > Security > Sign-in Activity). They do not expire on their own. If a token is revoked, run `mcp-synology setup` again to re-bootstrap. ## Keyring & Credentials @@ -148,21 +148,21 @@ See [docs/credentials.md](docs/credentials.md) for keyring service names, multi- ## Updates -synology-mcp checks for updates and notifies you in your Claude Desktop conversation — the first tool response in each session will include a notice if a newer version is available on PyPI. +mcp-synology checks for updates and notifies you in your Claude Desktop conversation — the first tool response in each session will include a notice if a newer version is available on PyPI. To manage updates from the CLI: ```bash -synology-mcp --check-update # Check for a newer version -synology-mcp --auto-upgrade enable # Auto-upgrade on each interactive run -synology-mcp --revert # Roll back to previous version -synology-mcp --revert 0.1.0 # Roll back to a specific version +mcp-synology --check-update # Check for a newer version +mcp-synology --auto-upgrade enable # Auto-upgrade on each interactive run +mcp-synology --revert # Roll back to previous version +mcp-synology --revert 0.1.0 # Roll back to a specific version ``` To disable update notifications, add to your config (top level): ```yaml -# ~/.config/synology-mcp/config.yaml +# ~/.config/mcp-synology/config.yaml check_for_updates: false ``` @@ -180,7 +180,7 @@ Each NAS gets its own config file, credentials, and Claude Desktop entry. The co Set `alias` to give Claude a display name for the connection: ```yaml -# ~/.config/synology-mcp/home-nas.yaml +# ~/.config/mcp-synology/home-nas.yaml alias: HomeNAS ``` @@ -197,18 +197,18 @@ Custom instructions let you shape how Claude interacts with your NAS tools. This **Add context** — `custom_instructions` is prepended to the built-in prompt (higher priority): ```yaml -# ~/.config/synology-mcp/config.yaml +# ~/.config/mcp-synology/config.yaml custom_instructions: | This is the admin NAS with elevated privileges. Prefer this connection for file operations requiring cross-user access. Never delete files from /Backups without explicit confirmation. ``` -**Full control** — `instructions_file` replaces the built-in prompt entirely. Copy the [built-in server.md](src/synology_mcp/instructions/server.md) as a starting point: +**Full control** — `instructions_file` replaces the built-in prompt entirely. Copy the [built-in server.md](src/mcp_synology/instructions/server.md) as a starting point: ```yaml -# ~/.config/synology-mcp/config.yaml -instructions_file: ~/.config/synology-mcp/my-instructions.md +# ~/.config/mcp-synology/config.yaml +instructions_file: ~/.config/mcp-synology/my-instructions.md ``` Both support template variables: `{display_name}`, `{instance_id}`, `{host}`, `{port}`. @@ -218,17 +218,17 @@ Both support template variables: `{display_name}`, `{instance_id}`, `{host}`, `{ Two ways to enable debug logging: ```bash -synology-mcp check --verbose # --verbose flag on setup/check -SYNOLOGY_LOG_LEVEL=debug synology-mcp serve # env var, works for all commands +mcp-synology check --verbose # --verbose flag on setup/check +SYNOLOGY_LOG_LEVEL=debug mcp-synology serve # env var, works for all commands ``` Or set it persistently in your config file: ```yaml -# ~/.config/synology-mcp/config.yaml +# ~/.config/mcp-synology/config.yaml logging: level: debug - file: ~/.local/state/synology-mcp/nas/server.log # optional, logs to stderr by default + file: ~/.local/state/mcp-synology/nas/server.log # optional, logs to stderr by default ``` Debug output includes every DSM API request/response (passwords masked), credential resolution steps, config discovery, version negotiation, and module registration decisions. @@ -247,7 +247,7 @@ Live testing against real hardware revealed behaviors the specs couldn't anticip ## License -[MIT](LICENSE) +[Apache 2.0](LICENSE) --- diff --git a/docs/credentials.md b/docs/credentials.md index 31060dc..45c68ce 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -1,13 +1,13 @@ # Credential Storage -synology-mcp stores credentials in your OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service / GNOME Keyring / KDE Wallet). +mcp-synology stores credentials in your OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service / GNOME Keyring / KDE Wallet). ## Keyring Service Name Credentials are stored under the service name: ``` -synology-mcp/{instance_id} +mcp-synology/{instance_id} ``` Where `instance_id` is: @@ -32,10 +32,10 @@ Each service stores up to three keys: For accounts with two-factor authentication enabled: -1. Run `synology-mcp setup` — it detects 2FA (DSM error 403) and prompts for your OTP code +1. Run `mcp-synology setup` — it detects 2FA (DSM error 403) and prompts for your OTP code 2. On successful OTP, DSM returns a device token which is stored as `device_id` in the keyring 3. Subsequent logins include the device token, so DSM treats the server as a remembered device — no OTP required -4. If the device token expires or is revoked in DSM, run `synology-mcp setup` again to re-bootstrap +4. If the device token expires or is revoked in DSM, run `mcp-synology setup` again to re-bootstrap The device token is specific to the `instance_id`. Multiple NAS configs with different 2FA accounts each get their own token. @@ -50,7 +50,7 @@ The device token is specific to the `instance_id`. Multiple NAS configs with dif ### Linux and Claude Desktop -On Linux, keyring backends communicate via D-Bus. When Claude Desktop launches the MCP server, the subprocess may not inherit the `DBUS_SESSION_BUS_ADDRESS` environment variable. synology-mcp handles this by checking for the standard systemd socket at `/run/user//bus` and setting the env var if the socket exists. +On Linux, keyring backends communicate via D-Bus. When Claude Desktop launches the MCP server, the subprocess may not inherit the `DBUS_SESSION_BUS_ADDRESS` environment variable. mcp-synology handles this by checking for the standard systemd socket at `/run/user//bus` and setting the env var if the socket exists. No special configuration is needed — keyring works from Claude Desktop on Linux with standard systemd-based desktop environments. @@ -60,30 +60,30 @@ You can inspect stored credentials using your OS keyring tools or Python's `keyr ```bash # Check what's stored for a given instance -python -m keyring get synology-mcp/192-168-1-100 username +python -m keyring get mcp-synology/192-168-1-100 username # Or using the keyring CLI directly -keyring get synology-mcp/nas-primary username +keyring get mcp-synology/nas-primary username # Check if a device token is stored (2FA) -keyring get synology-mcp/nas-primary device_id +keyring get mcp-synology/nas-primary device_id ``` ## Removing Credentials ```bash -keyring del synology-mcp/192-168-1-100 username -keyring del synology-mcp/192-168-1-100 password -keyring del synology-mcp/192-168-1-100 device_id +keyring del mcp-synology/192-168-1-100 username +keyring del mcp-synology/192-168-1-100 password +keyring del mcp-synology/192-168-1-100 device_id ``` ## Credential Resolution Order -When authenticating, synology-mcp checks these sources in order: +When authenticating, mcp-synology checks these sources in order: 1. **Environment variables** (highest priority) — `SYNOLOGY_USERNAME`, `SYNOLOGY_PASSWORD`, `SYNOLOGY_DEVICE_ID` 2. **Config file** — `auth.username`, `auth.password` — triggers a plaintext warning -3. **OS keyring** (default) — set via `synology-mcp setup` +3. **OS keyring** (default) — set via `mcp-synology setup` Explicit sources (env vars, config file) override the implicit default (keyring). This means setting `SYNOLOGY_PASSWORD=x` will always use that password, even if the keyring has a different one stored. @@ -92,19 +92,19 @@ Explicit sources (env vars, config file) override the implicit default (keyring) Each NAS gets its own keyring entry keyed by `instance_id`. Use any meaningful name: ```yaml -# ~/.config/synology-mcp/nas-primary.yaml +# ~/.config/mcp-synology/nas-primary.yaml instance_id: nas-primary connection: host: 192.168.1.100 -# ~/.config/synology-mcp/nas-backup.yaml +# ~/.config/mcp-synology/nas-backup.yaml instance_id: nas-backup connection: host: 192.168.1.200 ``` Their credentials are stored independently: -- `synology-mcp/nas-primary` — username, password, device_id -- `synology-mcp/nas-backup` — username, password, device_id +- `mcp-synology/nas-primary` — username, password, device_id +- `mcp-synology/nas-backup` — username, password, device_id -Run `synology-mcp setup --list` to see all configured instances. +Run `mcp-synology setup --list` to see all configured instances. diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 666e53d..40eef9d 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -24,7 +24,7 @@ ## Layered Architecture -### Layer 1 — Core (`synology_mcp/core/`) +### Layer 1 — Core (`mcp_synology/core/`) Shared infrastructure used by all modules. @@ -94,7 +94,7 @@ Modules use these by default. They can compose them (e.g., a file move returns ` **Rationale:** Consistent formatting means the LLM learns response patterns quickly and is less prone to parsing errors. Doesn't preclude the LLM or downstream tools from reformatting further. -### Layer 2 — Modules (`synology_mcp/modules/`) +### Layer 2 — Modules (`mcp_synology/modules/`) Each module is a Python package (directory with `__init__.py`) corresponding to a DSM service. @@ -160,7 +160,7 @@ class VersionedHandler: - Multi-version is opt-in per handler for broader NAS compatibility - The lower you go in version support, the wider the installed base you can serve -### Layer 3 — Server (`synology_mcp/server.py`) +### Layer 3 — Server (`mcp_synology/server.py`) Top-level MCP server entry point. @@ -217,10 +217,10 @@ Users with multiple NAS devices configure multiple MCP server entries in their c # Claude Desktop config "synology-primary": command: uvx - args: [synology-mcp, --config, ~/.config/synology-mcp/primary.yaml] + args: [mcp-synology, --config, ~/.config/mcp-synology/primary.yaml] "synology-backup": command: uvx - args: [synology-mcp, --config, ~/.config/synology-mcp/backup.yaml] + args: [mcp-synology, --config, ~/.config/mcp-synology/backup.yaml] ``` The LLM distinguishes them by server name. Each instance validates independently against its target NAS. @@ -263,7 +263,7 @@ All three secrets (including device_id) are stored in the keyring when available **Service name convention for keyring:** ``` -Service: synology-mcp/{instance_id} +Service: mcp-synology/{instance_id} Accounts: username, password, device_id ``` @@ -271,7 +271,7 @@ Accounts: username, password, device_id Non-sensitive server-managed runtime state is stored in a separate state file (not in the YAML config): -- **Location:** `~/.local/state/synology-mcp/{instance_id}/state.yaml` (or alongside the config file) +- **Location:** `~/.local/state/mcp-synology/{instance_id}/state.yaml` (or alongside the config file) - **Contents:** SYNO.API.Info cache (API name → path/version map), last successful connection timestamp, negotiated API versions - **Not stored here:** credentials, device tokens, session IDs (these go in the keyring or are ephemeral) @@ -283,7 +283,7 @@ Non-sensitive server-managed runtime state is stored in a separate state file (n DSM supports multiple concurrent named sessions per account via the `session` parameter on `SYNO.API.Auth`. Different station APIs use different session names (e.g., `FileStation`, `DownloadStation`). Sessions with different names coexist without conflict; sessions with the *same* name on the same account will displace each other (error 107). -**Session name format:** `SynologyMCP_{instance_id}_{unique_id}` +**Session name format:** `MCPSynology_{instance_id}_{unique_id}` - `instance_id`: from the config file, differentiates multiple NAS targets - `unique_id`: generated at process startup (short UUID), prevents collision with stale sessions from previous runs that didn't cleanly logout @@ -323,14 +323,14 @@ sid = await auth_manager.get_session() sid = await auth_manager.get_session(session_key=ctx.session_id) ``` -The DSM session name becomes `SynologyMCP_{instance_id}_{mcp_session_id}`, giving each connecting client its own isolated DSM session. +The DSM session name becomes `MCPSynology_{instance_id}_{mcp_session_id}`, giving each connecting client its own isolated DSM session. ### Setup / Bootstrap Flow A separate CLI command handles interactive first-run setup, including the 2FA device token exchange: ``` -$ synology-mcp setup --config ~/.config/synology-mcp/primary.yaml +$ mcp-synology setup --config ~/.config/mcp-synology/primary.yaml ``` **Setup sequence:** @@ -338,7 +338,7 @@ $ synology-mcp setup --config ~/.config/synology-mcp/primary.yaml 2. Prompt for username and password interactively (or read from env vars if pre-set) 3. Attempt login to the NAS 4. If error 403 (2FA required): prompt for OTP code from the user's authenticator app -5. Login with otp_code + `enable_device_token=yes` + `device_name=SynologyMCP` +5. Login with otp_code + `enable_device_token=yes` + `device_name=MCPSynology` 6. Store username, password, and returned device_id in the keyring (or fallback storage) 7. Perform a validation login using the stored credentials (mimicking server startup) 8. Confirm success; log out the setup session @@ -351,11 +351,11 @@ $ synology-mcp setup --config ~/.config/synology-mcp/primary.yaml **Additional CLI commands:** -- `synology-mcp serve` — normal MCP server mode (launched by Claude Desktop) -- `synology-mcp setup` — interactive credential setup and 2FA bootstrap -- `synology-mcp check` — validate stored credentials can authenticate (for debugging, does not start the server) +- `mcp-synology serve` — normal MCP server mode (launched by Claude Desktop) +- `mcp-synology setup` — interactive credential setup and 2FA bootstrap +- `mcp-synology check` — validate stored credentials can authenticate (for debugging, does not start the server) -**Documentation flow:** install → create config with host/port → run `synology-mcp setup` → add server entry to Claude Desktop config → restart Claude Desktop. +**Documentation flow:** install → create config with host/port → run `mcp-synology setup` → add server entry to Claude Desktop config → restart Claude Desktop. #### Future: In-Chat Bootstrap via MCP Elicitation @@ -375,7 +375,7 @@ Documentation should strongly recommend: ### Approach -Every module uses `logging.getLogger(__name__)` so log output includes the full module path (e.g., `synology_mcp.core.client`, `synology_mcp.core.auth`). This makes it easy to trace log messages to their source file during debugging. +Every module uses `logging.getLogger(__name__)` so log output includes the full module path (e.g., `mcp_synology.core.client`, `mcp_synology.core.auth`). This makes it easy to trace log messages to their source file during debugging. ### Log Level Guidelines diff --git a/docs/specs/config-schema-spec.md b/docs/specs/config-schema-spec.md index f185ab3..5794087 100644 --- a/docs/specs/config-schema-spec.md +++ b/docs/specs/config-schema-spec.md @@ -1,11 +1,11 @@ # Config Schema — YAML Structure > **Version:** 0.2 | **Updated:** 2026-03-16T22:30Z — Resolved all open items: env-var-only config (Option B), schema_version field, hybrid module settings validation. -> **Parent doc:** `synology-mcp-architecture.md` v0.3 +> **Parent doc:** `mcp-synology-architecture.md` v0.3 ## Overview -The config file is the single user-edited input to the synology-mcp server. It is **never modified by the server** — runtime state goes in a separate state file. The config's job is to answer three questions: +The config file is the single user-edited input to the mcp-synology server. It is **never modified by the server** — runtime state goes in a separate state file. The config's job is to answer three questions: 1. **Where is the NAS?** (connection details) 2. **What modules should be active, and with what permissions?** (module config) @@ -15,10 +15,10 @@ The config file is the single user-edited input to the synology-mcp server. It i ## Minimal Config (Quick Start) -The smallest useful config file. The `synology-mcp setup` CLI fills in credentials via the keyring, so they don't appear here: +The smallest useful config file. The `mcp-synology setup` CLI fills in credentials via the keyring, so they don't appear here: ```yaml -# ~/.config/synology-mcp/config.yaml +# ~/.config/mcp-synology/config.yaml schema_version: 1 connection: @@ -34,15 +34,15 @@ That's it. Everything else has sensible defaults: - HTTPS defaults to `false` (most home LANs use HTTP internally) - Instance ID auto-generated from hostname if not specified - Permission tier defaults to `read` -- Credentials come from the keyring (populated by `synology-mcp setup`) +- Credentials come from the keyring (populated by `mcp-synology setup`) --- ## Full Config (Annotated Reference) ```yaml -# synology-mcp configuration -# Docs: https://github.com/cmeans/synology-mcp#configuration +# mcp-synology configuration +# Docs: https://github.com/cmeans/mcp-synology#configuration # ─── Schema Version ────────────────────────────────────────────── # Required. Identifies the config format version so the server can @@ -52,9 +52,9 @@ schema_version: 1 # ─── Instance Identity ─────────────────────────────────────────── # Unique identifier for this server instance. Used for: -# - Keyring namespacing (synology-mcp/{instance_id}) -# - DSM session naming (SynologyMCP_{instance_id}_{uuid}) -# - State file location (~/.local/state/synology-mcp/{instance_id}/) +# - Keyring namespacing (mcp-synology/{instance_id}) +# - DSM session naming (MCPSynology_{instance_id}_{uuid}) +# - State file location (~/.local/state/mcp-synology/{instance_id}/) # - Claude Desktop server name differentiation (multi-NAS setups) # # Default: derived from connection.host (sanitized to alphanumeric + hyphens) @@ -89,12 +89,12 @@ connection: # ─── Authentication ────────────────────────────────────────────── # Credentials are resolved in this order: -# 1. OS keyring (preferred — populated by `synology-mcp setup`) +# 1. OS keyring (preferred — populated by `mcp-synology setup`) # 2. Environment variables: SYNOLOGY_USERNAME, SYNOLOGY_PASSWORD, # SYNOLOGY_DEVICE_ID # 3. Values in this section (⚠ plaintext on disk — testing only) # -# For production use, run `synology-mcp setup` and leave this +# For production use, run `mcp-synology setup` and leave this # section empty or omitted entirely. auth: # ⚠ INSECURE: Plaintext credentials. Use keyring or env vars instead. @@ -160,7 +160,7 @@ logging: # Useful for debugging when the server is launched by Claude Desktop # (where stderr may not be visible). # Default: null (stderr only) - # file: ~/.local/state/synology-mcp/primary/server.log + # file: ~/.local/state/mcp-synology/primary/server.log ``` --- @@ -231,7 +231,7 @@ Each key is a module name (e.g., `filestation`, `docker`, `system_info`). The va - `warning` — configuration issues (unknown modules, deprecated settings). - `error` — failures that prevent operation. -All modules use `logging.getLogger(__name__)` so log output includes the full module path (e.g., `synology_mcp.core.client`), making it easy to trace messages to source. +All modules use `logging.getLogger(__name__)` so log output includes the full module path (e.g., `mcp_synology.core.client`), making it easy to trace messages to source. --- @@ -294,11 +294,11 @@ Certain config values can be overridden by environment variables. This supports The server finds its config file in this order: -1. **Explicit `--config` flag:** `synology-mcp serve --config /path/to/config.yaml` -2. **Environment variable:** `SYNOLOGY_MCP_CONFIG=/path/to/config.yaml` +1. **Explicit `--config` flag:** `mcp-synology serve --config /path/to/config.yaml` +2. **Environment variable:** `MCP_SYNOLOGY_CONFIG=/path/to/config.yaml` 3. **Default locations** (first found wins): - - `~/.config/synology-mcp/config.yaml` - - `./synology-mcp.yaml` (current directory) + - `~/.config/mcp-synology/config.yaml` + - `./mcp-synology.yaml` (current directory) For multi-NAS setups, users must use the `--config` flag to specify which config to load: @@ -308,11 +308,11 @@ For multi-NAS setups, users must use the `--config` flag to specify which config "mcpServers": { "synology-primary": { "command": "uvx", - "args": ["synology-mcp", "serve", "--config", "~/.config/synology-mcp/primary.yaml"] + "args": ["mcp-synology", "serve", "--config", "~/.config/mcp-synology/primary.yaml"] }, "synology-backup": { "command": "uvx", - "args": ["synology-mcp", "serve", "--config", "~/.config/synology-mcp/backup.yaml"] + "args": ["mcp-synology", "serve", "--config", "~/.config/mcp-synology/backup.yaml"] } } } @@ -324,12 +324,12 @@ For multi-NAS setups, users must use the `--config` flag to specify which config Separate from the config. Server-managed, never user-edited. -**Location:** `~/.local/state/synology-mcp/{instance_id}/state.yaml` +**Location:** `~/.local/state/mcp-synology/{instance_id}/state.yaml` **Contents:** ```yaml -# Auto-generated by synology-mcp. Do not edit. +# Auto-generated by mcp-synology. Do not edit. api_info_cache: SYNO.API.Auth: path: entry.cgi @@ -414,7 +414,7 @@ modules: logging: level: debug - file: ~/.local/state/synology-mcp/nas-primary/server.log + file: ~/.local/state/mcp-synology/nas-primary/server.log ``` ### Docker Deployment — All Config via Environment @@ -455,7 +455,7 @@ Unknown top-level keys are errors (catches typos like `conection` or `moduels`). **Decision: The server never writes to the config file.** -All server-managed state goes in the state file. This means `synology-mcp setup` writes credentials to the keyring, not to the config file. The config file remains exactly as the user authored it. +All server-managed state goes in the state file. This means `mcp-synology setup` writes credentials to the keyring, not to the config file. The config file remains exactly as the user authored it. ### 3. YAML Variable Interpolation @@ -481,7 +481,7 @@ This means Docker deployments can use a minimal config file that contains only ` The server checks `schema_version` before attempting to parse the rest of the config. This enables: - Clear error messages when a user's config is from an older/newer schema version -- Automated migration guidance ("your config is schema_version 1, this version of synology-mcp expects schema_version 2 — here's what changed") +- Automated migration guidance ("your config is schema_version 1, this version of mcp-synology expects schema_version 2 — here's what changed") - The ability to make breaking config changes without silent failures Current schema version: `1`. The version increments only on breaking changes to the config structure, not on additive changes (new optional keys are backward-compatible). diff --git a/docs/specs/filestation-module-spec.md b/docs/specs/filestation-module-spec.md index 3077599..1b4933f 100644 --- a/docs/specs/filestation-module-spec.md +++ b/docs/specs/filestation-module-spec.md @@ -1,7 +1,7 @@ # File Station Module — Tool Specifications -> **Version:** 0.3 | **Updated:** 2026-03-16T22:30Z — restore_from_recycle_bin moved to WRITE tier. #recycle path preservation confirmed on DS1618+. Tool counts: 6 READ + 6 WRITE = 12. -> **Parent doc:** `synology-mcp-architecture.md` v0.3 +> **Version:** 0.4 | **Updated:** 2026-03-18 — Added upload_file (WRITE) and download_file (READ) tools for local↔NAS file transfer. Tool counts: 7 READ + 7 WRITE = 14. +> **Parent doc:** `mcp-synology-architecture.md` v0.3 ## Module Metadata @@ -18,8 +18,10 @@ MODULE_INFO = ModuleInfo( ApiRequirement(api_name="SYNO.FileStation.Rename", min_version=1), ApiRequirement(api_name="SYNO.FileStation.CopyMove", min_version=1), ApiRequirement(api_name="SYNO.FileStation.Delete", min_version=1), + ApiRequirement(api_name="SYNO.FileStation.Upload", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.FileStation.Download", min_version=1, optional=True), ], - tools=[...], # See individual tool specs below; 6 READ + 6 WRITE = 12 total + tools=[...], # See individual tool specs below; 7 READ + 7 WRITE = 14 total ) ``` @@ -93,7 +95,7 @@ MCP tool descriptions are the primary way the LLM learns what each tool does. Gi --- -## READ Tier Tools (6 tools) +## READ Tier Tools (7 tools) ### 1. `list_shares` @@ -354,9 +356,80 @@ To restore items, use restore_from_recycle_bin with the file paths shown above. --- -## WRITE Tier Tools (6 tools) +### 7. `download_file` -### 7. `restore_from_recycle_bin` +Download a NAS file to a local directory on this machine. + +**Permission tier:** READ + +**DSM API:** `SYNO.FileStation.Download` / `download` (GET, binary response) + +**Tool description (LLM-facing):** +> Download a NAS file to a local directory on this machine. Provide the NAS file path and a local destination directory. Does not overwrite existing local files by default. + +**Parameters:** + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | `str` | **Yes** | — | NAS file path to download. | +| `dest_folder` | `str` | **Yes** | — | Local directory to save the file to. | +| `filename` | `str` | No | `None` | Rename on download (defaults to the NAS filename). | +| `overwrite` | `bool` | No | `false` | Replace existing local file. | + +**Response shape:** + +``` +[+] Downloaded movie.mkv (4.2 GB) to /home/user/Downloads/movie.mkv +``` + +**Design notes:** +- Single-file download only in v1. Multi-file download (returns zip) deferred. +- Binary data streamed to disk in 64 KB chunks — never buffered entirely in memory. +- If the NAS returns `Content-Type: application/json` instead of binary, it's an error envelope — parsed and raised. +- Partial files are cleaned up on failure (network error, DSM error). +- The MCP server runs locally, so `dest_folder` refers to a path on the machine running the server. + +--- + +## WRITE Tier Tools (7 tools) + +### 8. `upload_file` + +Upload a local file from this machine to a NAS folder. + +**Permission tier:** WRITE + +**DSM API:** `SYNO.FileStation.Upload` / `upload` (POST multipart — the ONE exception to the GET-only rule) + +**Tool description (LLM-facing):** +> Upload a local file from this machine to a NAS folder. Provide the local file path and NAS destination folder. Does not overwrite existing NAS files by default. + +**Parameters:** + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `local_path` | `str` | **Yes** | — | Local file path on this machine. | +| `dest_folder` | `str` | **Yes** | — | NAS destination folder. | +| `filename` | `str` | No | `None` | Rename on upload (defaults to the local filename). | +| `overwrite` | `bool` | No | `false` | Replace existing NAS file. | +| `create_parents` | `bool` | No | `true` | Create missing parent directories on the NAS. | + +**Response shape:** + +``` +[+] Uploaded report.pdf (2.4 MB) to /documents/reports/ +``` + +**Design notes:** +- Upload uses POST multipart — the only DSM API call that requires POST. Documented clearly in the client method. +- File is streamed from disk, not buffered in memory. +- On session error (106/107/119), re-auth and retry with a fresh file handle. +- `SynologyFileExistsError` (414) caught with suggestion to use `overwrite=true`. +- The MCP server runs locally, so `local_path` refers to a path on the machine running the server. + +--- + +### 9. `restore_from_recycle_bin` Restore files from a shared folder's recycle bin. @@ -392,7 +465,7 @@ Restore files from a shared folder's recycle bin. --- -### 8. `create_folder` +### 10. `create_folder` Create one or more new folders. @@ -419,7 +492,7 @@ Create one or more new folders. --- -### 9. `rename` +### 11. `rename` Rename a file or folder. @@ -446,7 +519,7 @@ Rename a file or folder. --- -### 10. `copy_files` +### 12. `copy_files` Copy files or folders to a destination. @@ -475,7 +548,7 @@ Copy files or folders to a destination. --- -### 11. `move_files` +### 13. `move_files` Move files or folders to a new location. **Source files are removed after successful transfer.** @@ -506,7 +579,7 @@ Source files have been removed from /video/Downloads/. --- -### 12. `delete_files` +### 14. `delete_files` Delete files or folders. @@ -607,7 +680,7 @@ This is outside the MCP server's scope but worth documenting as a power-user pat The `FastMCP(instructions=...)` string provides shared knowledge at connection time: ``` -You are connected to a Synology NAS via the synology-mcp File Station module. +You are connected to a Synology NAS via the mcp-synology File Station module. PATH FORMAT: All file paths start with a shared folder name: /video/..., /music/..., etc. diff --git a/docs/specs/project-scaffolding-spec.md b/docs/specs/project-scaffolding-spec.md index c6f7aa7..ee9e9eb 100644 --- a/docs/specs/project-scaffolding-spec.md +++ b/docs/specs/project-scaffolding-spec.md @@ -1,16 +1,16 @@ # Project Scaffolding — Repo Structure, Build, CI, Testing > **Version:** 0.2 | **Updated:** 2026-03-16T23:30Z — Domain-based module split, click for CLI, spec docs in repo, example configs, py.typed marker. -> **Parent doc:** `synology-mcp-architecture.md` v0.3 +> **Parent doc:** `mcp-synology-architecture.md` v0.3 ## Repository Structure ``` -synology-mcp/ +mcp-synology/ ├── src/ -│ └── synology_mcp/ +│ └── mcp_synology/ │ ├── __init__.py # Package version, top-level exports -│ ├── __main__.py # `python -m synology_mcp` entry point +│ ├── __main__.py # `python -m mcp_synology` entry point │ ├── py.typed # PEP 561 marker — ship type information │ ├── cli.py # CLI: serve, setup, check (click-based) │ ├── server.py # FastMCP server init, module loading, startup @@ -63,7 +63,7 @@ synology-mcp/ ├── pyproject.toml # Build config, dependencies, entry points ├── README.md # User-facing docs: install, configure, usage ├── CLAUDE.md # Claude Code instructions for this repo -├── LICENSE # MIT +├── LICENSE # Apache 2.0 ├── .gitignore └── .github/ └── workflows/ @@ -98,11 +98,11 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "synology-mcp" +name = "mcp-synology" version = "0.1.0" description = "MCP server for Synology NAS — manage files, containers, and more via Claude" readme = "README.md" -license = "MIT" +license = "Apache-2.0" requires-python = ">=3.11" authors = [ { name = "Chris Means", email = "..." }, @@ -112,7 +112,7 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -139,16 +139,16 @@ dev = [ ] [project.scripts] -synology-mcp = "synology_mcp.cli:main" +mcp-synology = "mcp_synology.cli:main" [project.urls] -Homepage = "https://github.com/cmeans/synology-mcp" -Repository = "https://github.com/cmeans/synology-mcp" -Issues = "https://github.com/cmeans/synology-mcp/issues" -Documentation = "https://github.com/cmeans/synology-mcp/tree/main/docs" +Homepage = "https://github.com/cmeans/mcp-synology" +Repository = "https://github.com/cmeans/mcp-synology" +Issues = "https://github.com/cmeans/mcp-synology/issues" +Documentation = "https://github.com/cmeans/mcp-synology/tree/main/docs" [tool.hatch.build.targets.wheel] -packages = ["src/synology_mcp"] +packages = ["src/mcp_synology"] [tool.ruff] target-version = "py311" @@ -204,7 +204,7 @@ import click @click.group() @click.version_option() def main(): - """synology-mcp — MCP server for Synology NAS.""" + """mcp-synology — MCP server for Synology NAS.""" pass @main.command() @@ -281,7 +281,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: uv sync --dev - - run: uv run pytest --cov=synology_mcp --cov-report=xml + - run: uv run pytest --cov=mcp_synology --cov-report=xml - uses: codecov/codecov-action@v4 if: matrix.python-version == '3.12' with: @@ -407,7 +407,7 @@ async def test_list_shares(mock_client): Detailed instructions for Claude Code when working on this repo: ```markdown -# CLAUDE.md — synology-mcp +# CLAUDE.md — mcp-synology ## Project Overview MCP server for Synology NAS devices. Layered architecture: @@ -474,14 +474,14 @@ and their rationale — don't reinvent or contradict them without discussion. ```bash # Users install and run: -uvx --from git+https://github.com/cmeans/synology-mcp synology-mcp serve +uvx --from git+https://github.com/cmeans/mcp-synology mcp-synology serve # Claude Desktop config: { "mcpServers": { "synology": { "command": "uvx", - "args": ["--from", "git+https://github.com/cmeans/synology-mcp", "synology-mcp", "serve"] + "args": ["--from", "git+https://github.com/cmeans/mcp-synology", "mcp-synology", "serve"] } } } @@ -489,14 +489,14 @@ uvx --from git+https://github.com/cmeans/synology-mcp synology-mcp serve ### Future: PyPI -Once stable enough for versioned releases: `uvx synology-mcp serve` +Once stable enough for versioned releases: `uvx mcp-synology serve` ### Future: Docker ```dockerfile FROM python:3.12-slim -RUN pip install synology-mcp -ENTRYPOINT ["synology-mcp", "serve", "--config", "/config/config.yaml"] +RUN pip install mcp-synology +ENTRYPOINT ["mcp-synology", "serve", "--config", "/config/config.yaml"] ``` --- diff --git a/examples/config-minimal.yaml b/examples/config-minimal.yaml index 20c8750..e43e2ba 100644 --- a/examples/config-minimal.yaml +++ b/examples/config-minimal.yaml @@ -1,5 +1,5 @@ # Minimal config — just host + one module. -# Credentials come from the keyring (populated by `synology-mcp setup`). +# Credentials come from the keyring (populated by `mcp-synology setup`). schema_version: 1 connection: diff --git a/examples/config-power-user.yaml b/examples/config-power-user.yaml index 327d622..2a7bfa3 100644 --- a/examples/config-power-user.yaml +++ b/examples/config-power-user.yaml @@ -15,7 +15,7 @@ alias: HomeNAS # Full replacement for the built-in server instructions. # Copy the built-in server.md as a starting point, customize it, # and point to your version. Same template variables are supported. -# instructions_file: ~/.config/synology-mcp/my-instructions.md +# instructions_file: ~/.config/mcp-synology/my-instructions.md connection: host: nas.local @@ -30,9 +30,13 @@ modules: settings: file_type_indicator: text async_timeout: 180 + upload_timeout: 600 + download_timeout: 600 + default_download_dir: ~/Downloads + default_upload_dir: /home/uploads system: enabled: true # CPU, memory, temperature, uptime (resource usage requires admin) logging: level: debug - file: ~/.local/state/synology-mcp/nas-primary/server.log + file: ~/.local/state/mcp-synology/nas-primary/server.log diff --git a/pyproject.toml b/pyproject.toml index 9796e42..f70726f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,11 +3,11 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "synology-mcp" -version = "0.3.1" +name = "mcp-synology" +version = "0.4.0" description = "MCP server for Synology NAS — manage files on your NAS via Claude" readme = "README.md" -license = "MIT" +license = "Apache-2.0" requires-python = ">=3.11" authors = [ { name = "Chris Means" }, @@ -17,7 +17,7 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -43,19 +43,28 @@ dev = [ "types-PyYAML>=6.0", "respx>=0.22", ] +vdsm = [ + "testcontainers>=4.0", + "playwright>=1.50", +] [project.scripts] -synology-mcp = "synology_mcp.cli:main" +mcp-synology = "mcp_synology.cli:main" [project.urls] -Homepage = "https://github.com/cmeans/synology-mcp" -Repository = "https://github.com/cmeans/synology-mcp" -Issues = "https://github.com/cmeans/synology-mcp/issues" -Documentation = "https://github.com/cmeans/synology-mcp/tree/main/docs" +Homepage = "https://github.com/cmeans/mcp-synology" +Repository = "https://github.com/cmeans/mcp-synology" +Issues = "https://github.com/cmeans/mcp-synology/issues" +Documentation = "https://github.com/cmeans/mcp-synology/tree/main/docs" [tool.hatch.build.targets.wheel] -packages = ["src/synology_mcp"] -artifacts = ["src/synology_mcp/instructions/*.md"] +packages = ["src/mcp_synology"] +artifacts = [ + "src/mcp_synology/instructions/*.md", + "src/mcp_synology/icons/*.svg", + "src/mcp_synology/icons/*.png", + "src/mcp_synology/favicon.ico", +] [tool.ruff] target-version = "py311" @@ -74,5 +83,8 @@ disallow_untyped_defs = true [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" -markers = ["integration: requires a real Synology NAS (not run in CI)"] -addopts = "-m 'not integration'" +markers = [ + "integration: requires a real Synology NAS (not run in CI)", + "vdsm: requires virtual-dsm Docker container with KVM (not run in CI by default)", +] +addopts = "-m 'not integration and not vdsm'" diff --git a/scripts/migrate-from-synology-mcp.py b/scripts/migrate-from-synology-mcp.py new file mode 100755 index 0000000..43a505b --- /dev/null +++ b/scripts/migrate-from-synology-mcp.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Migrate from synology-mcp (<=0.3.x) to mcp-synology (>=0.4.0). + +Moves config/state directories and migrates keyring entries. +Safe to run multiple times — skips already-migrated items. + +Usage: + python scripts/migrate-from-synology-mcp.py # dry run + python scripts/migrate-from-synology-mcp.py --apply # apply changes +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +OLD_NAME = "synology-mcp" +NEW_NAME = "mcp-synology" + +KEYRING_KEYS = ("username", "password", "device_id") + + +def migrate_directory(old: Path, new: Path, *, dry_run: bool) -> bool: + """Move old directory to new location. Returns True if action taken.""" + if not old.exists(): + return False + if new.exists(): + print(f" SKIP {old} -> {new} (destination already exists)") + return False + if dry_run: + print(f" MOVE {old} -> {new}") + return True + new.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(old), str(new)) + print(f" MOVED {old} -> {new}") + return True + + +def discover_instances(config_dir: Path, state_dir: Path) -> set[str]: + """Find instance IDs from config filenames and state subdirectories.""" + instances: set[str] = set() + + # Config files: ~/.config/synology-mcp/.yaml or config.yaml + if config_dir.exists(): + for f in config_dir.iterdir(): + if f.suffix in (".yaml", ".yml") and f.stem != "config": + instances.add(f.stem) + # default instance uses config.yaml + if (config_dir / "config.yaml").exists(): + instances.add("default") + + # State dirs: ~/.local/state/synology-mcp// + if state_dir.exists(): + for d in state_dir.iterdir(): + if d.is_dir(): + instances.add(d.name) + + return instances + + +def migrate_keyring(instances: set[str], *, dry_run: bool) -> int: + """Migrate keyring entries from old service name to new. Returns count.""" + try: + import keyring as kr + except ImportError: + print(" SKIP keyring not installed — cannot migrate credentials") + return 0 + + count = 0 + for instance_id in sorted(instances): + old_service = f"{OLD_NAME}/{instance_id}" + new_service = f"{NEW_NAME}/{instance_id}" + + migrated_any = False + for key in KEYRING_KEYS: + try: + value = kr.get_password(old_service, key) + except Exception: + continue + + if not value: + continue + + # Check if already migrated + try: + existing = kr.get_password(new_service, key) + if existing: + continue + except Exception: + pass + + if dry_run: + print(f" COPY keyring {old_service}/{key} -> {new_service}/{key}") + else: + try: + kr.set_password(new_service, key, value) + print(f" COPIED keyring {old_service}/{key} -> {new_service}/{key}") + except Exception as e: + print(f" ERROR keyring {new_service}/{key}: {e}") + continue + migrated_any = True + + if migrated_any: + count += 1 + + return count + + +def cleanup_keyring(instances: set[str], *, dry_run: bool) -> None: + """Delete old keyring entries after successful migration.""" + try: + import keyring as kr + except ImportError: + return + + for instance_id in sorted(instances): + old_service = f"{OLD_NAME}/{instance_id}" + for key in KEYRING_KEYS: + try: + value = kr.get_password(old_service, key) + except Exception: + continue + if not value: + continue + if dry_run: + print(f" DELETE keyring {old_service}/{key}") + else: + try: + kr.delete_password(old_service, key) + print(f" DELETED keyring {old_service}/{key}") + except Exception as e: + print(f" ERROR deleting {old_service}/{key}: {e}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Migrate from synology-mcp to mcp-synology" + ) + parser.add_argument( + "--apply", + action="store_true", + help="Apply changes (default is dry run)", + ) + parser.add_argument( + "--cleanup", + action="store_true", + help="Delete old keyring entries after migration (requires --apply)", + ) + args = parser.parse_args() + dry_run = not args.apply + + if dry_run: + print("DRY RUN — pass --apply to make changes\n") + + home = Path.home() + old_config = home / ".config" / OLD_NAME + new_config = home / ".config" / NEW_NAME + old_state = home / ".local" / "state" / OLD_NAME + new_state = home / ".local" / "state" / NEW_NAME + + # --- Discover instances before moving directories --- + instances = discover_instances(old_config, old_state) + if not instances: + # Try the new locations in case directories already moved but keyring didn't + instances = discover_instances(new_config, new_state) + + # --- Directories --- + print("Directories:") + dir_actions = 0 + dir_actions += migrate_directory(old_config, new_config, dry_run=dry_run) + dir_actions += migrate_directory(old_state, new_state, dry_run=dry_run) + if not dir_actions and not old_config.exists() and not old_state.exists(): + if new_config.exists() or new_state.exists(): + print(" OK directories already at new location") + else: + print(" SKIP no config or state directories found") + print() + + # --- Keyring --- + print("Keyring:") + if not instances: + print(" SKIP no instances found to migrate") + else: + print(f" Found instances: {', '.join(sorted(instances))}") + count = migrate_keyring(instances, dry_run=dry_run) + if count == 0: + print(" OK all keyring entries already migrated (or not present)") + + if args.cleanup: + print() + print("Cleanup (removing old keyring entries):") + cleanup_keyring(instances, dry_run=dry_run) + elif not dry_run and count > 0: + print("\n TIP: Run with --apply --cleanup to remove old keyring entries") + print() + + # --- Summary --- + if dry_run: + print("Re-run with --apply to execute these changes.") + else: + print("Migration complete.") + print(f" - Config: {new_config}") + print(f" - State: {new_state}") + print(" - Update Claude Desktop config: change \"synology-mcp\" to \"mcp-synology\"") + + +if __name__ == "__main__": + main() diff --git a/scripts/vdsm_setup.py b/scripts/vdsm_setup.py new file mode 100755 index 0000000..797d3be --- /dev/null +++ b/scripts/vdsm_setup.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""CLI tool for creating virtual-dsm golden images for testing. + +This script automates the process of setting up a virtual-dsm container, +configuring DSM for integration testing, and saving a golden image that +can be restored for fast test runs. + +Usage: + uv run python scripts/vdsm_setup.py --version 7.2.2 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Add project root to sys.path so tests.vdsm is importable +_PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import click # noqa: E402 + +from tests.vdsm.config import DEFAULT_DSM_VERSION, DSM_VERSIONS, storage_path # noqa: E402 +from tests.vdsm.container import VirtualDsmContainer # noqa: E402 +from tests.vdsm.golden_image import has_golden_image, save_golden_image # noqa: E402 +from tests.vdsm.setup_dsm import complete_wizard, setup_dsm_for_testing, wait_for_api # noqa: E402 + + +def _check_prerequisites() -> None: + """Check that KVM and Docker are available.""" + if not os.path.exists("/dev/kvm"): + click.echo("Error: /dev/kvm not found. KVM support is required for virtual-dsm.") + click.echo(" On Linux: ensure the kvm kernel module is loaded (modprobe kvm)") + sys.exit(1) + + import shutil + + if shutil.which("docker") is None: + click.echo("Error: Docker is not installed or not in PATH.") + click.echo(" Install Docker: https://docs.docker.com/engine/install/") + sys.exit(1) + + # Quick check that Docker daemon is responsive + result = os.system("docker info > /dev/null 2>&1") # noqa: S605 + if result != 0: + click.echo("Error: Docker daemon is not running or not accessible.") + click.echo(" Start Docker: sudo systemctl start docker") + click.echo(" Or add your user to the docker group: sudo usermod -aG docker $USER") + sys.exit(1) + + +@click.command() +@click.option( + "--version", + "dsm_version", + default=DEFAULT_DSM_VERSION, + type=click.Choice(list(DSM_VERSIONS.keys())), + help="DSM version to set up.", +) +@click.option( + "--admin-user", + prompt="Admin username (use during wizard)", + default="admin", + help="Admin username created during DSM setup wizard.", +) +@click.option( + "--admin-password", + prompt=True, + hide_input=True, + confirmation_prompt=True, + help="Admin password for DSM setup wizard.", +) +def setup(dsm_version: str, admin_user: str, admin_password: str) -> None: + """Create a golden image for virtual-dsm testing. + + This command starts a virtual-dsm container, waits for you to complete + the DSM setup wizard in a browser, then configures the instance for + integration testing and saves a golden image. + """ + # 1. Check prerequisites + click.echo("Checking prerequisites...") + _check_prerequisites() + click.echo(" KVM: OK") + click.echo(" Docker: OK") + + # 2. Check if golden image already exists + if has_golden_image(dsm_version) and not click.confirm( + f"\nGolden image for DSM {dsm_version} already exists. Overwrite?" + ): + click.echo("Aborted.") + sys.exit(0) + + # 3. Create storage directory + store = storage_path(dsm_version) + store.mkdir(parents=True, exist_ok=True) + click.echo(f"\nStorage directory: {store}") + + # 4. Start virtual-dsm container + click.echo(f"\nStarting virtual-dsm container (DSM {dsm_version})...") + click.echo(" This may take several minutes on first run (downloading DSM image).") + container = VirtualDsmContainer(version=dsm_version, storage_dir=store) + + try: + container.start() + base_url = container.base_url + click.echo(f"\n Container started: {base_url}") + + # 5. Wait for DSM web UI to become accessible + click.echo("\nWaiting for DSM to finish booting...") + wait_for_api(base_url) + + # 6. Automate setup wizard with Playwright (fully headless) + click.echo("\nRunning DSM setup wizard (automated)...") + complete_wizard(base_url, admin_user, admin_password) + + # 7. Run post-wizard API configuration + click.echo("\nConfiguring DSM for integration testing...") + metadata = setup_dsm_for_testing(base_url, admin_password, admin_user=admin_user) + + # 8. Stop container gracefully + click.echo("\nStopping container...") + container.stop() + container = None # type: ignore[assignment] + + # 9. Save golden image + click.echo(f"\nSaving golden image for DSM {dsm_version}...") + image_path = save_golden_image(dsm_version, metadata=metadata) + image_size_mb = image_path.stat().st_size / (1024 * 1024) + + # 10. Print success + click.echo(f"\n{'=' * 60}") + click.echo("Golden image created successfully!") + click.echo(f"{'=' * 60}") + click.echo(f" Image: {image_path}") + click.echo(f" Size: {image_size_mb:.1f} MB") + click.echo(f" Version: DSM {dsm_version}") + click.echo(f"\n Test user: {metadata['test_user']}") + click.echo(f" Test paths: {metadata['test_paths']}") + click.echo("\nTo run integration tests:") + click.echo(" uv run pytest -m integration -v") + + except Exception: + click.echo("\nError during setup. Stopping container...", err=True) + if container is not None: + container.stop() + raise + + +if __name__ == "__main__": + setup() diff --git a/src/mcp_synology/__init__.py b/src/mcp_synology/__init__.py new file mode 100644 index 0000000..87e8d91 --- /dev/null +++ b/src/mcp_synology/__init__.py @@ -0,0 +1,5 @@ +"""mcp-synology — MCP server for Synology NAS.""" + +from importlib.metadata import version + +__version__ = version("mcp-synology") diff --git a/src/mcp_synology/__main__.py b/src/mcp_synology/__main__.py new file mode 100644 index 0000000..0311165 --- /dev/null +++ b/src/mcp_synology/__main__.py @@ -0,0 +1,5 @@ +"""Entry point for `python -m mcp_synology`.""" + +from mcp_synology.cli import main + +main() diff --git a/src/synology_mcp/cli/__init__.py b/src/mcp_synology/cli/__init__.py similarity index 76% rename from src/synology_mcp/cli/__init__.py rename to src/mcp_synology/cli/__init__.py index 031d910..65e0430 100644 --- a/src/synology_mcp/cli/__init__.py +++ b/src/mcp_synology/cli/__init__.py @@ -7,9 +7,9 @@ from __future__ import annotations -from synology_mcp.cli.main import main -from synology_mcp.cli.setup import _CONFIG_DIR, _store_keyring -from synology_mcp.cli.version import ( +from mcp_synology.cli.main import main +from mcp_synology.cli.setup import _CONFIG_DIR, _store_keyring +from mcp_synology.cli.version import ( _check_for_update, _load_global_state, _save_global_state, diff --git a/src/synology_mcp/cli/check.py b/src/mcp_synology/cli/check.py similarity index 85% rename from src/synology_mcp/cli/check.py rename to src/mcp_synology/cli/check.py index 2fc0864..5c56a4c 100644 --- a/src/synology_mcp/cli/check.py +++ b/src/mcp_synology/cli/check.py @@ -7,7 +7,7 @@ import click -from synology_mcp.cli.logging_ import _configure_logging, _init_early_logging +from mcp_synology.cli.logging_ import _configure_logging, _init_early_logging @click.command() @@ -17,7 +17,7 @@ def check(config: str | None, verbose: bool) -> None: """Validate stored credentials can authenticate.""" _init_early_logging(verbose=verbose) - from synology_mcp.core.config import load_config + from mcp_synology.core.config import load_config try: app_config = load_config(config) @@ -34,9 +34,9 @@ def check(config: str | None, verbose: bool) -> None: async def _check_login(config: object) -> None: """Validate credentials by attempting a login.""" - from synology_mcp.core.auth import AuthManager - from synology_mcp.core.client import DsmClient - from synology_mcp.core.config import AppConfig + from mcp_synology.core.auth import AuthManager + from mcp_synology.core.client import DsmClient + from mcp_synology.core.config import AppConfig if not isinstance(config, AppConfig): raise RuntimeError("Expected AppConfig instance") @@ -53,7 +53,7 @@ async def _check_login(config: object) -> None: ) as client: await client.query_api_info() - from synology_mcp.core.errors import SynologyError + from mcp_synology.core.errors import SynologyError auth = AuthManager(config, client) try: diff --git a/src/synology_mcp/cli/logging_.py b/src/mcp_synology/cli/logging_.py similarity index 100% rename from src/synology_mcp/cli/logging_.py rename to src/mcp_synology/cli/logging_.py diff --git a/src/synology_mcp/cli/main.py b/src/mcp_synology/cli/main.py similarity index 89% rename from src/synology_mcp/cli/main.py rename to src/mcp_synology/cli/main.py index 7ba6249..0243bd8 100644 --- a/src/synology_mcp/cli/main.py +++ b/src/mcp_synology/cli/main.py @@ -6,9 +6,9 @@ import click -from synology_mcp import __version__ -from synology_mcp.cli.logging_ import _configure_logging, _init_early_logging -from synology_mcp.cli.version import ( +from mcp_synology import __version__ +from mcp_synology.cli.logging_ import _configure_logging, _init_early_logging +from mcp_synology.cli.version import ( _check_for_update, _detect_installer, _do_auto_upgrade, @@ -18,11 +18,11 @@ _save_global_state, ) -_PYPI_PACKAGE = "synology-mcp" +_PYPI_PACKAGE = "mcp-synology" @click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True) -@click.version_option(__version__, "-v", "--version", prog_name="synology-mcp") +@click.version_option(__version__, "-v", "--version", prog_name="mcp-synology") @click.option("--check-update", is_flag=True, help="Check PyPI for a newer version") @click.option( "--auto-upgrade", @@ -46,7 +46,7 @@ def main( auto_upgrade: str | None, revert: str | None, ) -> None: - """synology-mcp — MCP server for Synology NAS.""" + """mcp-synology — MCP server for Synology NAS.""" if check_update: state = _load_global_state() latest = _check_for_update(state, force=True) @@ -104,8 +104,8 @@ def serve(config: str | None) -> None: """Start the MCP server (launched by Claude Desktop).""" _init_early_logging() - from synology_mcp.core.config import load_config - from synology_mcp.server import create_server + from mcp_synology.core.config import load_config + from mcp_synology.server import create_server try: app_config = load_config(config) @@ -121,8 +121,8 @@ def serve(config: str | None) -> None: # Import and attach subcommands — avoids circular imports since # setup.py and check.py define standalone @click.command() functions. -from synology_mcp.cli.check import check # noqa: E402 -from synology_mcp.cli.setup import setup # noqa: E402 +from mcp_synology.cli.check import check # noqa: E402 +from mcp_synology.cli.setup import setup # noqa: E402 main.add_command(setup) main.add_command(check) diff --git a/src/synology_mcp/cli/setup.py b/src/mcp_synology/cli/setup.py similarity index 93% rename from src/synology_mcp/cli/setup.py rename to src/mcp_synology/cli/setup.py index 8a6b835..3bdc106 100644 --- a/src/synology_mcp/cli/setup.py +++ b/src/mcp_synology/cli/setup.py @@ -13,9 +13,9 @@ import click import yaml -from synology_mcp.cli.logging_ import _configure_logging, _init_early_logging +from mcp_synology.cli.logging_ import _configure_logging, _init_early_logging -_CONFIG_DIR = Path.home() / ".config" / "synology-mcp" +_CONFIG_DIR = Path.home() / ".config" / "mcp-synology" @click.command() @@ -36,7 +36,7 @@ def setup(config: str | None, list_configs: bool, verbose: bool) -> None: return # Try to load an existing config via discovery - from synology_mcp.core.config import load_config + from mcp_synology.core.config import load_config try: app_config = load_config(None) @@ -95,7 +95,7 @@ def _setup_interactive(verbose: bool) -> None: ) # Derive instance_id for file naming - from synology_mcp.core.config import _derive_instance_id + from mcp_synology.core.config import _derive_instance_id instance_id = _derive_instance_id(host) @@ -124,7 +124,7 @@ def _setup_interactive(verbose: bool) -> None: config_dict["alias"] = alias # Validate before writing - from synology_mcp.core.config import AppConfig + from mcp_synology.core.config import AppConfig try: app_config = AppConfig(**config_dict) @@ -138,7 +138,7 @@ def _setup_interactive(verbose: bool) -> None: password = click.prompt("DSM password", hide_input=True) # Store in keyring - service = f"synology-mcp/{instance_id}" + service = f"mcp-synology/{instance_id}" keyring_ok = _store_keyring(service, username, password) if not keyring_ok: return @@ -172,7 +172,7 @@ def _setup_interactive(verbose: bool) -> None: # Write config file _CONFIG_DIR.mkdir(parents=True, exist_ok=True) raw_yaml = yaml.dump(config_dict, default_flow_style=False, sort_keys=False) - header = "# Generated by synology-mcp setup\n" + header = "# Generated by mcp-synology setup\n" config_path.write_text(header + raw_yaml, encoding="utf-8") click.echo(f"\nConfig written to {config_path}") @@ -182,7 +182,7 @@ def _setup_interactive(verbose: bool) -> None: def _setup_with_config(config_path_str: str, verbose: bool) -> None: """Setup using an explicit config file path.""" - from synology_mcp.core.config import load_config + from mcp_synology.core.config import load_config try: app_config = load_config(config_path_str) @@ -195,7 +195,7 @@ def _setup_with_config(config_path_str: str, verbose: bool) -> None: def _setup_credential_flow(app_config: Any, verbose: bool) -> None: """Credential setup flow for an existing config.""" - from synology_mcp.core.config import AppConfig + from mcp_synology.core.config import AppConfig if not isinstance(app_config, AppConfig): raise RuntimeError("Expected AppConfig instance") @@ -213,7 +213,7 @@ def _setup_credential_flow(app_config: Any, verbose: bool) -> None: username = click.prompt("DSM username") password = click.prompt("DSM password", hide_input=True) - service = f"synology-mcp/{app_config.instance_id}" + service = f"mcp-synology/{app_config.instance_id}" keyring_ok = _store_keyring(service, username, password) if not keyring_ok: return @@ -252,7 +252,7 @@ async def _attempt_login( Returns dict with 'success' bool, and optionally 'sid'. On 2FA success, stores device token in keyring. """ - from synology_mcp.core.errors import SynologyError + from mcp_synology.core.errors import SynologyError result: dict[str, Any] = {"success": False} @@ -278,7 +278,7 @@ async def _attempt_login( "passwd": password, "otp_code": otp_code, "enable_device_token": "yes", - "device_name": "SynologyMCP", + "device_name": "MCPSynology", "format": "sid", }, ) @@ -315,8 +315,8 @@ async def _connect_and_login( config: Any, username: str, password: str, service: str, verbose: bool ) -> dict[str, Any]: """Connect to NAS, validate login, fetch hostname. Returns result dict.""" - from synology_mcp.core.client import DsmClient - from synology_mcp.core.config import AppConfig + from mcp_synology.core.client import DsmClient + from mcp_synology.core.config import AppConfig if not isinstance(config, AppConfig): raise RuntimeError("Expected AppConfig instance") @@ -358,8 +358,8 @@ async def _connect_and_login( async def _setup_login(config: object, username: str, password: str, service: str) -> None: """Attempt login during setup, handle 2FA if needed.""" - from synology_mcp.core.client import DsmClient - from synology_mcp.core.config import AppConfig + from mcp_synology.core.client import DsmClient + from mcp_synology.core.config import AppConfig if not isinstance(config, AppConfig): raise RuntimeError("Expected AppConfig instance") @@ -395,7 +395,7 @@ def _emit_claude_desktop_snippet(config: Any, config_path: Path) -> None: "--directory", str(Path.cwd()), "run", - "synology-mcp", + "mcp-synology", "serve", "--config", str(config_path), diff --git a/src/synology_mcp/cli/version.py b/src/mcp_synology/cli/version.py similarity index 95% rename from src/synology_mcp/cli/version.py rename to src/mcp_synology/cli/version.py index 4cedd6d..866317a 100644 --- a/src/synology_mcp/cli/version.py +++ b/src/mcp_synology/cli/version.py @@ -12,9 +12,9 @@ import click import yaml -from synology_mcp import __version__ +from mcp_synology import __version__ -_PYPI_PACKAGE = "synology-mcp" +_PYPI_PACKAGE = "mcp-synology" _VERSION_CHECK_INTERVAL = 86400 # 24 hours @@ -65,7 +65,7 @@ def _detect_installer() -> str | None: def _load_global_state() -> dict[str, Any]: """Load global (non-instance) state for version tracking.""" - path = Path.home() / ".local" / "state" / "synology-mcp" / "global.yaml" + path = Path.home() / ".local" / "state" / "mcp-synology" / "global.yaml" if not path.exists(): return {} try: @@ -77,10 +77,10 @@ def _load_global_state() -> dict[str, Any]: def _save_global_state(state: dict[str, Any]) -> None: """Save global state.""" - path = Path.home() / ".local" / "state" / "synology-mcp" / "global.yaml" + path = Path.home() / ".local" / "state" / "mcp-synology" / "global.yaml" path.parent.mkdir(parents=True, exist_ok=True) raw_text = yaml.dump(state, default_flow_style=False, sort_keys=False) - path.write_text("# Auto-generated by synology-mcp.\n" + raw_text, encoding="utf-8") + path.write_text("# Auto-generated by mcp-synology.\n" + raw_text, encoding="utf-8") def _check_for_update(state: dict[str, Any], *, force: bool = False) -> str | None: diff --git a/src/synology_mcp/core/__init__.py b/src/mcp_synology/core/__init__.py similarity index 100% rename from src/synology_mcp/core/__init__.py rename to src/mcp_synology/core/__init__.py diff --git a/src/synology_mcp/core/auth.py b/src/mcp_synology/core/auth.py similarity index 92% rename from src/synology_mcp/core/auth.py rename to src/mcp_synology/core/auth.py index 8275038..213febc 100644 --- a/src/synology_mcp/core/auth.py +++ b/src/mcp_synology/core/auth.py @@ -15,11 +15,11 @@ import keyring as kr -from synology_mcp.core.errors import AuthenticationError, SynologyError +from mcp_synology.core.errors import AuthenticationError, SynologyError if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient - from synology_mcp.core.config import AppConfig + from mcp_synology.core.client import DsmClient + from mcp_synology.core.config import AppConfig logger = logging.getLogger(__name__) @@ -41,10 +41,10 @@ def __init__(self, config: AppConfig, client: DsmClient) -> None: logger.debug("AuthManager initialized, session name: %s", self._session_name) def _build_session_name(self) -> str: - """Build a unique DSM session name: SynologyMCP_{instance_id}_{uuid}.""" + """Build a unique DSM session name: MCPSynology_{instance_id}_{uuid}.""" instance_id = self._config.instance_id or "default" unique_id = uuid.uuid4().hex[:8] - return f"SynologyMCP_{instance_id}_{unique_id}" + return f"MCPSynology_{instance_id}_{unique_id}" def _resolve_credentials(self) -> tuple[str, str, str | None]: """Resolve credentials from the storage hierarchy. @@ -83,7 +83,7 @@ def _resolve_credentials(self) -> tuple[str, str, str | None]: device_id = self._config.auth.device_id logger.debug("Device ID from config file") - # 3. OS keyring (implicit default — set by 'synology-mcp setup') + # 3. OS keyring (implicit default — set by 'mcp-synology setup') # Check keyring if we're missing username, password, OR device_id if not username or not password or not device_id: # On Linux, ensure D-Bus session address is available for keyring access. @@ -100,7 +100,7 @@ def _resolve_credentials(self) -> tuple[str, str, str | None]: logger.debug("D-Bus socket not found at %s; keyring may not work", socket_path) try: - service = f"synology-mcp/{self._config.instance_id or 'default'}" + service = f"mcp-synology/{self._config.instance_id or 'default'}" logger.debug("Trying keyring service: %s", service) kr_user = kr.get_password(service, "username") kr_pass = kr.get_password(service, "password") @@ -119,7 +119,7 @@ def _resolve_credentials(self) -> tuple[str, str, str | None]: if not username or not password: msg = ( - "No credentials found. Run 'synology-mcp setup' to store credentials " + "No credentials found. Run 'mcp-synology setup' to store credentials " "in the OS keyring, or set SYNOLOGY_USERNAME and SYNOLOGY_PASSWORD " "environment variables." ) @@ -148,7 +148,7 @@ async def login(self) -> str: # Path B: 2FA with remembered device if device_id: logger.debug("Login path: 2FA with device token") - params["device_name"] = "SynologyMCP" + params["device_name"] = "MCPSynology" params["device_id"] = device_id else: logger.debug("Login path: simple (no device token)") @@ -160,9 +160,9 @@ async def login(self) -> str: logger.debug("Login failed: 2FA required but no device token available") raise AuthenticationError( "2FA is required but no device token is available. " - "Run 'synology-mcp setup' to complete 2FA bootstrap.", + "Run 'mcp-synology setup' to complete 2FA bootstrap.", code=_ERROR_2FA_REQUIRED, - suggestion="Run: synology-mcp setup --config ", + suggestion="Run: mcp-synology setup --config ", ) from e raise diff --git a/src/synology_mcp/core/client.py b/src/mcp_synology/core/client.py similarity index 53% rename from src/synology_mcp/core/client.py rename to src/mcp_synology/core/client.py index 953dfa7..3b5e958 100644 --- a/src/synology_mcp/core/client.py +++ b/src/mcp_synology/core/client.py @@ -6,23 +6,32 @@ from __future__ import annotations +import json import logging +import shutil from collections.abc import Callable, Coroutine -from typing import Any, Self +from typing import TYPE_CHECKING, Any, BinaryIO, Self + +if TYPE_CHECKING: + from pathlib import Path import httpx -from synology_mcp.core.errors import ( +from mcp_synology.core.errors import ( SynologyError, error_from_code, ) -from synology_mcp.core.state import ApiInfoEntry +from mcp_synology.core.state import ApiInfoEntry logger = logging.getLogger(__name__) # Session error codes that trigger transparent re-auth. _SESSION_ERROR_CODES = frozenset({106, 107, 119}) +# Type alias for transfer progress callbacks. +# Called with (bytes_transferred, total_bytes_or_None). +ProgressCallback = Callable[[int, int | None], Coroutine[Any, Any, None]] + class DsmClient: """Async DSM API client. @@ -146,6 +155,8 @@ async def query_api_info(self) -> dict[str, ApiInfoEntry]: "SYNO.FileStation.Delete", "SYNO.Core.System", "SYNO.Core.System.Utilization", + "SYNO.FileStation.Upload", + "SYNO.FileStation.Download", } ) for name in sorted(_relevant_apis): @@ -179,7 +190,7 @@ def negotiate_version( Raises ApiNotFoundError if the API is not in the cache. """ if api_name not in self._api_cache: - from synology_mcp.core.errors import ApiNotFoundError + from mcp_synology.core.errors import ApiNotFoundError raise ApiNotFoundError( f"API '{api_name}' not found on this NAS.", @@ -198,13 +209,13 @@ def negotiate_version( negotiated = min(our_max, nas_max) if negotiated < max(min_version, nas_min): - from synology_mcp.core.errors import ApiNotFoundError + from mcp_synology.core.errors import ApiNotFoundError raise ApiNotFoundError( f"API '{api_name}': no compatible version. " f"NAS supports v{nas_min}-v{nas_max}, we need v{min_version}+.", code=104, - suggestion="Update DSM or use an older version of synology-mcp.", + suggestion="Update DSM or use an older version of mcp-synology.", ) logger.debug( @@ -238,7 +249,7 @@ async def request( # Resolve API path and version if api not in self._api_cache: - from synology_mcp.core.errors import ApiNotFoundError + from mcp_synology.core.errors import ApiNotFoundError raise ApiNotFoundError( f"API '{api}' not found. Call query_api_info() first.", @@ -304,6 +315,246 @@ async def request( raise error_from_code(code, api) + async def upload( + self, + dest_folder: str, + file_path: Path, + filename: str, + *, + overwrite: bool = False, + create_parents: bool = True, + version: int | None = None, + timeout: float = 300.0, + _is_retry: bool = False, + ) -> dict[str, Any]: + """Upload a file to the NAS via SYNO.FileStation.Upload (POST multipart). + + This is the ONE case where POST is mandatory — the Upload API requires + multipart form data. All other DSM APIs use GET. + + The file is opened (or re-opened on retry) within this method so the + stream is always fresh. + + Returns the parsed data dict from the JSON response envelope. + """ + api = "SYNO.FileStation.Upload" + http = self._get_http() + + if api not in self._api_cache: + from mcp_synology.core.errors import ApiNotFoundError + + raise ApiNotFoundError( + f"API '{api}' not found. Call query_api_info() first.", + code=102, + ) + + info = self._api_cache[api] + # Pin to v2 — v3 uses JSON request format that is incompatible with + # our multipart POST. Same issue as CopyMove/Delete. + resolved_version = version if version is not None else min(info.max_version, 2) + url = f"{self._base_url}/webapi/{info.path}" + + form_data: dict[str, str] = { + "api": api, + "version": str(resolved_version), + "method": "upload", + "path": dest_folder, + "overwrite": str(overwrite).lower(), + "create_parents": str(create_parents).lower(), + } + # SID must be a query parameter, not a form field — the Upload API + # does not read _sid from multipart form data. + query_params: dict[str, str] = {} + if self._sid: + query_params["_sid"] = self._sid + + _sensitive = frozenset({"_sid"}) + log_data = {k: ("***" if k in _sensitive else v) for k, v in form_data.items()} + retry_tag = " (retry)" if _is_retry else "" + logger.debug( + "DSM POST%s: %s/upload v%d — %s, file=%s", + retry_tag, + api, + resolved_version, + log_data, + filename, + ) + + def _open_file() -> BinaryIO: + return open(file_path, "rb") # noqa: SIM115 + + fh = _open_file() + try: + resp = await http.post( + url, + params=query_params, + data=form_data, + files={"file": (filename, fh, "application/octet-stream")}, + timeout=httpx.Timeout(timeout), + ) + finally: + fh.close() + + resp.raise_for_status() + body = resp.json() + + if body.get("success"): + logger.debug("DSM response: %s/upload — success", api) + data: dict[str, Any] = body.get("data", {}) + return data + + code = body.get("error", {}).get("code", 0) + logger.debug("DSM response: %s/upload — error code %d", api, code) + + # Re-auth on session errors (file must be re-opened on retry) + if code in _SESSION_ERROR_CODES and not _is_retry and self._re_auth_callback: + logger.info("Session error %d on %s/upload, attempting re-auth.", code, api) + try: + await self._re_auth_callback() + except SynologyError: + raise error_from_code(code, api) from None + return await self.upload( + dest_folder, + file_path, + filename, + overwrite=overwrite, + create_parents=create_parents, + version=version, + timeout=timeout, + _is_retry=True, + ) + + raise error_from_code(code, api) + + async def download( + self, + path: str, + dest_file: Path, + *, + version: int | None = None, + timeout: float = 300.0, + chunk_size: int = 65536, + progress_callback: ProgressCallback | None = None, + _is_retry: bool = False, + ) -> int: + """Download a file from the NAS via SYNO.FileStation.Download (GET, binary). + + Streams the response to disk. Returns total bytes written. + + Checks Content-Length against local disk free space before writing. + If the NAS returns a JSON error envelope (Content-Type: application/json) + instead of binary data, parses and raises the appropriate exception. + """ + api = "SYNO.FileStation.Download" + http = self._get_http() + + if api not in self._api_cache: + from mcp_synology.core.errors import ApiNotFoundError + + raise ApiNotFoundError( + f"API '{api}' not found. Call query_api_info() first.", + code=102, + ) + + info = self._api_cache[api] + resolved_version = version if version is not None else info.max_version + url = f"{self._base_url}/webapi/{info.path}" + + params: dict[str, str] = { + "api": api, + "version": str(resolved_version), + "method": "download", + "path": path, + "mode": "download", + } + if self._sid: + params["_sid"] = self._sid + + _sensitive = frozenset({"_sid"}) + log_params = {k: ("***" if k in _sensitive else v) for k, v in params.items()} + retry_tag = " (retry)" if _is_retry else "" + logger.debug( + "DSM GET%s: %s/download v%d — %s", + retry_tag, + api, + resolved_version, + log_params, + ) + + async with http.stream("GET", url, params=params, timeout=httpx.Timeout(timeout)) as resp: + # Check for HTTP-level errors (502, 404, etc.) before reading body. + # Some DSM errors come back as HTTP errors rather than JSON envelopes. + if resp.status_code >= 400: + # Try to read the body for a DSM error envelope + body_bytes = await resp.aread() + try: + body = json.loads(body_bytes) + code = body.get("error", {}).get("code", 0) + raise error_from_code(code, api) + except (json.JSONDecodeError, KeyError): + from mcp_synology.core.errors import FileStationError + + raise FileStationError( + f"HTTP {resp.status_code} from download API", + code=resp.status_code, + suggestion="Check that the file path exists on the NAS.", + ) from None + + content_type = resp.headers.get("content-type", "") + + # JSON response means error envelope + if "application/json" in content_type: + body_bytes = await resp.aread() + body = json.loads(body_bytes) + code = body.get("error", {}).get("code", 0) + logger.debug("DSM response: %s/download — error code %d", api, code) + + if code in _SESSION_ERROR_CODES and not _is_retry and self._re_auth_callback: + logger.info("Session error %d on %s/download, attempting re-auth.", code, api) + try: + await self._re_auth_callback() + except SynologyError: + raise error_from_code(code, api) from None + return await self.download( + path, + dest_file, + version=version, + timeout=timeout, + chunk_size=chunk_size, + progress_callback=progress_callback, + _is_retry=True, + ) + + raise error_from_code(code, api) + + # Check disk space before writing + content_length = resp.headers.get("content-length") + total_size: int | None = int(content_length) if content_length else None + if total_size: + free_space = shutil.disk_usage(dest_file.parent).free + if total_size > free_space: + from mcp_synology.core.formatting import format_size + + msg = ( + f"Insufficient local disk space: file is {format_size(total_size)} " + f"but only {format_size(free_space)} free." + ) + raise OSError(msg) + + # Binary response — stream to disk with progress + bytes_written = 0 + with open(dest_file, "wb") as fh: + async for chunk in resp.aiter_bytes(chunk_size=chunk_size): + fh.write(chunk) + bytes_written += len(chunk) + if progress_callback: + await progress_callback(bytes_written, total_size) + + logger.debug( + "DSM response: %s/download — success (%d bytes written)", api, bytes_written + ) + return bytes_written + async def fetch_dsm_info(self) -> dict[str, Any]: """Query SYNO.DSM.Info getinfo and return the data dict. diff --git a/src/synology_mcp/core/config.py b/src/mcp_synology/core/config.py similarity index 94% rename from src/synology_mcp/core/config.py rename to src/mcp_synology/core/config.py index 30ca341..079fac7 100644 --- a/src/synology_mcp/core/config.py +++ b/src/mcp_synology/core/config.py @@ -154,9 +154,9 @@ def discover_config_path(explicit_path: str | None = None) -> Path: """Find the config file using the discovery hierarchy. 1. Explicit --config flag - 2. SYNOLOGY_MCP_CONFIG env var - 3. ~/.config/synology-mcp/config.yaml - 4. ./synology-mcp.yaml + 2. MCP_SYNOLOGY_CONFIG env var + 3. ~/.config/mcp-synology/config.yaml + 4. ./mcp-synology.yaml """ if explicit_path: path = Path(explicit_path).expanduser() @@ -166,18 +166,18 @@ def discover_config_path(explicit_path: str | None = None) -> Path: raise FileNotFoundError(msg) return path - env_path = os.environ.get("SYNOLOGY_MCP_CONFIG") + env_path = os.environ.get("MCP_SYNOLOGY_CONFIG") if env_path: path = Path(env_path).expanduser() - logger.debug("Config path from SYNOLOGY_MCP_CONFIG env: %s", path) + logger.debug("Config path from MCP_SYNOLOGY_CONFIG env: %s", path) if not path.exists(): - msg = f"Config file not found (from SYNOLOGY_MCP_CONFIG): {path}" + msg = f"Config file not found (from MCP_SYNOLOGY_CONFIG): {path}" raise FileNotFoundError(msg) return path default_paths = [ - Path.home() / ".config" / "synology-mcp" / "config.yaml", - Path.cwd() / "synology-mcp.yaml", + Path.home() / ".config" / "mcp-synology" / "config.yaml", + Path.cwd() / "mcp-synology.yaml", ] for path in default_paths: logger.debug("Checking default config path: %s", path) @@ -186,7 +186,7 @@ def discover_config_path(explicit_path: str | None = None) -> Path: return path msg = ( - "No config file found. Create one at ~/.config/synology-mcp/config.yaml " + "No config file found. Create one at ~/.config/mcp-synology/config.yaml " "or specify with --config. See examples/ for sample configs." ) raise FileNotFoundError(msg) @@ -228,12 +228,12 @@ def _emit_warnings(config: AppConfig) -> None: if creds_from_env: logger.warning( "Credentials provided via environment variables. " - "Consider using 'synology-mcp setup' to store them securely in the OS keyring." + "Consider using 'mcp-synology setup' to store them securely in the OS keyring." ) else: logger.warning( "Plaintext credentials found in config file. " - "Use 'synology-mcp setup' to store credentials securely in the OS keyring." + "Use 'mcp-synology setup' to store credentials securely in the OS keyring." ) conn = config.connection diff --git a/src/synology_mcp/core/errors.py b/src/mcp_synology/core/errors.py similarity index 95% rename from src/synology_mcp/core/errors.py rename to src/mcp_synology/core/errors.py index 7b50f2f..4e83635 100644 --- a/src/synology_mcp/core/errors.py +++ b/src/mcp_synology/core/errors.py @@ -79,7 +79,7 @@ class IllegalNameError(FileStationError): "This account does not have permission to use this service. " "Check DSM > Control Panel > User > Applications.", ), - 403: ("2FA required", "Run 'synology-mcp setup' to complete 2FA bootstrap."), + 403: ("2FA required", "Run 'mcp-synology setup' to complete 2FA bootstrap."), 404: ( "2FA code failed", "The OTP code was incorrect or expired. Try again with a fresh code.", @@ -154,6 +154,10 @@ class IllegalNameError(FileStationError): "Unsupported target filesystem", "The destination filesystem does not support this operation.", ), + 1800: ("Disk quota exceeded during upload", "Free space or contact NAS administrator."), + 1801: ("No permission to upload", "Check DSM shared folder write permissions."), + 1802: ("Upload error", "Check the destination path and disk space."), + 1803: ("Upload timeout", "Try a smaller file or increase the timeout."), } diff --git a/src/synology_mcp/core/formatting.py b/src/mcp_synology/core/formatting.py similarity index 100% rename from src/synology_mcp/core/formatting.py rename to src/mcp_synology/core/formatting.py diff --git a/src/synology_mcp/core/state.py b/src/mcp_synology/core/state.py similarity index 90% rename from src/synology_mcp/core/state.py rename to src/mcp_synology/core/state.py index 605fcf6..4e5badd 100644 --- a/src/synology_mcp/core/state.py +++ b/src/mcp_synology/core/state.py @@ -1,7 +1,7 @@ """State file read/write. Server-managed runtime state stored at: - ~/.local/state/synology-mcp/{instance_id}/state.yaml + ~/.local/state/mcp-synology/{instance_id}/state.yaml """ from __future__ import annotations @@ -35,7 +35,7 @@ class ServerState(BaseModel): def _state_path(instance_id: str) -> Path: """Get the state file path for an instance.""" - return Path.home() / ".local" / "state" / "synology-mcp" / instance_id / "state.yaml" + return Path.home() / ".local" / "state" / "mcp-synology" / instance_id / "state.yaml" def load_state(instance_id: str) -> ServerState: @@ -58,5 +58,5 @@ def save_state(instance_id: str, state: ServerState) -> None: raw_text = yaml.dump(data, default_flow_style=False, sort_keys=False) # Add header comment - header = "# Auto-generated by synology-mcp. Do not edit.\n" + header = "# Auto-generated by mcp-synology. Do not edit.\n" path.write_text(header + raw_text, encoding="utf-8") diff --git a/src/mcp_synology/favicon.ico b/src/mcp_synology/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..31377522906e41cc70023383da7a8d3016d66cd9 GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-128.png b/src/mcp_synology/icons/mcp-synology-logo-dark-128.png new file mode 100644 index 0000000000000000000000000000000000000000..522c33004c8ef071f1c8553c6e4e15b9376dd960 GIT binary patch literal 8228 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 Ul3Ua5M}!|xl2endlr|6j9|kg_y8r+H literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-16.png b/src/mcp_synology/icons/mcp-synology-logo-dark-16.png new file mode 100644 index 0000000000000000000000000000000000000000..e2734ae3a5afe381aed662711c2396b1c0025ee1 GIT binary patch literal 650 zcmV;50(Jd~P)wWKe&zWfviJFGcvz=#t4A0Dvnc;urE-Nf=^sXV(EC4`k|MmLN;YB_=Kf&FX z{W%wP%nD0dxhk7hWg#O-JRFk#*mx8^UkF&)jEhNm8fJa9?jb6G3p2GcLD*}oNVrkL z+4WMS(_ZsCCxDgb`7yL^bb9t8l$HQM8N^0&@Br610QvwLs3Vunl2?^-+*&W6{LVnD zkKHouH?4T6WZuEQrEK-F*L_ZA3=&vt#DXptK_)V=om7s`gxDDHRHo`F08qjmb<_%7 zd<)|)_6BjWXcazpbB9@H64@}oX8|I-;9_H@E~=jqaT@@_Q2C(h+m!cwVEr^k&JBdI zpAv>Z=hm2#HO(u#Sd5b7c>!00T<=NnUX=pH)Mf7%2FZHt^4Dx00Kk6$p=`Yi%pDUA znw?W|OJZ^pN+g*^!7!Hiotc_2)MpQgxzSBWdy?!>v@uZ@?nbKnQNc0SHY%ch_EIMB z5u1549xs@>ClhS78{18#Ns-okF6vG{b#`gsc389vh7s9-lBlT7CgvlsdlV}bP^RECXUnM+-+eoQa%>oQ+vJGHQ=P_FTiOK2U;?trWCf*UJYNa^b4nTe>2{y k4fbZOcz>DapMb0A7yXMIc<=a?ApigX07*qoM6N<$f|1G|kpKVy literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-256.png b/src/mcp_synology/icons/mcp-synology-logo-dark-256.png new file mode 100644 index 0000000000000000000000000000000000000000..0009bb67ed76389531da5a0b89bc69db89261ade GIT binary patch literal 15761 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 literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-32.png b/src/mcp_synology/icons/mcp-synology-logo-dark-32.png new file mode 100644 index 0000000000000000000000000000000000000000..15b2b7c539b2978d6eb8b4e45d85ed0d99956ac4 GIT binary patch literal 1656 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 literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-64.png b/src/mcp_synology/icons/mcp-synology-logo-dark-64.png new file mode 100644 index 0000000000000000000000000000000000000000..807cc064d1382af3ef6bdbf57ac03a7b6e618acd GIT binary patch literal 3810 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 Y1sQonG@Vki-~a#s07*qoM6N<$f>)<0t^fc4 literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark.svg b/src/mcp_synology/icons/mcp-synology-logo-dark.svg new file mode 100644 index 0000000..14039d5 --- /dev/null +++ b/src/mcp_synology/icons/mcp-synology-logo-dark.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-128.png b/src/mcp_synology/icons/mcp-synology-logo-light-128.png new file mode 100644 index 0000000000000000000000000000000000000000..7681b1832de73f8815ed907b7568afb15a4ea89c GIT binary patch literal 8035 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 literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-16.png b/src/mcp_synology/icons/mcp-synology-logo-light-16.png new file mode 100644 index 0000000000000000000000000000000000000000..d99f76569d869f8d77ae44410d93416fa14c441c GIT binary patch literal 648 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

xz#cdS8u4M2xy-zu3es2g_TFS5#bRDgXYYusw` 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 NHDyhuY6Z*C{||U(WKRG9 literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-32.png b/src/mcp_synology/icons/mcp-synology-logo-light-32.png new file mode 100644 index 0000000000000000000000000000000000000000..1bb2f0edaf58707eb391542e3bd2dcaec8fb749a GIT binary patch literal 1632 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 literal 0 HcmV?d00001 diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-64.png b/src/mcp_synology/icons/mcp-synology-logo-light-64.png new file mode 100644 index 0000000000000000000000000000000000000000..919cde1ed833bf855956df7f780cbb05aaee84ca GIT binary patch literal 3759 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/synology_mcp/instructions/server.md b/src/mcp_synology/instructions/server.md similarity index 76% rename from src/synology_mcp/instructions/server.md rename to src/mcp_synology/instructions/server.md index cf2d4c3..f00d17c 100644 --- a/src/synology_mcp/instructions/server.md +++ b/src/mcp_synology/instructions/server.md @@ -7,9 +7,11 @@ Do NOT fall back to a different Synology server if an operation fails — ask th {instance_id} — derived from host or set explicitly (e.g., "192-168-200-52") {host} — NAS hostname or IP (e.g., "192.168.200.52") {port} — connection port (e.g., "5000") + {home_dir} — local user home directory (e.g., "/home/chris") + {platform} — local OS: "Linux", "macOS", or "Windows" --> -You are connected to a Synology NAS via the synology-mcp File Station module. +You are connected to a Synology NAS via the mcp-synology File Station module. PATH FORMAT: All file paths start with a shared folder name: /video/..., /music/..., etc. @@ -24,11 +26,25 @@ WORKING WITH FILES: - Use list_files to browse directories, search_files to find specific files - get_file_info for detailed metadata, get_dir_size for directory totals +LOCAL MACHINE: +This MCP server runs on the user's {platform} machine. +Home directory: {home_dir} +Upload and download paths refer to this machine's local filesystem. +When the user says "download" without specifying a local path, use the default +download directory if configured, otherwise ask. + +FILE TRANSFERS: +- upload_file: Upload a local file to the NAS (local path + NAS destination folder) +- download_file: Download a NAS file to a local directory (NAS path + local destination) +Neither overwrites existing files by default — use overwrite=true when intended. + CHOOSING THE RIGHT TOOL: - "How much space does X use?" → Navigate with list_files to find the folder, then get_dir_size on it - "What's in this folder?" → list_files - "Find all .mkv files" or "find files named X" → search_files - "Show me details about this file" → get_file_info +- "Upload this file to the NAS" → upload_file +- "Download this file from the NAS" → download_file BROWSING vs SEARCHING: - list_files shows one directory level. Its pattern parameter supports glob filtering (e.g., "*.mkv") diff --git a/src/synology_mcp/modules/__init__.py b/src/mcp_synology/modules/__init__.py similarity index 99% rename from src/synology_mcp/modules/__init__.py rename to src/mcp_synology/modules/__init__.py index 231e871..4c15dc1 100644 --- a/src/synology_mcp/modules/__init__.py +++ b/src/mcp_synology/modules/__init__.py @@ -12,7 +12,7 @@ from mcp.server.fastmcp import FastMCP from pydantic import BaseModel - from synology_mcp.server import SharedClientManager + from mcp_synology.server import SharedClientManager class PermissionTier(Enum): diff --git a/src/synology_mcp/modules/filestation/__init__.py b/src/mcp_synology/modules/filestation/__init__.py similarity index 75% rename from src/synology_mcp/modules/filestation/__init__.py rename to src/mcp_synology/modules/filestation/__init__.py index 29b479a..0fa9127 100644 --- a/src/synology_mcp/modules/filestation/__init__.py +++ b/src/mcp_synology/modules/filestation/__init__.py @@ -2,12 +2,16 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING, Literal +from mcp.server.fastmcp import ( + Context, # noqa: TC002 — runtime import needed for FastMCP context injection +) from mcp.types import ToolAnnotations from pydantic import BaseModel, Field -from synology_mcp.modules import ( +from mcp_synology.modules import ( ApiRequirement, ModuleInfo, PermissionTier, @@ -16,7 +20,7 @@ ) if TYPE_CHECKING: - from synology_mcp.modules import RegisterContext + from mcp_synology.modules import RegisterContext # Annotations for tools that need explicit overrides _ANNO_IDEMPOTENT = ToolAnnotations( @@ -37,6 +41,10 @@ class FileStationSettings(BaseModel): copy_move_timeout: int | None = Field(default=None, ge=10, le=3600) delete_timeout: int | None = Field(default=None, ge=10, le=3600) dir_size_timeout: int | None = Field(default=None, ge=10, le=3600) + upload_timeout: int | None = Field(default=None, ge=10, le=3600) + download_timeout: int | None = Field(default=None, ge=10, le=3600) + default_download_dir: str | None = None + default_upload_dir: str | None = None search_poll_interval: float = Field(default=1.0, ge=0.5, le=10.0) @@ -52,9 +60,11 @@ class FileStationSettings(BaseModel): ApiRequirement(api_name="SYNO.FileStation.Rename", min_version=1), ApiRequirement(api_name="SYNO.FileStation.CopyMove", min_version=1), ApiRequirement(api_name="SYNO.FileStation.Delete", min_version=1), + ApiRequirement(api_name="SYNO.FileStation.Upload", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.FileStation.Download", min_version=1, optional=True), ], tools=[ - # READ tools (6) + # READ tools (7) ToolInfo( name="list_shares", description=( @@ -107,7 +117,17 @@ class FileStationSettings(BaseModel): ), permission_tier=PermissionTier.READ, ), - # WRITE tools (6) + ToolInfo( + name="download_file", + description=( + "Download a NAS file to a local directory on this machine. " + "Provide the NAS file path. dest_folder is optional if " + "default_download_dir is configured. " + "Does not overwrite existing local files by default." + ), + permission_tier=PermissionTier.READ, + ), + # WRITE tools (7) ToolInfo( name="create_folder", description=( @@ -151,6 +171,16 @@ class FileStationSettings(BaseModel): permission_tier=PermissionTier.WRITE, annotations=_ANNO_DESTRUCTIVE, ), + ToolInfo( + name="upload_file", + description=( + "Upload a local file from this machine to a NAS folder. " + "Provide the local file path. dest_folder is optional if " + "default_upload_dir is configured. " + "Does not overwrite existing NAS files by default." + ), + permission_tier=PermissionTier.WRITE, + ), ToolInfo( name="restore_from_recycle_bin", description=( @@ -166,13 +196,13 @@ class FileStationSettings(BaseModel): def register(ctx: RegisterContext) -> None: """Register File Station tools with the MCP server.""" - from synology_mcp.modules.filestation.listing import ( + from mcp_synology.modules.filestation.listing import ( list_files, list_recycle_bin, list_shares, ) - from synology_mcp.modules.filestation.metadata import get_dir_size, get_file_info - from synology_mcp.modules.filestation.operations import ( + from mcp_synology.modules.filestation.metadata import get_dir_size, get_file_info + from mcp_synology.modules.filestation.operations import ( copy_files, create_folder, delete_files, @@ -180,7 +210,8 @@ def register(ctx: RegisterContext) -> None: rename, restore_from_recycle_bin, ) - from synology_mcp.modules.filestation.search import search_files + from mcp_synology.modules.filestation.search import search_files + from mcp_synology.modules.filestation.transfer import download_file, upload_file settings = FileStationSettings(**ctx.settings_dict) indicator = settings.file_type_indicator @@ -188,6 +219,15 @@ def register(ctx: RegisterContext) -> None: copy_move_timeout = float(settings.copy_move_timeout or settings.async_timeout) delete_timeout = float(settings.delete_timeout or settings.async_timeout) dir_size_timeout = float(settings.dir_size_timeout or settings.async_timeout) + upload_timeout = float(settings.upload_timeout or 300) + download_timeout = float(settings.download_timeout or 300) + # Expand ~ in local paths; NAS paths pass through unchanged + default_download_dir = ( + str(Path(settings.default_download_dir).expanduser()) + if settings.default_download_dir + else None + ) + default_upload_dir = settings.default_upload_dir # NAS path, no expansion search_poll_interval = settings.search_poll_interval hide_recycle = settings.hide_recycle_in_listings @@ -347,6 +387,45 @@ async def tool_get_dir_size(path: str) -> str: result = await get_dir_size(client, path=path, timeout=dir_size_timeout) return manager.with_update_notice(result) + if "download_file" in ctx.allowed_tools: + + @server.tool( + name="download_file", + description=_desc("download_file"), + annotations=_tool_annos["download_file"], + ) + async def tool_download_file( + ctx: Context, # type: ignore[type-arg] + path: str, + dest_folder: str | None = None, + filename: str | None = None, + overwrite: bool = False, + ) -> str: + effective_dest = dest_folder or default_download_dir + if not effective_dest: + return ( + "[!] No destination folder specified and no default_download_dir " + "configured.\n Provide dest_folder or set default_download_dir " + "in filestation module settings." + ) + + client = await manager.get_client() + + async def _progress(current: int, total: int | None) -> None: + await ctx.report_progress(float(current), float(total) if total else None) + + return manager.with_update_notice( + await download_file( + client, + path=path, + dest_folder=effective_dest, + filename=filename, + overwrite=overwrite, + timeout=download_timeout, + progress_callback=_progress, + ) + ) + # WRITE tools if "create_folder" in ctx.allowed_tools: @@ -442,6 +521,47 @@ async def tool_delete_files( ) ) + if "upload_file" in ctx.allowed_tools: + + @server.tool( + name="upload_file", + description=_desc("upload_file"), + annotations=_tool_annos["upload_file"], + ) + async def tool_upload_file( + ctx: Context, # type: ignore[type-arg] + local_path: str, + dest_folder: str | None = None, + filename: str | None = None, + overwrite: bool = False, + create_parents: bool = True, + ) -> str: + effective_dest = dest_folder or default_upload_dir + if not effective_dest: + return ( + "[!] No NAS destination folder specified and no default_upload_dir " + "configured.\n Provide dest_folder or set default_upload_dir " + "in filestation module settings." + ) + + client = await manager.get_client() + + async def _progress(current: int, total: int | None) -> None: + await ctx.report_progress(float(current), float(total) if total else None) + + return manager.with_update_notice( + await upload_file( + client, + local_path=local_path, + dest_folder=effective_dest, + filename=filename, + overwrite=overwrite, + create_parents=create_parents, + timeout=upload_timeout, + progress_callback=_progress, + ) + ) + if "restore_from_recycle_bin" in ctx.allowed_tools: @server.tool( diff --git a/src/synology_mcp/modules/filestation/helpers.py b/src/mcp_synology/modules/filestation/helpers.py similarity index 98% rename from src/synology_mcp/modules/filestation/helpers.py rename to src/mcp_synology/modules/filestation/helpers.py index 8853591..ab12081 100644 --- a/src/synology_mcp/modules/filestation/helpers.py +++ b/src/mcp_synology/modules/filestation/helpers.py @@ -6,7 +6,7 @@ import logging import re -from synology_mcp.core.client import DsmClient +from mcp_synology.core.client import DsmClient logger = logging.getLogger(__name__) diff --git a/src/synology_mcp/modules/filestation/listing.py b/src/mcp_synology/modules/filestation/listing.py similarity index 96% rename from src/synology_mcp/modules/filestation/listing.py rename to src/mcp_synology/modules/filestation/listing.py index 487fad4..0dd5f4b 100644 --- a/src/synology_mcp/modules/filestation/listing.py +++ b/src/mcp_synology/modules/filestation/listing.py @@ -4,20 +4,20 @@ from typing import TYPE_CHECKING, Any -from synology_mcp.core.errors import SynologyError -from synology_mcp.core.formatting import ( +from mcp_synology.core.errors import SynologyError +from mcp_synology.core.formatting import ( format_error, format_size, format_table, format_timestamp, ) -from synology_mcp.modules.filestation.helpers import ( +from mcp_synology.modules.filestation.helpers import ( file_type_icon, normalize_path, ) if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient # Map common sort field names to DSM API field names _SORT_ALIASES: dict[str, str] = { diff --git a/src/synology_mcp/modules/filestation/metadata.py b/src/mcp_synology/modules/filestation/metadata.py similarity index 96% rename from src/synology_mcp/modules/filestation/metadata.py rename to src/mcp_synology/modules/filestation/metadata.py index f911dc5..f501600 100644 --- a/src/synology_mcp/modules/filestation/metadata.py +++ b/src/mcp_synology/modules/filestation/metadata.py @@ -6,21 +6,21 @@ import logging from typing import TYPE_CHECKING, Any -from synology_mcp.core.errors import SynologyError -from synology_mcp.core.formatting import ( +from mcp_synology.core.errors import SynologyError +from mcp_synology.core.formatting import ( format_error, format_key_value, format_size, format_table, format_timestamp, ) -from synology_mcp.modules.filestation.helpers import ( +from mcp_synology.modules.filestation.helpers import ( escape_multi_path, normalize_path, ) if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient logger = logging.getLogger(__name__) diff --git a/src/synology_mcp/modules/filestation/operations.py b/src/mcp_synology/modules/filestation/operations.py similarity index 98% rename from src/synology_mcp/modules/filestation/operations.py rename to src/mcp_synology/modules/filestation/operations.py index c9dcd48..0a3c4fe 100644 --- a/src/synology_mcp/modules/filestation/operations.py +++ b/src/mcp_synology/modules/filestation/operations.py @@ -6,15 +6,15 @@ import logging from typing import TYPE_CHECKING, Any -from synology_mcp.core.errors import SynologyError -from synology_mcp.core.formatting import format_error, format_size, format_status -from synology_mcp.modules.filestation.helpers import ( +from mcp_synology.core.errors import SynologyError +from mcp_synology.core.formatting import format_error, format_size, format_status +from mcp_synology.modules.filestation.helpers import ( escape_multi_path, normalize_path, ) if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient logger = logging.getLogger(__name__) diff --git a/src/synology_mcp/modules/filestation/search.py b/src/mcp_synology/modules/filestation/search.py similarity index 97% rename from src/synology_mcp/modules/filestation/search.py rename to src/mcp_synology/modules/filestation/search.py index e1b6705..0bfcf5e 100644 --- a/src/synology_mcp/modules/filestation/search.py +++ b/src/mcp_synology/modules/filestation/search.py @@ -7,9 +7,9 @@ import time from typing import TYPE_CHECKING, Any -from synology_mcp.core.errors import SynologyError -from synology_mcp.core.formatting import format_error, format_size, format_table, format_timestamp -from synology_mcp.modules.filestation.helpers import ( +from mcp_synology.core.errors import SynologyError +from mcp_synology.core.formatting import format_error, format_size, format_table, format_timestamp +from mcp_synology.modules.filestation.helpers import ( file_type_icon, matches_pattern, normalize_path, @@ -17,7 +17,7 @@ ) if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient async def _cleanup_search_task( diff --git a/src/mcp_synology/modules/filestation/transfer.py b/src/mcp_synology/modules/filestation/transfer.py new file mode 100644 index 0000000..0b1d186 --- /dev/null +++ b/src/mcp_synology/modules/filestation/transfer.py @@ -0,0 +1,197 @@ +"""File Station transfer operations: upload_file, download_file.""" + +from __future__ import annotations + +import contextlib +import logging +import shutil +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +from mcp_synology.core.errors import SynologyError, SynologyFileExistsError +from mcp_synology.core.formatting import format_error, format_size, format_status +from mcp_synology.modules.filestation.helpers import normalize_path + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient, ProgressCallback + +logger = logging.getLogger(__name__) + +# Files larger than this get a timeout warning in the output. +_LARGE_FILE_THRESHOLD = 1024 * 1024 * 1024 # 1 GB + + +async def upload_file( + client: DsmClient, + *, + local_path: str, + dest_folder: str, + filename: str | None = None, + overwrite: bool = False, + create_parents: bool = True, + timeout: float = 300.0, + progress_callback: ProgressCallback | None = None, +) -> str: + """Upload a local file to a NAS folder.""" + local = Path(local_path) + if not local.is_file(): + return format_error( + "Upload", + f"Local file not found: {local_path}", + "Check the file path and try again.", + ) + + file_size = local.stat().st_size + dest = normalize_path(dest_folder) + effective_name = filename or local.name + + # Report initial progress + if progress_callback: + await progress_callback(0, file_size) + + try: + await client.upload( + dest, + local, + effective_name, + overwrite=overwrite, + create_parents=create_parents, + timeout=timeout, + ) + except SynologyFileExistsError: + return format_error( + "Upload", + f"File '{effective_name}' already exists in {dest}.", + "Use overwrite=true to replace the existing file.", + ) + except SynologyError as e: + return format_error("Upload", str(e), e.suggestion) + except OSError as e: + return format_error( + "Upload", + f"Failed to read local file '{local_path}': {e}", + "Check file permissions and that the file is not locked.", + ) + + # Report completion + if progress_callback: + await progress_callback(file_size, file_size) + + result = format_status(f"Uploaded {effective_name} ({format_size(file_size)}) to {dest}/") + + if file_size >= _LARGE_FILE_THRESHOLD: + result += ( + f"\n Note: Large file ({format_size(file_size)}). " + "If future uploads of this size time out, " + "increase upload_timeout in module settings." + ) + + return result + + +async def download_file( + client: DsmClient, + *, + path: str, + dest_folder: str, + filename: str | None = None, + overwrite: bool = False, + timeout: float = 300.0, + progress_callback: ProgressCallback | None = None, +) -> str: + """Download a NAS file to a local directory.""" + local_dir = Path(dest_folder) + if not local_dir.is_dir(): + return format_error( + "Download", + f"Local directory not found: {dest_folder}", + "Check the directory path and try again.", + ) + + nas_path = normalize_path(path) + effective_name = filename or PurePosixPath(nas_path).name + dest_file = local_dir / effective_name + + if dest_file.exists() and not overwrite: + return format_error( + "Download", + f"Local file already exists: {dest_file}", + "Use overwrite=true to replace the existing file.", + ) + + # Pre-flight disk space check using NAS file metadata. + # Best-effort: if the API call fails, skip the check and let the + # download proceed (client.download() also checks Content-Length). + nas_file_size: int | None = None + try: + info = await client.request( + "SYNO.FileStation.List", + "getinfo", + params={"path": nas_path, "additional": '["size"]'}, + ) + files = info.get("files", []) + if files: + nas_file_size = files[0].get("additional", {}).get("size", 0) + except Exception: # noqa: BLE001 + pass # Best-effort — download will catch disk space issues via Content-Length + + if nas_file_size: + free_space = shutil.disk_usage(local_dir).free + if nas_file_size > free_space: + return format_error( + "Download", + f"Insufficient local disk space: file is {format_size(nas_file_size)} " + f"but only {format_size(free_space)} free on {local_dir}.", + "Free space on the local disk or choose a different destination.", + ) + + try: + bytes_written = await client.download( + nas_path, + dest_file, + timeout=timeout, + progress_callback=progress_callback, + ) + except SynologyError as e: + # Clean up partial file on failure + if dest_file.exists(): + try: + dest_file.unlink() + logger.debug("Cleaned up partial download: %s", dest_file) + except OSError: + logger.warning("Failed to clean up partial download: %s", dest_file) + return format_error("Download", str(e), e.suggestion) + except OSError as e: + # Filesystem rejected the write — illegal characters in filename on this OS, + # permission denied, disk full, path too long, etc. + if dest_file.exists(): + with contextlib.suppress(OSError): + dest_file.unlink() + error_str = str(e) + if "disk space" in error_str.lower() or "space" in error_str.lower(): + suggestion = "Free space on the local disk or choose a different destination." + else: + suggestion = ( + "The filename may contain characters not allowed on this OS. " + "Use the filename parameter to specify a compatible name." + ) + return format_error("Download", f"Failed to write local file: {e}", suggestion) + except Exception: + # Clean up partial file on unexpected failure + if dest_file.exists(): + with contextlib.suppress(OSError): + dest_file.unlink() + raise + + result = format_status( + f"Downloaded {effective_name} ({format_size(bytes_written)}) to {dest_file}" + ) + + if bytes_written >= _LARGE_FILE_THRESHOLD: + result += ( + f"\n Note: Large file ({format_size(bytes_written)}). " + "If future downloads of this size time out, " + "increase download_timeout in module settings." + ) + + return result diff --git a/src/synology_mcp/modules/system/__init__.py b/src/mcp_synology/modules/system/__init__.py similarity index 92% rename from src/synology_mcp/modules/system/__init__.py rename to src/mcp_synology/modules/system/__init__.py index 6c6672c..3a8ec92 100644 --- a/src/synology_mcp/modules/system/__init__.py +++ b/src/mcp_synology/modules/system/__init__.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from synology_mcp.modules import ( +from mcp_synology.modules import ( ApiRequirement, ModuleInfo, PermissionTier, @@ -13,7 +13,7 @@ ) if TYPE_CHECKING: - from synology_mcp.modules import RegisterContext + from mcp_synology.modules import RegisterContext MODULE_INFO = ModuleInfo( name="system", @@ -49,8 +49,8 @@ def register(ctx: RegisterContext) -> None: """Register system monitoring tools with the MCP server.""" - from synology_mcp.modules.system.info import get_system_info - from synology_mcp.modules.system.utilization import get_resource_usage + from mcp_synology.modules.system.info import get_system_info + from mcp_synology.modules.system.utilization import get_resource_usage server = ctx.server manager = ctx.manager diff --git a/src/synology_mcp/modules/system/info.py b/src/mcp_synology/modules/system/info.py similarity index 95% rename from src/synology_mcp/modules/system/info.py rename to src/mcp_synology/modules/system/info.py index cc7f728..86bf722 100644 --- a/src/synology_mcp/modules/system/info.py +++ b/src/mcp_synology/modules/system/info.py @@ -5,11 +5,11 @@ import logging from typing import TYPE_CHECKING, Any -from synology_mcp.core.errors import SynologyError -from synology_mcp.core.formatting import format_error, format_key_value +from mcp_synology.core.errors import SynologyError +from mcp_synology.core.formatting import format_error, format_key_value if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient logger = logging.getLogger(__name__) diff --git a/src/synology_mcp/modules/system/utilization.py b/src/mcp_synology/modules/system/utilization.py similarity index 96% rename from src/synology_mcp/modules/system/utilization.py rename to src/mcp_synology/modules/system/utilization.py index cbff684..9fb78fe 100644 --- a/src/synology_mcp/modules/system/utilization.py +++ b/src/mcp_synology/modules/system/utilization.py @@ -5,11 +5,11 @@ import logging from typing import TYPE_CHECKING, Any -from synology_mcp.core.errors import SynologyError -from synology_mcp.core.formatting import format_error, format_key_value, format_size +from mcp_synology.core.errors import SynologyError +from mcp_synology.core.formatting import format_error, format_key_value, format_size if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient logger = logging.getLogger(__name__) diff --git a/src/synology_mcp/py.typed b/src/mcp_synology/py.typed similarity index 100% rename from src/synology_mcp/py.typed rename to src/mcp_synology/py.typed diff --git a/src/synology_mcp/server.py b/src/mcp_synology/server.py similarity index 82% rename from src/synology_mcp/server.py rename to src/mcp_synology/server.py index cf0fcdd..4c43cf3 100644 --- a/src/synology_mcp/server.py +++ b/src/mcp_synology/server.py @@ -5,23 +5,25 @@ import asyncio import atexit import logging +import platform import signal from pathlib import Path from typing import TYPE_CHECKING from mcp.server.fastmcp import FastMCP +from mcp.types import Icon -from synology_mcp.core.auth import AuthManager -from synology_mcp.core.client import DsmClient -from synology_mcp.core.state import ServerState -from synology_mcp.modules import PermissionTier, RegisterContext, filter_tools_by_permission -from synology_mcp.modules import filestation as _filestation_mod -from synology_mcp.modules import system as _system_mod +from mcp_synology.core.auth import AuthManager +from mcp_synology.core.client import DsmClient +from mcp_synology.core.state import ServerState +from mcp_synology.modules import PermissionTier, RegisterContext, filter_tools_by_permission +from mcp_synology.modules import filestation as _filestation_mod +from mcp_synology.modules import system as _system_mod if TYPE_CHECKING: from types import ModuleType - from synology_mcp.core.config import AppConfig + from mcp_synology.core.config import AppConfig logger = logging.getLogger(__name__) @@ -34,6 +36,32 @@ def _load_instruction(name: str) -> str: _BASE_INSTRUCTIONS = _load_instruction("server.md") +_ICON_BASE_URL = "https://raw.githubusercontent.com/cmeans/mcp-synology/main/src/mcp_synology/icons" + + +def _load_icons() -> list[Icon]: + """Return Icon objects pointing to hosted SVGs on GitHub.""" + icons: list[Icon] = [] + theme_map = {"light": "mcp-synology-logo-light.svg", "dark": "mcp-synology-logo-dark.svg"} + for theme, filename in theme_map.items(): + icons.append( + Icon( # type: ignore[call-arg] + src=f"{_ICON_BASE_URL}/{filename}", + mimeType="image/svg+xml", + theme=theme, + ) + ) + return icons + + +def _platform_label() -> str: + """Return a short platform label like 'Linux', 'macOS', or 'Windows'.""" + system = platform.system() + if system == "Darwin": + return "macOS" + return system # "Linux", "Windows", etc. + + # Known module registry — each entry exposes MODULE_INFO and register() _MODULE_REGISTRY: dict[str, ModuleType] = { "filestation": _filestation_mod, @@ -130,7 +158,7 @@ async def _logout() -> None: async def _bg_update_check(self) -> None: """Background update check — appends notice on first tool result.""" try: - from synology_mcp.cli import ( + from mcp_synology.cli import ( _check_for_update, _load_global_state, _save_global_state, @@ -142,12 +170,12 @@ async def _bg_update_check(self) -> None: latest = await loop.run_in_executor(None, _check_for_update, gstate) _save_global_state(gstate) if latest: - from synology_mcp import __version__ + from mcp_synology import __version__ self._update_notice = ( - f"\n\n---\nUpdate available: synology-mcp {latest} " + f"\n\n---\nUpdate available: mcp-synology {latest} " f"(current: {__version__}). " - f"Run: synology-mcp --check-update" + f"Run: mcp-synology --check-update" ) except (OSError, ValueError, KeyError): pass # Never let update check break tool functionality @@ -167,6 +195,8 @@ def create_server(config: AppConfig) -> FastMCP: "instance_id": config.instance_id, "host": conn.host if conn else "unknown", "port": str(conn.port) if conn else "5000", + "home_dir": str(Path.home()), + "platform": _platform_label(), } server_name = f"synology-{config.display_name}" @@ -190,6 +220,7 @@ def create_server(config: AppConfig) -> FastMCP: server = FastMCP( server_name, instructions=instructions, + icons=_load_icons(), ) logger.debug("Creating MCP server") diff --git a/src/synology_mcp/__init__.py b/src/synology_mcp/__init__.py deleted file mode 100644 index 7e970e1..0000000 --- a/src/synology_mcp/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""synology-mcp — MCP server for Synology NAS.""" - -from importlib.metadata import version - -__version__ = version("synology-mcp") diff --git a/src/synology_mcp/__main__.py b/src/synology_mcp/__main__.py deleted file mode 100644 index 650bea8..0000000 --- a/src/synology_mcp/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Entry point for `python -m synology_mcp`.""" - -from synology_mcp.cli import main - -main() diff --git a/tests/conftest.py b/tests/conftest.py index bea3b98..b4651da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,9 +9,9 @@ import pytest -from synology_mcp.core.client import DsmClient -from synology_mcp.core.config import AppConfig -from synology_mcp.core.state import ApiInfoEntry +from mcp_synology.core.client import DsmClient +from mcp_synology.core.config import AppConfig +from mcp_synology.core.state import ApiInfoEntry BASE_URL = "http://nas:5000" @@ -52,6 +52,8 @@ def make_api_cache() -> dict[str, ApiInfoEntry]: "SYNO.FileStation.Rename": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=2), "SYNO.FileStation.CopyMove": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=3), "SYNO.FileStation.Delete": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=2), + "SYNO.FileStation.Upload": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=2), + "SYNO.FileStation.Download": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=2), } diff --git a/tests/core/test_auth.py b/tests/core/test_auth.py index 270b882..c559c2a 100644 --- a/tests/core/test_auth.py +++ b/tests/core/test_auth.py @@ -10,11 +10,11 @@ import pytest import respx -from synology_mcp.core.auth import AuthManager -from synology_mcp.core.client import DsmClient -from synology_mcp.core.config import AppConfig -from synology_mcp.core.errors import AuthenticationError -from synology_mcp.core.state import ApiInfoEntry +from mcp_synology.core.auth import AuthManager +from mcp_synology.core.client import DsmClient +from mcp_synology.core.config import AppConfig +from mcp_synology.core.errors import AuthenticationError +from mcp_synology.core.state import ApiInfoEntry BASE_URL = "http://nas:5000" @@ -73,7 +73,7 @@ def test_credentials_from_config(self) -> None: with ( patch.dict(os.environ, _clean_env(), clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), ): username, password, device_id = auth._resolve_credentials() @@ -94,7 +94,7 @@ def test_credentials_from_env(self) -> None: } with ( patch.dict(os.environ, env, clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), ): username, password, device_id = auth._resolve_credentials() @@ -110,7 +110,7 @@ def test_credentials_from_keyring(self) -> None: with ( patch.dict(os.environ, _clean_env(), clear=True), patch( - "synology_mcp.core.auth.kr", + "mcp_synology.core.auth.kr", _keyring_with("kr_user", "kr_pass", "kr_device"), ), ): @@ -127,7 +127,7 @@ def test_no_credentials_raises(self) -> None: with ( patch.dict(os.environ, _clean_env(), clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), pytest.raises(AuthenticationError, match="No credentials"), ): auth._resolve_credentials() @@ -145,7 +145,7 @@ async def test_simple_login(self) -> None: auth = AuthManager(config, client) with ( patch.dict(os.environ, _clean_env(), clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), ): sid = await auth.login() @@ -164,7 +164,7 @@ async def test_login_with_device_id(self) -> None: auth = AuthManager(config, client) with ( patch.dict(os.environ, _clean_env(), clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), ): sid = await auth.login() @@ -183,7 +183,7 @@ async def test_2fa_required_without_device_id(self) -> None: auth = AuthManager(config, client) with ( patch.dict(os.environ, _clean_env(), clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), pytest.raises(AuthenticationError, match="2FA"), ): await auth.login() @@ -213,7 +213,7 @@ def side_effect(request: httpx.Request) -> httpx.Response: with ( patch.dict(os.environ, _clean_env(), clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), ): data = await client.request("SYNO.FileStation.List", "list_share", version=2) assert "shares" in data @@ -237,7 +237,7 @@ async def test_get_session_logs_in_when_no_sid(self) -> None: auth = AuthManager(config, client) with ( patch.dict(os.environ, _clean_env(), clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), ): sid = await auth.get_session() assert sid == "fresh-sid" @@ -260,7 +260,7 @@ def test_env_overrides_keyring(self) -> None: with ( patch.dict(os.environ, env, clear=True), patch( - "synology_mcp.core.auth.kr", + "mcp_synology.core.auth.kr", _keyring_with("kr_user", "kr_pass"), ), ): @@ -278,7 +278,7 @@ def test_config_overrides_keyring(self) -> None: with ( patch.dict(os.environ, _clean_env(), clear=True), patch( - "synology_mcp.core.auth.kr", + "mcp_synology.core.auth.kr", _keyring_with("kr_user", "kr_pass"), ), ): @@ -300,7 +300,7 @@ def test_env_overrides_config(self) -> None: } with ( patch.dict(os.environ, env, clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), ): username, password, device_id = auth._resolve_credentials() @@ -320,7 +320,7 @@ def test_partial_env_falls_through(self) -> None: with ( patch.dict(os.environ, env, clear=True), patch( - "synology_mcp.core.auth.kr", + "mcp_synology.core.auth.kr", _keyring_with("kr_user", "kr_pass"), ), ): @@ -339,7 +339,7 @@ def test_device_id_from_keyring_when_not_in_env(self) -> None: with ( patch.dict(os.environ, env, clear=True), patch( - "synology_mcp.core.auth.kr", + "mcp_synology.core.auth.kr", _keyring_with(device_id="kr_device"), ), ): @@ -363,7 +363,7 @@ def test_dbus_set_when_missing_and_socket_exists(self) -> None: with ( patch.dict(os.environ, clean, clear=True), - patch("synology_mcp.core.auth.kr", _keyring_with("user", "pass")), + patch("mcp_synology.core.auth.kr", _keyring_with("user", "pass")), patch("sys.platform", "linux"), patch("pathlib.Path.exists", return_value=True), patch("os.getuid", return_value=1000), @@ -384,7 +384,7 @@ def test_dbus_not_set_on_macos(self) -> None: with ( patch.dict(os.environ, clean, clear=True), - patch("synology_mcp.core.auth.kr", _no_keyring()), + patch("mcp_synology.core.auth.kr", _no_keyring()), patch("sys.platform", "darwin"), ): username, password, _ = auth._resolve_credentials() @@ -397,6 +397,6 @@ def test_session_name_format(self) -> None: config = _make_config(instance_id="test-nas") client = _make_client() auth = AuthManager(config, client) - assert auth._session_name.startswith("SynologyMCP_test-nas_") + assert auth._session_name.startswith("MCPSynology_test-nas_") uuid_part = auth._session_name.split("_")[-1] assert len(uuid_part) == 8 diff --git a/tests/core/test_cli.py b/tests/core/test_cli.py index 08c788a..823420f 100644 --- a/tests/core/test_cli.py +++ b/tests/core/test_cli.py @@ -11,8 +11,8 @@ from click.testing import CliRunner -from synology_mcp import __version__ -from synology_mcp.cli import main +from mcp_synology import __version__ +from mcp_synology.cli import main class TestCli: @@ -32,13 +32,13 @@ def test_help(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--help"]) assert result.exit_code == 0 - assert "synology-mcp" in result.output + assert "mcp-synology" in result.output def test_help_short_flag(self) -> None: runner = CliRunner() result = runner.invoke(main, ["-h"]) assert result.exit_code == 0 - assert "synology-mcp" in result.output + assert "mcp-synology" in result.output def test_serve_help(self) -> None: runner = CliRunner() @@ -85,7 +85,7 @@ def test_short_config_flag_check(self) -> None: class TestSetupList: def test_list_no_configs(self, tmp_path: Path) -> None: runner = CliRunner() - with patch("synology_mcp.cli.setup._CONFIG_DIR", tmp_path / "nonexistent"): + with patch("mcp_synology.cli.setup._CONFIG_DIR", tmp_path / "nonexistent"): result = runner.invoke(main, ["setup", "--list"]) assert result.exit_code == 0 assert "No configurations found" in result.output @@ -103,7 +103,7 @@ def test_list_with_configs(self, tmp_path: Path) -> None: " enabled: true\n" ) runner = CliRunner() - with patch("synology_mcp.cli.setup._CONFIG_DIR", tmp_path): + with patch("mcp_synology.cli.setup._CONFIG_DIR", tmp_path): result = runner.invoke(main, ["setup", "--list"]) assert result.exit_code == 0 assert "my-nas.yaml" in result.output @@ -112,7 +112,7 @@ def test_list_with_configs(self, tmp_path: Path) -> None: def test_list_short_flag(self, tmp_path: Path) -> None: runner = CliRunner() - with patch("synology_mcp.cli.setup._CONFIG_DIR", tmp_path / "nonexistent"): + with patch("mcp_synology.cli.setup._CONFIG_DIR", tmp_path / "nonexistent"): result = runner.invoke(main, ["setup", "-l"]) assert result.exit_code == 0 assert "No configurations found" in result.output @@ -120,7 +120,7 @@ def test_list_short_flag(self, tmp_path: Path) -> None: def test_list_empty_directory(self, tmp_path: Path) -> None: """Config dir exists but has no .yaml files.""" runner = CliRunner() - with patch("synology_mcp.cli.setup._CONFIG_DIR", tmp_path): + with patch("mcp_synology.cli.setup._CONFIG_DIR", tmp_path): result = runner.invoke(main, ["setup", "--list"]) assert result.exit_code == 0 assert "No configurations found" in result.output @@ -130,7 +130,7 @@ def test_list_with_unparseable_config(self, tmp_path: Path) -> None: bad_file = tmp_path / "broken.yaml" bad_file.write_text("{{{{invalid yaml") runner = CliRunner() - with patch("synology_mcp.cli.setup._CONFIG_DIR", tmp_path): + with patch("mcp_synology.cli.setup._CONFIG_DIR", tmp_path): result = runner.invoke(main, ["setup", "--list"]) assert result.exit_code == 0 assert "broken.yaml" in result.output @@ -144,7 +144,7 @@ def test_list_multiple_configs(self, tmp_path: Path) -> None: "modules:\n filestation:\n enabled: true\n" ) runner = CliRunner() - with patch("synology_mcp.cli.setup._CONFIG_DIR", tmp_path): + with patch("mcp_synology.cli.setup._CONFIG_DIR", tmp_path): result = runner.invoke(main, ["setup", "--list"]) assert "nas-a.yaml" in result.output assert "nas-b.yaml" in result.output @@ -167,10 +167,10 @@ def test_interactive_setup_creates_config(self, tmp_path: Path) -> None: # Input order: host, https(n), permission(read), alias(""), # username, password, hostname-confirm(y) with ( - patch("synology_mcp.cli.setup._CONFIG_DIR", config_dir), - patch("synology_mcp.core.config.discover_config_path", side_effect=FileNotFoundError), - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=connect_result), + patch("mcp_synology.cli.setup._CONFIG_DIR", config_dir), + patch("mcp_synology.core.config.discover_config_path", side_effect=FileNotFoundError), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=connect_result), patch.dict(os.environ, clean_env, clear=True), ): result = runner.invoke( @@ -197,10 +197,10 @@ def test_interactive_setup_aborts_on_overwrite_decline(self, tmp_path: Path) -> connect_result: dict[str, Any] = {"success": True} with ( - patch("synology_mcp.cli.setup._CONFIG_DIR", config_dir), - patch("synology_mcp.core.config.discover_config_path", side_effect=FileNotFoundError), - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=connect_result), + patch("mcp_synology.cli.setup._CONFIG_DIR", config_dir), + patch("mcp_synology.core.config.discover_config_path", side_effect=FileNotFoundError), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=connect_result), patch.dict(os.environ, clean_env, clear=True), ): result = runner.invoke( @@ -225,10 +225,10 @@ def test_interactive_setup_with_https(self, tmp_path: Path) -> None: # Prompts: host, https(y), verify_ssl(n), permission(write), alias(""), # username, password with ( - patch("synology_mcp.cli.setup._CONFIG_DIR", config_dir), - patch("synology_mcp.core.config.discover_config_path", side_effect=FileNotFoundError), - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=connect_result), + patch("mcp_synology.cli.setup._CONFIG_DIR", config_dir), + patch("mcp_synology.core.config.discover_config_path", side_effect=FileNotFoundError), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=connect_result), patch.dict(os.environ, clean_env, clear=True), ): result = runner.invoke( @@ -258,9 +258,9 @@ def test_interactive_setup_keyring_failure(self, tmp_path: Path) -> None: } with ( - patch("synology_mcp.cli.setup._CONFIG_DIR", config_dir), - patch("synology_mcp.core.config.discover_config_path", side_effect=FileNotFoundError), - patch("synology_mcp.cli.setup._store_keyring", return_value=False), + patch("mcp_synology.cli.setup._CONFIG_DIR", config_dir), + patch("mcp_synology.core.config.discover_config_path", side_effect=FileNotFoundError), + patch("mcp_synology.cli.setup._store_keyring", return_value=False), patch.dict(os.environ, clean_env, clear=True), ): result = runner.invoke( @@ -285,10 +285,10 @@ def test_interactive_setup_login_failure(self, tmp_path: Path) -> None: connect_result: dict[str, Any] = {"success": False} with ( - patch("synology_mcp.cli.setup._CONFIG_DIR", config_dir), - patch("synology_mcp.core.config.discover_config_path", side_effect=FileNotFoundError), - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=connect_result), + patch("mcp_synology.cli.setup._CONFIG_DIR", config_dir), + patch("mcp_synology.core.config.discover_config_path", side_effect=FileNotFoundError), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=connect_result), patch.dict(os.environ, clean_env, clear=True), ): result = runner.invoke( @@ -316,8 +316,8 @@ def test_setup_with_existing_config(self, tmp_path: Path) -> None: ) runner = CliRunner() with ( - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=None), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=None), ): result = runner.invoke( main, @@ -343,8 +343,8 @@ def test_setup_with_config_shows_display_name(self, tmp_path: Path) -> None: ) runner = CliRunner() with ( - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=None), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=None), ): result = runner.invoke( main, @@ -357,25 +357,25 @@ def test_setup_with_config_shows_display_name(self, tmp_path: Path) -> None: class TestStoreKeyring: def test_store_keyring_success(self) -> None: - from synology_mcp.cli.setup import _store_keyring + from mcp_synology.cli.setup import _store_keyring mock_kr = MagicMock() # keyring is imported inside the function, so mock at the import target with patch.dict("sys.modules", {"keyring": mock_kr}): - result = _store_keyring("synology-mcp/test", "admin", "secret") + result = _store_keyring("mcp-synology/test", "admin", "secret") assert result is True assert mock_kr.set_password.call_count == 2 def test_store_keyring_failure(self) -> None: - from synology_mcp.cli.setup import _store_keyring + from mcp_synology.cli.setup import _store_keyring mock_kr = MagicMock() mock_kr.set_password.side_effect = OSError("No backend") mock_errors = MagicMock() mock_errors.KeyringError = type("KeyringError", (Exception,), {}) with patch.dict("sys.modules", {"keyring": mock_kr, "keyring.errors": mock_errors}): - result = _store_keyring("synology-mcp/test", "admin", "secret") + result = _store_keyring("mcp-synology/test", "admin", "secret") assert result is False @@ -394,10 +394,10 @@ def test_snippet_includes_dbus_on_linux(self, tmp_path: Path) -> None: connect_result: dict[str, Any] = {"success": True} with ( - patch("synology_mcp.cli.setup._CONFIG_DIR", config_dir), - patch("synology_mcp.core.config.discover_config_path", side_effect=FileNotFoundError), - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=connect_result), + patch("mcp_synology.cli.setup._CONFIG_DIR", config_dir), + patch("mcp_synology.core.config.discover_config_path", side_effect=FileNotFoundError), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=connect_result), patch.dict(os.environ, clean_env, clear=True), patch("sys.platform", "linux"), ): @@ -422,10 +422,10 @@ def test_snippet_no_dbus_on_macos(self, tmp_path: Path) -> None: connect_result: dict[str, Any] = {"success": True} with ( - patch("synology_mcp.cli.setup._CONFIG_DIR", config_dir), - patch("synology_mcp.core.config.discover_config_path", side_effect=FileNotFoundError), - patch("synology_mcp.cli.setup._store_keyring", return_value=True), - patch("synology_mcp.cli.setup.asyncio.run", return_value=connect_result), + patch("mcp_synology.cli.setup._CONFIG_DIR", config_dir), + patch("mcp_synology.core.config.discover_config_path", side_effect=FileNotFoundError), + patch("mcp_synology.cli.setup._store_keyring", return_value=True), + patch("mcp_synology.cli.setup.asyncio.run", return_value=connect_result), patch.dict(os.environ, clean_env, clear=True), patch("sys.platform", "darwin"), ): @@ -453,7 +453,7 @@ def test_check_with_valid_config(self, tmp_path: Path) -> None: " enabled: true\n" ) runner = CliRunner() - with patch("synology_mcp.cli.check.asyncio.run", return_value=None): + with patch("mcp_synology.cli.check.asyncio.run", return_value=None): result = runner.invoke(main, ["check", "-c", str(config_file)]) assert "Checking credentials" in result.output @@ -472,7 +472,7 @@ def test_check_uses_display_name(self, tmp_path: Path) -> None: " enabled: true\n" ) runner = CliRunner() - with patch("synology_mcp.cli.check.asyncio.run", return_value=None): + with patch("mcp_synology.cli.check.asyncio.run", return_value=None): result = runner.invoke(main, ["check", "-c", str(config_file)]) assert "My Server" in result.output @@ -481,7 +481,7 @@ def test_check_uses_display_name(self, tmp_path: Path) -> None: class TestEnvVarMode: def test_serve_env_var_mode(self) -> None: """When SYNOLOGY_HOST is set and no config file, synthesize config.""" - from synology_mcp.core.config import _synthesize_env_config + from mcp_synology.core.config import _synthesize_env_config env = {"SYNOLOGY_HOST": "10.0.0.5"} with patch.dict(os.environ, env, clear=False): @@ -494,7 +494,7 @@ def test_serve_env_var_mode(self) -> None: assert config.modules["filestation"].permission == "read" def test_no_env_var_returns_none(self) -> None: - from synology_mcp.core.config import _synthesize_env_config + from mcp_synology.core.config import _synthesize_env_config clean_env: dict[str, str] = { k: v for k, v in os.environ.items() if not k.startswith("SYNOLOGY_") @@ -506,7 +506,7 @@ def test_no_env_var_returns_none(self) -> None: def test_load_config_falls_back_to_env(self, tmp_path: Path) -> None: """load_config falls back to env-var mode when no config file exists.""" - from synology_mcp.core.config import load_config + from mcp_synology.core.config import load_config env: dict[str, str] = { "SYNOLOGY_HOST": "10.0.0.99", @@ -525,14 +525,14 @@ def test_load_config_explicit_path_no_fallback(self) -> None: """Explicit --config path should not fall back to env-var mode.""" import pytest - from synology_mcp.core.config import load_config + from mcp_synology.core.config import load_config with pytest.raises(FileNotFoundError): load_config("/nonexistent/config.yaml") def test_env_var_mode_with_port_override(self) -> None: """Env vars can override port and https in synthesized config.""" - from synology_mcp.core.config import _synthesize_env_config + from mcp_synology.core.config import _synthesize_env_config env: dict[str, str] = { "SYNOLOGY_HOST": "nas.local", @@ -559,7 +559,7 @@ def test_alias_in_config(self) -> None: "connection": {"host": "192.168.1.100"}, "modules": {"filestation": {"enabled": True}}, } - from synology_mcp.core.config import AppConfig + from mcp_synology.core.config import AppConfig config = AppConfig(**raw) assert config.alias == "HomeNAS" @@ -571,7 +571,7 @@ def test_display_name_falls_back_to_instance_id(self) -> None: "connection": {"host": "192.168.1.100"}, "modules": {"filestation": {"enabled": True}}, } - from synology_mcp.core.config import AppConfig + from mcp_synology.core.config import AppConfig config = AppConfig(**raw) assert config.alias is None @@ -585,7 +585,7 @@ def test_display_name_with_alias_and_instance_id(self) -> None: "connection": {"host": "10.0.0.1"}, "modules": {"filestation": {"enabled": True}}, } - from synology_mcp.core.config import AppConfig + from mcp_synology.core.config import AppConfig config = AppConfig(**raw) assert config.display_name == "Office NAS" @@ -594,7 +594,7 @@ def test_display_name_with_alias_and_instance_id(self) -> None: class TestFetchDsmInfo: async def test_fetch_dsm_info_not_in_cache(self) -> None: """When SYNO.DSM.Info is not in the API cache, return empty dict.""" - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient async with DsmClient(base_url="http://nas:5000") as client: result = await client.fetch_dsm_info() @@ -604,8 +604,8 @@ async def test_fetch_dsm_info_in_cache(self) -> None: """When SYNO.DSM.Info is available, call getinfo and return data.""" import respx - from synology_mcp.core.client import DsmClient - from synology_mcp.core.state import ApiInfoEntry + from mcp_synology.core.client import DsmClient + from mcp_synology.core.state import ApiInfoEntry with respx.mock: respx.get("http://nas:5000/webapi/entry.cgi").respond( diff --git a/tests/core/test_client.py b/tests/core/test_client.py index b08d847..aeca1be 100644 --- a/tests/core/test_client.py +++ b/tests/core/test_client.py @@ -6,14 +6,14 @@ import pytest import respx -from synology_mcp.core.client import DsmClient -from synology_mcp.core.errors import ( +from mcp_synology.core.client import DsmClient +from mcp_synology.core.errors import ( ApiNotFoundError, PathNotFoundError, SessionExpiredError, SynologyError, ) -from synology_mcp.core.state import ApiInfoEntry +from mcp_synology.core.state import ApiInfoEntry BASE_URL = "http://nas:5000" diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 9def788..6d8396b 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -8,7 +8,7 @@ import pytest -from synology_mcp.core.config import ( +from mcp_synology.core.config import ( AppConfig, ConnectionConfig, LoggingConfig, @@ -191,7 +191,7 @@ def test_explicit_path_not_found(self) -> None: def test_env_var_path(self, tmp_path: Path) -> None: config_file = tmp_path / "config.yaml" config_file.write_text("schema_version: 1\n") - with patch.dict(os.environ, {"SYNOLOGY_MCP_CONFIG": str(config_file)}): + with patch.dict(os.environ, {"MCP_SYNOLOGY_CONFIG": str(config_file)}): result = discover_config_path() assert result == config_file diff --git a/tests/core/test_errors.py b/tests/core/test_errors.py index 2583845..9e24b84 100644 --- a/tests/core/test_errors.py +++ b/tests/core/test_errors.py @@ -1,6 +1,6 @@ """Tests for core/errors.py — exception hierarchy and error_from_code factory.""" -from synology_mcp.core.errors import ( +from mcp_synology.core.errors import ( ApiNotFoundError, AuthenticationError, DiskFullError, diff --git a/tests/core/test_formatting.py b/tests/core/test_formatting.py index b457e4e..3b29b06 100644 --- a/tests/core/test_formatting.py +++ b/tests/core/test_formatting.py @@ -1,6 +1,6 @@ """Tests for core/formatting.py — all shared formatters.""" -from synology_mcp.core.formatting import ( +from mcp_synology.core.formatting import ( TreeNode, format_error, format_key_value, diff --git a/tests/core/test_server.py b/tests/core/test_server.py index a935998..34da0d3 100644 --- a/tests/core/test_server.py +++ b/tests/core/test_server.py @@ -2,7 +2,7 @@ from __future__ import annotations -from synology_mcp.server import _BASE_INSTRUCTIONS, create_server +from mcp_synology.server import _BASE_INSTRUCTIONS, create_server from tests.conftest import make_test_config @@ -81,7 +81,7 @@ def test_instructions_mention_list_shares_first(self) -> None: class TestFileStationSettings: def test_default_settings(self) -> None: - from synology_mcp.modules.filestation import FileStationSettings + from mcp_synology.modules.filestation import FileStationSettings s = FileStationSettings() assert s.hide_recycle_in_listings is False @@ -94,7 +94,7 @@ def test_default_settings(self) -> None: assert s.search_poll_interval == 1.0 def test_specific_timeouts_override(self) -> None: - from synology_mcp.modules.filestation import FileStationSettings + from mcp_synology.modules.filestation import FileStationSettings s = FileStationSettings( async_timeout=60, @@ -109,7 +109,7 @@ def test_specific_timeouts_override(self) -> None: def test_search_poll_interval_bounds(self) -> None: import pytest - from synology_mcp.modules.filestation import FileStationSettings + from mcp_synology.modules.filestation import FileStationSettings with pytest.raises(ValueError): FileStationSettings(search_poll_interval=0.1) # below minimum 0.5 diff --git a/tests/core/test_state.py b/tests/core/test_state.py index 1ecf70c..c6507f6 100644 --- a/tests/core/test_state.py +++ b/tests/core/test_state.py @@ -4,7 +4,7 @@ from unittest.mock import patch -from synology_mcp.core.state import ApiInfoEntry, ServerState, load_state, save_state +from mcp_synology.core.state import ApiInfoEntry, ServerState, load_state, save_state class TestServerState: @@ -32,7 +32,7 @@ def test_save_and_load_roundtrip(self, tmp_path: object) -> None: ) # Use tmp_path for home directory to avoid polluting real filesystem - with patch("synology_mcp.core.state.Path.home", return_value=tmp_path): + with patch("mcp_synology.core.state.Path.home", return_value=tmp_path): save_state(instance_id, state) loaded = load_state(instance_id) diff --git a/tests/integration_config.yaml.example b/tests/integration_config.yaml.example index e76a82e..809deea 100644 --- a/tests/integration_config.yaml.example +++ b/tests/integration_config.yaml.example @@ -4,9 +4,9 @@ # Credentials are resolved via the standard chain: # 1. Environment variables: SYNOLOGY_USERNAME, SYNOLOGY_PASSWORD, SYNOLOGY_DEVICE_ID # 2. Config file auth section (below) -# 3. OS keyring (set by 'synology-mcp setup') +# 3. OS keyring (set by 'mcp-synology setup') # -# For 2FA NAS, run 'synology-mcp setup' first to store the device token in keyring. +# For 2FA NAS, run 'mcp-synology setup' first to store the device token in keyring. schema_version: 1 @@ -36,5 +36,5 @@ modules: # writable_folder: /home/Test # A folder where tests can create/copy/delete files # Optional: path to a config using an admin account (for resource utilization tests). -# The admin account must be set up via 'synology-mcp setup --config '. -# admin_config: ~/.config/synology-mcp/admin.yaml +# The admin account must be set up via 'mcp-synology setup --config '. +# admin_config: ~/.config/mcp-synology/admin.yaml diff --git a/tests/modules/filestation/test_helpers.py b/tests/modules/filestation/test_helpers.py index bbb4595..3d38753 100644 --- a/tests/modules/filestation/test_helpers.py +++ b/tests/modules/filestation/test_helpers.py @@ -4,7 +4,7 @@ import pytest -from synology_mcp.modules.filestation.helpers import ( +from mcp_synology.modules.filestation.helpers import ( escape_multi_path, file_type_icon, matches_pattern, diff --git a/tests/modules/filestation/test_listing.py b/tests/modules/filestation/test_listing.py index ce462e8..f0e1398 100644 --- a/tests/modules/filestation/test_listing.py +++ b/tests/modules/filestation/test_listing.py @@ -6,7 +6,7 @@ import respx -from synology_mcp.modules.filestation.listing import ( +from mcp_synology.modules.filestation.listing import ( list_files, list_recycle_bin, list_shares, @@ -14,7 +14,7 @@ from tests.conftest import BASE_URL if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient class TestListShares: diff --git a/tests/modules/filestation/test_metadata.py b/tests/modules/filestation/test_metadata.py index 43f4b4d..58c2516 100644 --- a/tests/modules/filestation/test_metadata.py +++ b/tests/modules/filestation/test_metadata.py @@ -7,11 +7,11 @@ import httpx import respx -from synology_mcp.modules.filestation.metadata import get_dir_size, get_file_info +from mcp_synology.modules.filestation.metadata import get_dir_size, get_file_info from tests.conftest import BASE_URL if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient class TestGetFileInfo: diff --git a/tests/modules/filestation/test_operations.py b/tests/modules/filestation/test_operations.py index 301ba06..9645b05 100644 --- a/tests/modules/filestation/test_operations.py +++ b/tests/modules/filestation/test_operations.py @@ -7,7 +7,7 @@ import httpx import respx -from synology_mcp.modules.filestation.operations import ( +from mcp_synology.modules.filestation.operations import ( copy_files, create_folder, delete_files, @@ -18,7 +18,7 @@ from tests.conftest import BASE_URL if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient def _async_task_side_effect( diff --git a/tests/modules/filestation/test_search.py b/tests/modules/filestation/test_search.py index 8e8f838..c6b9a6e 100644 --- a/tests/modules/filestation/test_search.py +++ b/tests/modules/filestation/test_search.py @@ -7,11 +7,11 @@ import httpx import respx -from synology_mcp.modules.filestation.search import search_files +from mcp_synology.modules.filestation.search import search_files from tests.conftest import BASE_URL if TYPE_CHECKING: - from synology_mcp.core.client import DsmClient + from mcp_synology.core.client import DsmClient class TestSearchFiles: diff --git a/tests/modules/filestation/test_transfer.py b/tests/modules/filestation/test_transfer.py new file mode 100644 index 0000000..a35be25 --- /dev/null +++ b/tests/modules/filestation/test_transfer.py @@ -0,0 +1,390 @@ +"""Tests for modules/filestation/transfer.py — upload_file, download_file.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING +from unittest.mock import patch + +import httpx +import respx + +from mcp_synology.modules.filestation.transfer import ( + download_file, + upload_file, +) +from tests.conftest import BASE_URL + +if TYPE_CHECKING: + from pathlib import Path + + from mcp_synology.core.client import DsmClient + + +class TestUploadFile: + @respx.mock + async def test_upload_success(self, mock_client: DsmClient, tmp_path: Path) -> None: + local_file = tmp_path / "test.txt" + local_file.write_text("hello world") + + respx.post(f"{BASE_URL}/webapi/entry.cgi").respond(json={"success": True, "data": {}}) + + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + ) + assert "[+]" in result + assert "test.txt" in result + assert "/video/uploads/" in result + + async def test_upload_local_file_not_found(self, mock_client: DsmClient) -> None: + result = await upload_file( + mock_client, + local_path="/nonexistent/file.txt", + dest_folder="/video/uploads", + ) + assert "[!]" in result + assert "not found" in result.lower() + + @respx.mock + async def test_upload_file_exists_no_overwrite( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + local_file = tmp_path / "test.txt" + local_file.write_text("hello") + + respx.post(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 414}} + ) + + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + ) + assert "[!]" in result + assert "already exists" in result + assert "overwrite=true" in result + + @respx.mock + async def test_upload_overwrite_success(self, mock_client: DsmClient, tmp_path: Path) -> None: + local_file = tmp_path / "test.txt" + local_file.write_text("updated content") + + respx.post(f"{BASE_URL}/webapi/entry.cgi").respond(json={"success": True, "data": {}}) + + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + overwrite=True, + ) + assert "[+]" in result + assert "test.txt" in result + + @respx.mock + async def test_upload_custom_filename(self, mock_client: DsmClient, tmp_path: Path) -> None: + local_file = tmp_path / "test.txt" + local_file.write_text("hello") + + respx.post(f"{BASE_URL}/webapi/entry.cgi").respond(json={"success": True, "data": {}}) + + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + filename="renamed.txt", + ) + assert "[+]" in result + assert "renamed.txt" in result + + @respx.mock + async def test_upload_dsm_error(self, mock_client: DsmClient, tmp_path: Path) -> None: + local_file = tmp_path / "test.txt" + local_file.write_text("hello") + + respx.post(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 1802}} + ) + + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + ) + assert "[!]" in result + assert "Upload" in result + + async def test_upload_local_file_permission_error( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + """OSError reading local file should return a formatted error.""" + local_file = tmp_path / "no_read.txt" + local_file.write_text("hello") + local_file.chmod(0o000) + + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + ) + # Restore permissions for cleanup + local_file.chmod(0o644) + assert "[!]" in result + assert "permission" in result.lower() or "not found" in result.lower() + + +class TestDownloadFile: + @respx.mock + async def test_download_success(self, mock_client: DsmClient, tmp_path: Path) -> None: + file_content = b"binary file content here" + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + content=file_content, + headers={"content-type": "application/octet-stream"}, + ) + + result = await download_file( + mock_client, + path="/video/movie.mkv", + dest_folder=str(tmp_path), + ) + assert "[+]" in result + assert "movie.mkv" in result + + downloaded = tmp_path / "movie.mkv" + assert downloaded.exists() + assert downloaded.read_bytes() == file_content + + async def test_download_local_dir_not_found(self, mock_client: DsmClient) -> None: + result = await download_file( + mock_client, + path="/video/movie.mkv", + dest_folder="/nonexistent/dir", + ) + assert "[!]" in result + assert "not found" in result.lower() + + async def test_download_file_exists_no_overwrite( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + existing = tmp_path / "movie.mkv" + existing.write_text("existing") + + result = await download_file( + mock_client, + path="/video/movie.mkv", + dest_folder=str(tmp_path), + ) + assert "[!]" in result + assert "already exists" in result + assert "overwrite=true" in result + + @respx.mock + async def test_download_overwrite_success(self, mock_client: DsmClient, tmp_path: Path) -> None: + existing = tmp_path / "movie.mkv" + existing.write_text("old content") + + new_content = b"new file content" + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + content=new_content, + headers={"content-type": "application/octet-stream"}, + ) + + result = await download_file( + mock_client, + path="/video/movie.mkv", + dest_folder=str(tmp_path), + overwrite=True, + ) + assert "[+]" in result + assert existing.read_bytes() == new_content + + @respx.mock + async def test_download_custom_filename(self, mock_client: DsmClient, tmp_path: Path) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + content=b"data", + headers={"content-type": "application/octet-stream"}, + ) + + result = await download_file( + mock_client, + path="/video/movie.mkv", + dest_folder=str(tmp_path), + filename="renamed.mkv", + ) + assert "[+]" in result + assert "renamed.mkv" in result + assert (tmp_path / "renamed.mkv").exists() + + @respx.mock + async def test_download_dsm_error_response( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 408}}, + headers={"content-type": "application/json"}, + ) + + result = await download_file( + mock_client, + path="/video/nonexistent.mkv", + dest_folder=str(tmp_path), + ) + assert "[!]" in result + assert "Download" in result + # Partial file should not exist + assert not (tmp_path / "nonexistent.mkv").exists() + + @respx.mock + async def test_download_partial_cleanup(self, mock_client: DsmClient, tmp_path: Path) -> None: + """Verify partial file is deleted on network failure.""" + respx.get(f"{BASE_URL}/webapi/entry.cgi").mock( + side_effect=httpx.ReadError("connection reset") + ) + + with contextlib.suppress(httpx.ReadError): + await download_file( + mock_client, + path="/video/big_file.mkv", + dest_folder=str(tmp_path), + ) + + # Partial file should be cleaned up + assert not (tmp_path / "big_file.mkv").exists() + + @respx.mock + async def test_download_write_permission_error( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + """OSError writing local file should return a formatted error. + + Simulates what happens when the filename contains OS-illegal characters + (e.g., ':' on Windows) or the directory is read-only, by attempting to + write to a read-only directory. + """ + readonly_dir = tmp_path / "readonly" + readonly_dir.mkdir() + readonly_dir.chmod(0o555) + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + content=b"data", + headers={"content-type": "application/octet-stream"}, + ) + + result = await download_file( + mock_client, + path="/video/file.mkv", + dest_folder=str(readonly_dir), + filename="test.mkv", + ) + # Restore permissions for cleanup + readonly_dir.chmod(0o755) + assert "[!]" in result + assert "filename" in result.lower() + + @respx.mock + async def test_download_insufficient_disk_space_preflight( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + """Pre-flight disk space check should catch insufficient space.""" + # Mock getinfo to return a huge file size + respx.get(f"{BASE_URL}/webapi/entry.cgi").mock( + side_effect=[ + # First call: getinfo returns file metadata + httpx.Response( + 200, + json={ + "success": True, + "data": { + "files": [ + { + "path": "/video/huge.mkv", + "additional": {"size": 999_999_999_999_999}, + } + ] + }, + }, + ), + # Second call (download) should not be reached + ] + ) + + result = await download_file( + mock_client, + path="/video/huge.mkv", + dest_folder=str(tmp_path), + ) + assert "[!]" in result + assert "disk space" in result.lower() + + @respx.mock + async def test_download_progress_callback(self, mock_client: DsmClient, tmp_path: Path) -> None: + """Progress callback should be called during download.""" + file_content = b"x" * 1024 + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + content=file_content, + headers={ + "content-type": "application/octet-stream", + "content-length": str(len(file_content)), + }, + ) + + progress_calls: list[tuple[int, int | None]] = [] + + async def _track_progress(current: int, total: int | None) -> None: + progress_calls.append((current, total)) + + result = await download_file( + mock_client, + path="/video/small.bin", + dest_folder=str(tmp_path), + progress_callback=_track_progress, + ) + assert "[+]" in result + assert len(progress_calls) > 0 + # Last call should have current == total bytes + last_current, last_total = progress_calls[-1] + assert last_current == len(file_content) + assert last_total == len(file_content) + + +class TestLargeFileWarnings: + @respx.mock + async def test_upload_large_file_timeout_warning( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + """Large uploads should include a timeout warning note.""" + local_file = tmp_path / "big.bin" + local_file.write_bytes(b"\0") + + respx.post(f"{BASE_URL}/webapi/entry.cgi").respond(json={"success": True, "data": {}}) + + # Temporarily lower the threshold so our tiny file triggers the warning + with patch("mcp_synology.modules.filestation.transfer._LARGE_FILE_THRESHOLD", 0): + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + ) + + assert "[+]" in result + assert "upload_timeout" in result + + @respx.mock + async def test_upload_small_file_no_warning( + self, mock_client: DsmClient, tmp_path: Path + ) -> None: + """Small uploads should NOT include a timeout warning.""" + local_file = tmp_path / "small.txt" + local_file.write_text("hello") + + respx.post(f"{BASE_URL}/webapi/entry.cgi").respond(json={"success": True, "data": {}}) + + result = await upload_file( + mock_client, + local_path=str(local_file), + dest_folder="/video/uploads", + ) + assert "[+]" in result + assert "timeout" not in result.lower() diff --git a/tests/modules/test_module_system.py b/tests/modules/test_module_system.py index 90b7da3..5faed56 100644 --- a/tests/modules/test_module_system.py +++ b/tests/modules/test_module_system.py @@ -4,8 +4,8 @@ import pytest -from synology_mcp.core.state import ApiInfoEntry -from synology_mcp.modules import ( +from mcp_synology.core.state import ApiInfoEntry +from mcp_synology.modules import ( ApiRequirement, ModuleInfo, PermissionTier, diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 88f7af8..86e39c2 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -5,11 +5,11 @@ import httpx import respx -from synology_mcp.core.client import DsmClient -from synology_mcp.modules.filestation.listing import list_shares -from synology_mcp.modules.filestation.operations import move_files -from synology_mcp.modules.filestation.search import search_files -from synology_mcp.server import create_server +from mcp_synology.core.client import DsmClient +from mcp_synology.modules.filestation.listing import list_shares +from mcp_synology.modules.filestation.operations import move_files +from mcp_synology.modules.filestation.search import search_files +from mcp_synology.server import create_server from tests.conftest import BASE_URL, make_api_cache, make_test_config diff --git a/tests/test_integration.py b/tests/test_integration.py index 56f11bb..54db24b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -21,21 +21,22 @@ import pytest import yaml -from synology_mcp.core.auth import AuthManager -from synology_mcp.core.client import DsmClient -from synology_mcp.core.config import AppConfig -from synology_mcp.modules.filestation.listing import list_files, list_recycle_bin, list_shares -from synology_mcp.modules.filestation.metadata import get_dir_size, get_file_info -from synology_mcp.modules.filestation.operations import ( +from mcp_synology.core.auth import AuthManager +from mcp_synology.core.client import DsmClient +from mcp_synology.core.config import AppConfig +from mcp_synology.modules.filestation.listing import list_files, list_recycle_bin, list_shares +from mcp_synology.modules.filestation.metadata import get_dir_size, get_file_info +from mcp_synology.modules.filestation.operations import ( copy_files, create_folder, delete_files, move_files, rename, ) -from synology_mcp.modules.filestation.search import search_files -from synology_mcp.modules.system.info import get_system_info -from synology_mcp.modules.system.utilization import get_resource_usage +from mcp_synology.modules.filestation.search import search_files +from mcp_synology.modules.filestation.transfer import download_file, upload_file +from mcp_synology.modules.system.info import get_system_info +from mcp_synology.modules.system.utilization import get_resource_usage logger = logging.getLogger(__name__) @@ -115,6 +116,8 @@ async def nas_client( "SYNO.FileStation.Rename", "SYNO.FileStation.DirSize", "SYNO.FileStation.Info", + "SYNO.FileStation.Upload", + "SYNO.FileStation.Download", "SYNO.DSM.Info", ] for api_name in _relevant: @@ -618,6 +621,208 @@ async def test_02_list_recycle_bin(self, nas_client: Any) -> None: assert isinstance(result, str) +# --------------------------------------------------------------------------- +# File transfers (upload / download) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestFileTransfers: + """Test upload_file and download_file against real NAS. + + Creates a temp file locally, uploads it, downloads it back, and verifies + the round-trip. Cleans up both local and NAS files. + """ + + _UPLOAD_DIR = "_integration_test_transfer" + _TEST_CONTENT = b"mcp-synology integration test content\n" + + async def test_01_upload_file(self, nas_client: Any, tmp_path: Path) -> None: + """Upload a small test file to the NAS.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + dest = f"{base}/{self._UPLOAD_DIR}" + + # Create local test file + local_file = tmp_path / "upload_test.txt" + local_file.write_bytes(self._TEST_CONTENT) + + result = await upload_file( + client, + local_path=str(local_file), + dest_folder=dest, + create_parents=True, + ) + logger.info("upload_file result:\n%s", result) + assert "[+]" in result + assert "upload_test.txt" in result + + async def test_02_upload_duplicate_no_overwrite(self, nas_client: Any, tmp_path: Path) -> None: + """Uploading the same file again without overwrite. + + Note: DSM's Upload API v2 may silently skip or overwrite depending + on the NAS configuration. We verify it doesn't crash — the behavior + varies across DSM versions. + """ + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + dest = f"{base}/{self._UPLOAD_DIR}" + + local_file = tmp_path / "upload_test.txt" + local_file.write_bytes(self._TEST_CONTENT) + + result = await upload_file( + client, + local_path=str(local_file), + dest_folder=dest, + ) + logger.info("upload duplicate (no overwrite):\n%s", result) + # DSM may return success (silent overwrite) or error (already exists). + # Either is acceptable — we just verify it doesn't crash. + assert isinstance(result, str) + + async def test_03_upload_overwrite(self, nas_client: Any, tmp_path: Path) -> None: + """Uploading with overwrite=True should succeed.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + dest = f"{base}/{self._UPLOAD_DIR}" + + local_file = tmp_path / "upload_test.txt" + local_file.write_bytes(self._TEST_CONTENT) + + result = await upload_file( + client, + local_path=str(local_file), + dest_folder=dest, + overwrite=True, + ) + logger.info("upload overwrite:\n%s", result) + assert "[+]" in result + + async def test_04_upload_custom_filename(self, nas_client: Any, tmp_path: Path) -> None: + """Upload with a custom filename on the NAS.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + dest = f"{base}/{self._UPLOAD_DIR}" + + local_file = tmp_path / "original_name.txt" + local_file.write_bytes(b"renamed upload test\n") + + result = await upload_file( + client, + local_path=str(local_file), + dest_folder=dest, + filename="renamed_on_nas.txt", + ) + logger.info("upload custom filename:\n%s", result) + assert "[+]" in result + assert "renamed_on_nas.txt" in result + + async def test_05_verify_uploaded_files(self, nas_client: Any) -> None: + """Verify both uploaded files appear in the NAS listing.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + listing = await list_files(client, path=f"{base}/{self._UPLOAD_DIR}") + logger.info("Listing after uploads:\n%s", listing) + assert "upload_test.txt" in listing + assert "renamed_on_nas.txt" in listing + + async def test_06_download_file(self, nas_client: Any, tmp_path: Path) -> None: + """Download the uploaded file back and verify content.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + nas_path = f"{base}/{self._UPLOAD_DIR}/upload_test.txt" + + result = await download_file( + client, + path=nas_path, + dest_folder=str(tmp_path), + ) + logger.info("download_file result:\n%s", result) + assert "[+]" in result + assert "upload_test.txt" in result + + # Verify content round-trip + downloaded = tmp_path / "upload_test.txt" + assert downloaded.exists() + assert downloaded.read_bytes() == self._TEST_CONTENT + + async def test_07_download_custom_filename(self, nas_client: Any, tmp_path: Path) -> None: + """Download with a custom local filename.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + nas_path = f"{base}/{self._UPLOAD_DIR}/upload_test.txt" + + result = await download_file( + client, + path=nas_path, + dest_folder=str(tmp_path), + filename="local_renamed.txt", + ) + logger.info("download custom filename:\n%s", result) + assert "[+]" in result + assert "local_renamed.txt" in result + assert (tmp_path / "local_renamed.txt").exists() + + async def test_08_download_no_overwrite(self, nas_client: Any, tmp_path: Path) -> None: + """Download should fail if local file exists and overwrite=False.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + nas_path = f"{base}/{self._UPLOAD_DIR}/upload_test.txt" + + # Create existing local file + existing = tmp_path / "upload_test.txt" + existing.write_text("existing local content") + + result = await download_file( + client, + path=nas_path, + dest_folder=str(tmp_path), + ) + logger.info("download no overwrite:\n%s", result) + assert "[!]" in result + assert "already exists" in result + + async def test_09_download_nonexistent(self, nas_client: Any, tmp_path: Path) -> None: + """Download a non-existent NAS file should return formatted error.""" + client, _, _, _ = _unpack(nas_client) + + result = await download_file( + client, + path="/zzz_nonexistent_999/fake.txt", + dest_folder=str(tmp_path), + ) + logger.info("download nonexistent:\n%s", result) + assert "[!]" in result + + async def test_10_cleanup(self, nas_client: Any) -> None: + """Delete the test upload directory from the NAS.""" + client, _, config, paths = _unpack(nas_client) + _skip_unless_write(config) + + base = paths["writable_folder"] + target = f"{base}/{self._UPLOAD_DIR}" + result = await delete_files(client, paths=[target], recursive=True) + logger.info("transfer cleanup:\n%s", result) + assert "[!]" not in result + + # --------------------------------------------------------------------------- # Error handling # --------------------------------------------------------------------------- diff --git a/tests/vdsm/__init__.py b/tests/vdsm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/vdsm/config.py b/tests/vdsm/config.py new file mode 100644 index 0000000..3e27e6f --- /dev/null +++ b/tests/vdsm/config.py @@ -0,0 +1,95 @@ +"""Version registry and constants for virtual-dsm test infrastructure.""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path + + +@dataclasses.dataclass(frozen=True) +class DsmVersionInfo: + """Metadata for a specific DSM release.""" + + version: str + build: str + pat_url: str + + +DSM_VERSIONS: dict[str, DsmVersionInfo] = { + "7.0.1": DsmVersionInfo( + version="7.0.1", + build="42218", + pat_url=( + "https://global.synologydownload.com/download/DSM/release" + "/7.0.1/42218/DSM_VirtualDSM_42218.pat" + ), + ), + "7.1": DsmVersionInfo( + version="7.1", + build="42661", + pat_url=( + "https://global.synologydownload.com/download/DSM/release" + "/7.1/42661/DSM_VirtualDSM_42661.pat" + ), + ), + "7.2.1": DsmVersionInfo( + version="7.2.1", + build="69057", + pat_url=( + "https://global.synologydownload.com/download/DSM/release" + "/7.2.1/69057/DSM_VirtualDSM_69057.pat" + ), + ), + "7.2.2": DsmVersionInfo( + version="7.2.2", + build="72806", + pat_url=( + "https://global.synologydownload.com/download/DSM/release" + "/7.2.2/72806/DSM_VirtualDSM_72806.pat" + ), + ), + "7.3.2": DsmVersionInfo( + version="7.3.2", + build="86009", + pat_url=( + "https://global.synologydownload.com/download/DSM/release" + "/7.3.2/86009/DSM_VirtualDSM_86009.pat" + ), + ), +} + +DEFAULT_DSM_VERSION: str = "7.2.2" + +# Project root / .vdsm directory for all virtual-dsm artifacts +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +VDSM_ROOT: Path = _PROJECT_ROOT / ".vdsm" + + +def golden_image_path(version: str) -> Path: + """Path to the compressed golden image tarball for a DSM version.""" + return VDSM_ROOT / "golden" / f"dsm-{version}.tar.gz" + + +def golden_meta_path(version: str) -> Path: + """Path to the metadata sidecar JSON for a golden image.""" + return VDSM_ROOT / "golden" / f"dsm-{version}.meta.json" + + +def storage_path(version: str) -> Path: + """Path to the live storage directory for a DSM version container.""" + return VDSM_ROOT / "storage" / f"dsm-{version}" + + +# Default credentials for test DSM instances +DEFAULT_ADMIN_USER: str = "mcpadmin" +DEFAULT_TEST_USER: str = "mcptest" +DEFAULT_TEST_PASSWORD: str = "McpTest123!" + +# Container resource limits +CONTAINER_DISK_SIZE: str = "16G" +CONTAINER_RAM: str = "2G" +CONTAINER_CPU_CORES: str = "2" + +# Timing +DSM_BOOT_TIMEOUT: int = 600 # seconds +DSM_API_POLL_INTERVAL: int = 10 # seconds diff --git a/tests/vdsm/conftest.py b/tests/vdsm/conftest.py new file mode 100644 index 0000000..41e8830 --- /dev/null +++ b/tests/vdsm/conftest.py @@ -0,0 +1,203 @@ +"""Pytest fixtures for virtual-dsm integration tests. + +Provides a session-scoped container fixture and a function-scoped nas_client +fixture that matches the shape of the real-NAS fixture in test_integration.py. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import pytest + +from mcp_synology.core.auth import AuthManager +from mcp_synology.core.client import DsmClient +from mcp_synology.core.config import AppConfig +from tests.vdsm.config import DEFAULT_DSM_VERSION, DSM_VERSIONS +from tests.vdsm.container import VirtualDsmContainer +from tests.vdsm.golden_image import has_golden_image, load_golden_meta, restore_golden_image + +logger = logging.getLogger(__name__) + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add --dsm-version command line option.""" + parser.addoption( + "--dsm-version", + default=DEFAULT_DSM_VERSION, + choices=list(DSM_VERSIONS.keys()), + help=f"DSM version to test against (default: {DEFAULT_DSM_VERSION})", + ) + + +@pytest.fixture(scope="session") +def dsm_version(request: pytest.FixtureRequest) -> str: + """The DSM version to test, from --dsm-version CLI option.""" + return request.config.getoption("--dsm-version") # type: ignore[no-any-return] + + +@pytest.fixture(scope="session") +def vdsm_container(dsm_version: str) -> Any: + """Boot a virtual-dsm container from golden image for the test session. + + Yields the VirtualDsmContainer instance. Skips if prerequisites are missing. + """ + if not Path("/dev/kvm").exists(): + pytest.skip("virtual-dsm requires /dev/kvm (Linux with KVM support)") + + if not has_golden_image(dsm_version): + pytest.skip( + f"Golden image not found for DSM {dsm_version}. " + f"Run: python scripts/vdsm_setup.py --version {dsm_version}" + ) + + storage_dir = restore_golden_image(dsm_version) + logger.info("Restored golden image for DSM %s to %s", dsm_version, storage_dir) + + container = VirtualDsmContainer(dsm_version, storage_dir) + try: + container.start() + yield container + finally: + container.stop() + + +@pytest.fixture(scope="session") +def vdsm_config( + vdsm_container: VirtualDsmContainer, + dsm_version: str, +) -> tuple[AppConfig, dict[str, str]]: + """Build AppConfig and test_paths from the running virtual-dsm container. + + Session-scoped — config doesn't change between tests. + """ + meta = load_golden_meta(dsm_version) + test_paths: dict[str, str] = meta.get("test_paths", {}) # type: ignore[assignment] + + parsed = urlparse(vdsm_container.base_url) + host = parsed.hostname or "localhost" + port = parsed.port or 5000 + + config = AppConfig( + schema_version=1, + instance_id=f"vdsm-{dsm_version}", + connection={ + "host": host, + "port": port, + "https": False, + "verify_ssl": False, + }, + modules={ + "filestation": { + "enabled": True, + "permission": "write", + }, + "system": { + "enabled": True, + }, + }, + ) + return config, test_paths + + +@pytest.fixture +def integration_config( + vdsm_config: tuple[AppConfig, dict[str, str]], +) -> tuple[AppConfig, dict[str, str]]: + """Override integration_config to point at virtual-dsm. + + This shadows the integration_config fixture from test_integration.py + for tests collected under tests/vdsm/. + """ + return vdsm_config + + +@pytest.fixture +async def nas_client( + vdsm_config: tuple[AppConfig, dict[str, str]], + dsm_version: str, +) -> Any: + """Provide an authenticated DsmClient connected to virtual-dsm. + + Function-scoped async generator — same shape as the real-NAS fixture + in test_integration.py: yields (client, auth, config, test_paths). + """ + config, test_paths = vdsm_config + conn = config.connection + assert conn is not None + + base_url = f"http://{conn.host}:{conn.port}" + + client = DsmClient( + base_url=base_url, + verify_ssl=False, + timeout=30, + ) + + async with client: + cache = await client.query_api_info() + logger.info("vDSM API cache: %d APIs discovered (DSM %s)", len(cache), dsm_version) + + auth = AuthManager(config, client) + sid = await auth.login() + logger.info("vDSM authenticated, SID=%s... (DSM %s)", sid[:8], dsm_version) + + yield client, auth, config, test_paths + + await auth.logout() + + +@pytest.fixture +async def admin_client( + vdsm_config: tuple[AppConfig, dict[str, str]], + dsm_version: str, +) -> Any: + """Provide an admin-authenticated DsmClient for virtual-dsm. + + Virtual-dsm typically runs with admin credentials, so this is the + same as nas_client but authenticates as admin. + """ + config, test_paths = vdsm_config + conn = config.connection + assert conn is not None + + base_url = f"http://{conn.host}:{conn.port}" + meta = load_golden_meta(dsm_version) + + # Build admin config + admin_config = AppConfig( + schema_version=1, + instance_id=f"vdsm-admin-{dsm_version}", + connection={ + "host": conn.host, + "port": conn.port, + "https": False, + "verify_ssl": False, + }, + auth={ + "username": meta.get("admin_user", "admin"), + }, + modules={ + "filestation": {"enabled": True, "permission": "write"}, + "system": {"enabled": True}, + }, + ) + + client = DsmClient( + base_url=base_url, + verify_ssl=False, + timeout=30, + ) + + async with client: + await client.query_api_info() + auth = AuthManager(admin_config, client) + sid = await auth.login() + logger.info("vDSM admin authenticated, SID=%s... (DSM %s)", sid[:8], dsm_version) + + yield client, auth, admin_config, test_paths + + await auth.logout() diff --git a/tests/vdsm/container.py b/tests/vdsm/container.py new file mode 100644 index 0000000..671c06d --- /dev/null +++ b/tests/vdsm/container.py @@ -0,0 +1,216 @@ +"""Virtual-dsm Docker container lifecycle management using testcontainers.""" + +from __future__ import annotations + +import logging +import os +import time +from pathlib import Path +from typing import Any + +import httpx + +from tests.vdsm.config import ( + CONTAINER_CPU_CORES, + CONTAINER_DISK_SIZE, + CONTAINER_RAM, + DSM_API_POLL_INTERVAL, + DSM_BOOT_TIMEOUT, + DSM_VERSIONS, + DsmVersionInfo, +) + +try: + from testcontainers.core.container import DockerContainer +except ImportError: + DockerContainer = None # type: ignore[assignment,misc] + +logger = logging.getLogger(__name__) + +# Common non-default Docker socket locations (Rancher Desktop, Docker Desktop, +# Podman, rootless Docker, Colima, etc.) +_DOCKER_SOCKET_CANDIDATES = [ + Path.home() / ".docker/desktop/docker.sock", # Rancher Desktop / Docker Desktop + Path.home() / ".docker/run/docker.sock", # Docker Desktop alt + Path(f"/run/user/{os.getuid()}/docker.sock"), # Rootless Docker + Path(f"/run/user/{os.getuid()}/podman/podman.sock"), # Podman + Path.home() / ".colima/default/docker.sock", # Colima (macOS) +] + + +def _ensure_docker_host() -> None: + """Set DOCKER_HOST if not already set, preferring Podman for KVM access. + + virtual-dsm needs /dev/kvm passthrough. Docker Desktop and Rancher Desktop + run containers inside a VM that typically lacks nested virtualization, so + /dev/kvm isn't available. Podman runs containers natively on the host and + can access /dev/kvm directly. + + Priority order: + 1. DOCKER_HOST already set — respect it + 2. Podman socket — preferred for KVM passthrough + 3. Default Docker socket (/var/run/docker.sock) + 4. Other Docker socket locations (Rancher Desktop, Docker Desktop, etc.) + """ + if os.environ.get("DOCKER_HOST"): + return + + # Prefer Podman — it runs natively on the host with direct KVM access. + # Docker Desktop/Rancher Desktop run containers in a VM without KVM. + podman_socket = Path(f"/run/user/{os.getuid()}/podman/podman.sock") + if podman_socket.exists(): + docker_host = f"unix://{podman_socket}" + os.environ["DOCKER_HOST"] = docker_host + # Testcontainers also needs this to skip Docker-specific API features + os.environ.setdefault("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", str(podman_socket)) + logger.info("Using Podman socket for KVM passthrough: %s", docker_host) + return + + default_socket = Path("/var/run/docker.sock") + if default_socket.exists(): + return + + for candidate in _DOCKER_SOCKET_CANDIDATES: + if candidate.exists(): + docker_host = f"unix://{candidate}" + os.environ["DOCKER_HOST"] = docker_host + logger.info("Auto-detected Docker socket: %s", docker_host) + return + + logger.warning("No Docker/Podman socket found. Set DOCKER_HOST or start Docker/Podman.") + + +def _is_podman() -> bool: + """Detect if we're using Podman instead of Docker.""" + docker_host = os.environ.get("DOCKER_HOST", "") + return "podman" in docker_host + + +class VirtualDsmContainer: + """Manages a virtual-dsm Docker container for integration testing. + + Requires testcontainers-python (install with `uv sync --extra vdsm`) + and KVM support on the host. + """ + + def __init__(self, version: str, storage_dir: Path) -> None: + if DockerContainer is None: + msg = "testcontainers is not installed. Install with: uv sync --extra vdsm" + raise ImportError(msg) + + if version not in DSM_VERSIONS: + msg = f"Unknown DSM version: {version!r}. Available: {list(DSM_VERSIONS)}" + raise ValueError(msg) + + self.version = version + self.version_info: DsmVersionInfo = DSM_VERSIONS[version] + self.storage_dir = storage_dir + self._container: DockerContainer | None = None + + def start(self) -> None: + """Create and start the virtual-dsm container, waiting for DSM to boot.""" + _ensure_docker_host() + # Disable the Ryuk reaper — it fails on non-standard Docker setups + # (Rancher Desktop, rootless, etc.). We handle cleanup in stop(). + os.environ.setdefault("TESTCONTAINERS_RYUK_DISABLED", "true") + self.storage_dir.mkdir(parents=True, exist_ok=True) + + container = DockerContainer("vdsm/virtual-dsm") + container.with_env("DISK_SIZE", CONTAINER_DISK_SIZE) + container.with_env("URL", self.version_info.pat_url) + container.with_env("RAM_SIZE", CONTAINER_RAM) + container.with_env("CPU_CORES", CONTAINER_CPU_CORES) + container.with_exposed_ports(5000) + # :Z flag is required on SELinux-enforcing systems (Fedora, RHEL) + # so the container process can write to the bind mount. + container.with_volume_mapping(str(self.storage_dir), "/storage", mode="Z") + + kwargs: dict[str, Any] = { + "devices": ["/dev/kvm:/dev/kvm", "/dev/net/tun:/dev/net/tun"], + "cap_add": ["NET_ADMIN"], + } + # Podman's default passt networking doesn't forward ports properly + # for QEMU VMs. Use slirp4netns instead. + if _is_podman(): + kwargs["network_mode"] = "slirp4netns:port_handler=slirp4netns" + + container.with_kwargs(**kwargs) + + logger.info( + "Starting virtual-dsm container (DSM %s, build %s)", + self.version, + self.version_info.build, + ) + container.start() + self._container = container + + self._wait_for_dsm() + + def _wait_for_dsm(self) -> None: + """Poll the DSM API info endpoint until DSM is ready.""" + url = f"{self.base_url}/webapi/query.cgi" + params = {"api": "SYNO.API.Info", "version": "1", "method": "query", "query": "ALL"} + logger.info("Polling DSM at %s (timeout: %ds)", self.base_url, DSM_BOOT_TIMEOUT) + + start = time.monotonic() + deadline = start + DSM_BOOT_TIMEOUT + last_status = "" + + while time.monotonic() < deadline: + elapsed = int(time.monotonic() - start) + try: + resp = httpx.get(url, params=params, timeout=10, verify=False) # noqa: S501 + status = f"HTTP {resp.status_code}" + if resp.status_code == 200: + try: + data = resp.json() + if data.get("success"): + logger.info("DSM %s ready after %ds", self.version, elapsed) + return + status = "HTTP 200 but success=false" + except Exception: + status = "HTTP 200 but non-JSON response" + if status != last_status: + logger.info("DSM boot: %s (%ds)", status, elapsed) + last_status = status + except ( + httpx.ConnectError, + httpx.ReadTimeout, + httpx.ConnectTimeout, + httpx.ReadError, + httpx.RemoteProtocolError, + ) as e: + status = type(e).__name__ + if status != last_status: + logger.info("DSM boot: %s (%ds)", status, elapsed) + last_status = status + + time.sleep(DSM_API_POLL_INTERVAL) + + elapsed = int(time.monotonic() - start) + msg = f"DSM {self.version} did not become ready within {elapsed}s" + raise TimeoutError(msg) + + @property + def base_url(self) -> str: + """HTTP base URL for the running DSM instance.""" + if self._container is None: + msg = "Container is not started" + raise RuntimeError(msg) + host = self._container.get_container_host_ip() + port = self._container.get_exposed_port(5000) + return f"http://{host}:{port}" + + def stop(self) -> None: + """Stop the container if it is running.""" + if self._container is not None: + logger.info("Stopping virtual-dsm container (DSM %s)", self.version) + try: + self._container.stop() + except Exception: + logger.warning( + "Failed to stop virtual-dsm container cleanly", + exc_info=True, + ) + finally: + self._container = None diff --git a/tests/vdsm/golden_image.py b/tests/vdsm/golden_image.py new file mode 100644 index 0000000..109248f --- /dev/null +++ b/tests/vdsm/golden_image.py @@ -0,0 +1,119 @@ +"""Golden image save/restore for virtual-dsm test instances.""" + +from __future__ import annotations + +import json +import logging +import shutil +import tarfile +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +from tests.vdsm.config import ( + DEFAULT_ADMIN_USER, + DEFAULT_TEST_PASSWORD, + DEFAULT_TEST_USER, + DSM_VERSIONS, + golden_image_path, + golden_meta_path, + storage_path, +) + +logger = logging.getLogger(__name__) + + +def save_golden_image( + version: str, + *, + metadata: dict[str, object] | None = None, +) -> Path: + """Tar the storage directory into a golden image. + + The storage directory must already exist and contain a configured DSM instance. + A metadata sidecar JSON is saved alongside the tarball. + + If metadata is provided (from setup_dsm_for_testing), it's saved as-is. + Otherwise, a default metadata dict is generated. + + Returns the path to the created golden image tarball. + """ + source = storage_path(version) + if not source.is_dir(): + msg = f"Storage directory does not exist: {source}" + raise FileNotFoundError(msg) + + dest = golden_image_path(version) + dest.parent.mkdir(parents=True, exist_ok=True) + + logger.info("Saving golden image for DSM %s: %s -> %s", version, source, dest) + with tarfile.open(dest, "w:gz") as tar: + tar.add(str(source), arcname=".") + + # Save metadata sidecar + if metadata is None: + version_info = DSM_VERSIONS.get(version) + metadata = { + "version": version, + "build": version_info.build if version_info else "unknown", + "admin_user": DEFAULT_ADMIN_USER, + "test_user": DEFAULT_TEST_USER, + "test_password": DEFAULT_TEST_PASSWORD, + "test_paths": { + "existing_share": "/testshare", + "search_folder": "/testshare/Documents", + "search_keyword": "Bambu", + "writable_folder": "/writable", + }, + } + + meta_path = golden_meta_path(version) + meta_path.write_text(json.dumps(metadata, indent=2) + "\n") + logger.info("Saved golden image metadata: %s", meta_path) + + return dest + + +def restore_golden_image(version: str) -> Path: + """Extract golden image to storage directory. + + Deletes any existing storage directory for this version to ensure a clean + slate, then extracts the golden image tarball. + + Returns the storage directory path. + """ + source = golden_image_path(version) + if not source.is_file(): + msg = f"Golden image not found: {source}" + raise FileNotFoundError(msg) + + dest = storage_path(version) + + # Clean slate + if dest.exists(): + logger.info("Removing existing storage directory: %s", dest) + shutil.rmtree(dest) + + dest.mkdir(parents=True, exist_ok=True) + + logger.info("Restoring golden image for DSM %s: %s -> %s", version, source, dest) + with tarfile.open(source, "r:gz") as tar: + tar.extractall(path=str(dest)) # noqa: S202 + + return dest + + +def has_golden_image(version: str) -> bool: + """Check if a golden image exists for this version.""" + return golden_image_path(version).is_file() + + +def load_golden_meta(version: str) -> dict[str, object]: + """Load the meta.json sidecar for a golden image.""" + meta_path = golden_meta_path(version) + if not meta_path.is_file(): + msg = f"Golden image metadata not found: {meta_path}" + raise FileNotFoundError(msg) + + return json.loads(meta_path.read_text()) # type: ignore[no-any-return] diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py new file mode 100644 index 0000000..c40869f --- /dev/null +++ b/tests/vdsm/setup_dsm.py @@ -0,0 +1,578 @@ +"""DSM setup automation for virtual-dsm test instances. + +Includes: +- Playwright-based wizard automation (first-boot setup, fully headless) +- Post-wizard API configuration (users, shares, permissions, test data) + +Note: SYNO.Core.User, SYNO.Core.Share, and SYNO.Core.Share.Permission are +UNDOCUMENTED admin APIs. Parameters are based on community reverse engineering +and may need adjustment across DSM versions. +""" + +from __future__ import annotations + +import io +import logging +import time +from typing import Any + +import httpx + +from tests.vdsm.config import ( + DEFAULT_ADMIN_USER, + DEFAULT_TEST_PASSWORD, + DEFAULT_TEST_USER, + DSM_API_POLL_INTERVAL, + DSM_BOOT_TIMEOUT, +) + +logger = logging.getLogger(__name__) + + +def wait_for_api(base_url: str, timeout: int = DSM_BOOT_TIMEOUT) -> None: + """Poll DSM API info endpoint until it responds. + + Retries every DSM_API_POLL_INTERVAL seconds until success or timeout. + """ + url = f"{base_url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query&query=ALL" + start = time.monotonic() + deadline = start + timeout + + while time.monotonic() < deadline: + elapsed = int(time.monotonic() - start) + try: + resp = httpx.get(url, timeout=10, verify=False) # noqa: S501 + if resp.status_code == 200: + data = resp.json() + if data.get("success"): + logger.info("DSM API ready after %ds", elapsed) + return + except (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout): + pass + + logger.debug("Waiting for DSM API... (%ds)", elapsed) + print(f" Waiting for DSM API... ({elapsed}s)") + time.sleep(DSM_API_POLL_INTERVAL) + + elapsed = int(time.monotonic() - start) + msg = f"DSM API did not become ready within {elapsed}s" + raise TimeoutError(msg) + + +def complete_wizard(base_url: str, admin_user: str, admin_password: str) -> None: + """Automate the DSM first-boot setup wizard using Playwright. + + Fills in the admin account form and clicks through all wizard pages + (update settings, Synology account, analytics, package offers). + + Requires: playwright with chromium installed + (uv sync --extra vdsm && uv run playwright install chromium) + """ + try: + from playwright.sync_api import sync_playwright + except ImportError as e: + msg = "Playwright is required for wizard automation: uv sync --extra vdsm" + raise ImportError(msg) from e + + print(" Automating DSM setup wizard with Playwright...") + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1280, "height": 900}) + + try: + page.goto(base_url, wait_until="networkidle", timeout=60000) + time.sleep(3) + + # Step 1: Welcome — wait for wizard to fully render, then click Start + print(" [1/6] Welcome page (waiting for wizard to load)...") + page.wait_for_selector(".welcome-page-btn", timeout=120000) + page.click(".welcome-page-btn") + time.sleep(2) + + # Step 2: Account setup — fill form and click Next + print(" [2/6] Account setup...") + page.fill("input[name=device_name]", "VirtualDSM") + page.fill("input[name=nas_account]", admin_user) + passwords = [ + pw for pw in page.query_selector_all("input[name=password]") if pw.is_visible() + ] + if len(passwords) < 2: + msg = "Could not find password fields in wizard" + raise RuntimeError(msg) + passwords[0].fill(admin_password) + passwords[1].fill(admin_password) + page.click("button:has-text('Next')") + time.sleep(3) + + # Verify we advanced (check for error banner) + error = page.query_selector(".v-tooltip-error, .error-msg") + if error and error.is_visible(): + error_text = error.inner_text() + msg = f"Wizard account setup failed: {error_text}" + raise RuntimeError(msg) + + # Step 3: Update options — accept default, click Next + print(" [3/6] Update options...") + page.click("button:has-text('Next')") + time.sleep(3) + + # Step 4: Synology Account — click Skip + print(" [4/6] Synology Account (skipping)...") + page.click("button:has-text('Skip')") + time.sleep(3) + + # Step 5: Device Analytics — click Submit (unchecked = decline) + print(" [5/6] Device Analytics (declining)...") + page.click("button:has-text('Submit')") + time.sleep(3) + + # Step 6: Synology Drive/Office install — click "No, thanks" + print(" [6/6] Package install offer (declining)...") + no_btn = page.query_selector("button:has-text('No, thanks')") + if no_btn and no_btn.is_visible(): + no_btn.click() + time.sleep(3) + else: + # Some DSM versions may not show this step + logger.info("No package install prompt found — skipping") + + print(" Wizard complete!") + + finally: + browser.close() + + +def login(base_url: str, username: str, password: str) -> tuple[str, str]: + """Login to DSM and return (session_id, syno_token). + + The SynoToken is a CSRF token required by DSM 7 for admin write operations + (SYNO.Core.User, SYNO.Core.Share, etc.). Requested via enable_syno_token=yes. + """ + params = { + "api": "SYNO.API.Auth", + "version": "6", + "method": "login", + "account": username, + "passwd": password, + "format": "sid", + "enable_syno_token": "yes", + } + resp = httpx.get( + f"{base_url}/webapi/entry.cgi", + params=params, + timeout=30, + verify=False, # noqa: S501 + ) + resp.raise_for_status() + body = resp.json() + + if not body.get("success"): + code = body.get("error", {}).get("code", 0) + msg = f"Login failed with error code {code}" + raise RuntimeError(msg) + + data = body["data"] + sid: str = data["sid"] + syno_token: str = data.get("synotoken", "") + logger.info("Logged in as %s (sid=%s..., synotoken=%s)", username, sid[:8], bool(syno_token)) + return sid, syno_token + + +def logout(base_url: str, sid: str) -> None: + """Logout from DSM, invalidating the session.""" + params = { + "api": "SYNO.API.Auth", + "version": "6", + "method": "logout", + "_sid": sid, + } + try: + resp = httpx.get( + f"{base_url}/webapi/entry.cgi", + params=params, + timeout=10, + verify=False, # noqa: S501 + ) + resp.raise_for_status() + logger.info("Logged out successfully") + except Exception: + logger.warning("Logout failed (non-critical)", exc_info=True) + + +def _admin_post( + base_url: str, + sid: str, + syno_token: str, + data: dict[str, str], +) -> dict[str, Any]: + """Make an admin POST request with SynoToken CSRF header. + + DSM 7 requires the SynoToken for all admin write operations + (SYNO.Core.User, SYNO.Core.Share, etc.). + """ + data["_sid"] = sid + headers: dict[str, str] = {} + if syno_token: + headers["X-SYNO-TOKEN"] = syno_token + resp = httpx.post( + f"{base_url}/webapi/entry.cgi", + data=data, + headers=headers, + timeout=30, + verify=False, # noqa: S501 + ) + resp.raise_for_status() + return resp.json() # type: ignore[no-any-return] + + +def create_user( + base_url: str, sid: str, username: str, password: str, *, syno_token: str = "" +) -> None: + """Create a local DSM user. + + Uses the undocumented SYNO.Core.User API. If it fails, prints manual + instructions and continues. + """ + try: + body = _admin_post( + base_url, + sid, + syno_token, + { + "api": "SYNO.Core.User", + "method": "create", + "version": "1", + "name": username, + "password": password, + "description": "MCP integration test user", + }, + ) + + if body.get("success"): + logger.info("Created user: %s", username) + print(f" Created user: {username}") + else: + code = body.get("error", {}).get("code", 0) + logger.warning("Create user API returned error code %d", code) + print(f" Warning: Create user returned error code {code}") + _print_manual_user_instructions(username, password) + except Exception: + logger.warning("Create user API call failed", exc_info=True) + _print_manual_user_instructions(username, password) + + +def _print_manual_user_instructions(username: str, password: str) -> None: + """Print instructions for manual user creation via web UI.""" + print("\n Manual step needed — create user via DSM web UI:") + print(" Control Panel > User & Group > Create") + print(f" Username: {username}") + print(f" Password: {password}") + print() + + +def create_shared_folder( + base_url: str, sid: str, name: str, vol_path: str = "/volume1", *, syno_token: str = "" +) -> None: + """Create a shared folder on the NAS. + + Uses the undocumented SYNO.Core.Share API. If it fails, prints manual + instructions and continues. + """ + try: + body = _admin_post( + base_url, + sid, + syno_token, + { + "api": "SYNO.Core.Share", + "method": "create", + "version": "1", + "name": name, + "vol_path": vol_path, + "desc": f"MCP test share: {name}", + }, + ) + + if body.get("success"): + logger.info("Created shared folder: %s", name) + print(f" Created shared folder: {name}") + else: + code = body.get("error", {}).get("code", 0) + logger.warning("Create share API returned error code %d for '%s'", code, name) + print(f" Warning: Create share '{name}' returned error code {code}") + _print_manual_share_instructions(name) + except Exception: + logger.warning("Create share API call failed for '%s'", name, exc_info=True) + _print_manual_share_instructions(name) + + +def _print_manual_share_instructions(name: str) -> None: + """Print instructions for manual shared folder creation via web UI.""" + print("\n Manual step needed — create shared folder via DSM web UI:") + print(" Control Panel > Shared Folder > Create") + print(f" Name: {name}") + print(" Location: Volume 1") + print() + + +def set_share_permissions( + base_url: str, sid: str, share_name: str, username: str, *, syno_token: str = "" +) -> None: + """Grant read/write access to a user on a shared folder. + + Uses the undocumented SYNO.Core.Share.Permission API. If it fails, + prints manual instructions and continues. + """ + # The permission payload format varies across DSM versions. This is a + # best-effort attempt based on community reverse engineering. + try: + body = _admin_post( + base_url, + sid, + syno_token, + { + "api": "SYNO.Core.Share.Permission", + "method": "set", + "version": "1", + "name": share_name, + "user_group_type": "local_user", + "permissions": f'{{"users":[{{"name":"{username}","is_writable":true}}]}}', + }, + ) + + if body.get("success"): + logger.info("Set permissions on /%s for %s", share_name, username) + print(f" Set permissions on /{share_name} for {username}") + else: + code = body.get("error", {}).get("code", 0) + logger.warning( + "Set permissions API returned error code %d for '%s'", + code, + share_name, + ) + print(f" Warning: Set permissions on '{share_name}' returned error code {code}") + _print_manual_permission_instructions(share_name, username) + except Exception: + logger.warning( + "Set permissions API call failed for '%s'", + share_name, + exc_info=True, + ) + _print_manual_permission_instructions(share_name, username) + + +def _print_manual_permission_instructions(share_name: str, username: str) -> None: + """Print instructions for manual permission setting via web UI.""" + print("\n Manual step needed — set permissions via DSM web UI:") + print(f" Control Panel > Shared Folder > Select '{share_name}' > Edit") + print(f" Permissions tab > Local users > {username} > Read/Write") + print() + + +def upload_test_data(base_url: str, sid: str) -> None: + """Upload seed files for search and listing tests. + + Creates small test files in /testshare for integration tests to validate + against. Uses SYNO.FileStation.Upload with multipart POST. + """ + test_files: list[tuple[str, str, bytes]] = [ + ( + "/testshare/Documents", + "report.txt", + b"This is a sample report for MCP integration testing.\n", + ), + ( + "/testshare/Documents", + "search_target.txt", + b"Bambu Lab X1C 3D printer configuration notes.\n", + ), + ( + "/testshare/Media", + "sample.mkv", + b"\x1a\x45\xdf\xa3", # Minimal MKV/WebM magic bytes + ), + ] + + for dest_folder, filename, content in test_files: + _upload_file(base_url, sid, dest_folder, filename, content) + + +def _upload_file( + base_url: str, + sid: str, + dest_folder: str, + filename: str, + content: bytes, +) -> None: + """Upload a single file via SYNO.FileStation.Upload. + + SID is passed as a query parameter. Form data includes api/version/method/ + path/overwrite/create_parents. File is sent as multipart "file" field. + """ + url = f"{base_url}/webapi/entry.cgi" + query_params = {"_sid": sid} + form_data = { + "api": "SYNO.FileStation.Upload", + "version": "2", + "method": "upload", + "path": dest_folder, + "overwrite": "true", + "create_parents": "true", + } + file_obj = io.BytesIO(content) + + try: + resp = httpx.post( + url, + params=query_params, + data=form_data, + files={"file": (filename, file_obj, "application/octet-stream")}, + timeout=60, + verify=False, # noqa: S501 + ) + resp.raise_for_status() + body = resp.json() + + if body.get("success"): + logger.info("Uploaded %s/%s (%d bytes)", dest_folder, filename, len(content)) + print(f" Uploaded {dest_folder}/{filename}") + else: + code = body.get("error", {}).get("code", 0) + logger.warning( + "Upload failed for %s/%s with error code %d", + dest_folder, + filename, + code, + ) + print(f" Warning: Upload {dest_folder}/{filename} failed (code {code})") + except Exception: + logger.warning( + "Upload failed for %s/%s", + dest_folder, + filename, + exc_info=True, + ) + print(f" Warning: Upload {dest_folder}/{filename} failed") + + +def _verify_setup(base_url: str, sid: str) -> bool: + """Verify setup by listing shares via SYNO.FileStation.List.""" + params = { + "api": "SYNO.FileStation.List", + "version": "2", + "method": "list_share", + "_sid": sid, + } + try: + resp = httpx.get( + f"{base_url}/webapi/entry.cgi", + params=params, + timeout=30, + verify=False, # noqa: S501 + ) + resp.raise_for_status() + body = resp.json() + + if body.get("success"): + shares = body.get("data", {}).get("shares", []) + share_names = [s.get("name", "") for s in shares] + logger.info("Shares found: %s", share_names) + print(f" Shares visible: {share_names}") + return True + else: + code = body.get("error", {}).get("code", 0) + logger.warning("List shares failed with error code %d", code) + return False + except Exception: + logger.warning("List shares verification failed", exc_info=True) + return False + + +def setup_dsm_for_testing( + base_url: str, + admin_password: str, + *, + admin_user: str = DEFAULT_ADMIN_USER, +) -> dict[str, Any]: + """Run the full post-wizard setup. Returns metadata dict. + + Steps: + 1. Login as admin + 2. Create test user + 3. Create shared folders: "testshare", "writable" + 4. Set permissions on shares for test user + 5. Upload test data to testshare + 6. Verify setup (list_shares check) + 7. Logout + 8. Return metadata dict with credentials and test_paths + """ + print("\nConfiguring DSM for integration testing...") + + # 1. Login as admin (with SynoToken for CSRF protection) + print("\n[1/7] Logging in as admin...") + sid, syno_token = login(base_url, admin_user, admin_password) + + try: + # 2. Create test user + print("\n[2/7] Creating test user...") + create_user( + base_url, + sid, + DEFAULT_TEST_USER, + DEFAULT_TEST_PASSWORD, + syno_token=syno_token, + ) + + # 3. Create shared folders + print("\n[3/7] Creating shared folders...") + create_shared_folder(base_url, sid, "testshare", syno_token=syno_token) + create_shared_folder(base_url, sid, "writable", syno_token=syno_token) + + # 4. Set permissions + print("\n[4/7] Setting share permissions...") + set_share_permissions( + base_url, + sid, + "testshare", + DEFAULT_TEST_USER, + syno_token=syno_token, + ) + set_share_permissions( + base_url, + sid, + "writable", + DEFAULT_TEST_USER, + syno_token=syno_token, + ) + + # 5. Upload test data + print("\n[5/7] Uploading test data...") + upload_test_data(base_url, sid) + + # 6. Verify + print("\n[6/7] Verifying setup...") + _verify_setup(base_url, sid) + + finally: + # 7. Logout + print("\n[7/7] Logging out...") + logout(base_url, sid) + + # 8. Build metadata + metadata: dict[str, Any] = { + "dsm_url": base_url, + "admin_user": admin_user, + "admin_password": admin_password, + "test_user": DEFAULT_TEST_USER, + "test_password": DEFAULT_TEST_PASSWORD, + "test_paths": { + "existing_share": "/testshare", + "search_folder": "/testshare/Documents", + "search_keyword": "Bambu", + "writable_folder": "/writable", + }, + } + + print("\nDSM setup complete.") + return metadata diff --git a/tests/vdsm/test_vdsm_integration.py b/tests/vdsm/test_vdsm_integration.py new file mode 100644 index 0000000..168cf33 --- /dev/null +++ b/tests/vdsm/test_vdsm_integration.py @@ -0,0 +1,46 @@ +"""Run the standard integration tests against virtual-dsm. + +This module re-exports test classes from test_integration.py so they run +against the virtual-dsm container instead of a real NAS. The nas_client +fixture in this directory's conftest.py provides the virtual-dsm connection. + +Run with: uv run pytest -m vdsm -v --log-cli-level=INFO +Override version: uv run pytest -m vdsm --dsm-version 7.1 +""" + +from __future__ import annotations + +import pytest + +from tests.test_integration import ( + TestConnection, + TestErrorHandling, + TestFileTransfers, + TestListing, + TestMetadata, + TestRecycleBin, + TestResourceUsage, + TestSearch, + TestSystemInfo, + TestWriteOperations, +) + +pytestmark = pytest.mark.vdsm + +# Re-export all test classes. Pytest collects them here and uses +# the nas_client fixture from tests/vdsm/conftest.py (which points +# at the virtual-dsm container) instead of the one in test_integration.py +# (which loads from integration_config.yaml). + +__all__ = [ + "TestConnection", + "TestErrorHandling", + "TestFileTransfers", + "TestListing", + "TestMetadata", + "TestRecycleBin", + "TestResourceUsage", + "TestSearch", + "TestSystemInfo", + "TestWriteOperations", +] diff --git a/uv.lock b/uv.lock index cc707fc..accd509 100644 --- a/uv.lock +++ b/uv.lock @@ -121,6 +121,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[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/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { 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" @@ -305,6 +394,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -569,6 +724,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] +[[package]] +name = "mcp-synology" +version = "0.4.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "keyring" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] +vdsm = [ + { name = "playwright" }, + { name = "testcontainers" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "keyring", specifier = ">=25.0" }, + { name = "mcp", specifier = ">=1.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "playwright", marker = "extra == 'vdsm'", specifier = ">=1.50" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, + { name = "testcontainers", marker = "extra == 'vdsm'", specifier = ">=4.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, +] +provides-extras = ["dev", "vdsm"] + [[package]] name = "more-itertools" version = "10.8.0" @@ -644,6 +847,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -788,6 +1010,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -969,6 +1203,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 = "respx" version = "0.22.0" @@ -1154,46 +1403,20 @@ wheels = [ ] [[package]] -name = "synology-mcp" -version = "0.3.1" -source = { editable = "." } +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "httpx" }, - { name = "keyring" }, - { name = "mcp" }, - { name = "pydantic" }, - { name = "pyyaml" }, -] - -[package.optional-dependencies] -dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "respx" }, - { name = "ruff" }, - { name = "types-pyyaml" }, + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, ] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "keyring", specifier = ">=25.0" }, - { name = "mcp", specifier = ">=1.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, - { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, ] -provides-extras = ["dev"] [[package]] name = "tomli" @@ -1279,6 +1502,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[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.42.0" @@ -1292,6 +1524,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From 06d190d66204a7a599db173da1eed8e0d1475655 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:40:35 -0400 Subject: [PATCH 2/8] Add project logo and badges to README Adds centered light/dark logo using GitHub's picture element and standard badges: PyPI version, Python versions, license, CI status, codecov coverage, and download count. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index ac75ed1..acbacf9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,20 @@ +

+ + + + mcp-synology logo + +

+ # mcp-synology +[![PyPI version](https://img.shields.io/pypi/v/mcp-synology)](https://pypi.org/project/mcp-synology/) +[![Python versions](https://img.shields.io/pypi/pyversions/mcp-synology)](https://pypi.org/project/mcp-synology/) +[![License](https://img.shields.io/pypi/l/mcp-synology)](https://github.com/cmeans/mcp-synology/blob/main/LICENSE) +[![Tests](https://img.shields.io/github/actions/workflow/status/cmeans/mcp-synology/ci.yml?label=tests)](https://github.com/cmeans/mcp-synology/actions/workflows/ci.yml) +[![Coverage](https://codecov.io/gh/cmeans/mcp-synology/graph/badge.svg)](https://codecov.io/gh/cmeans/mcp-synology) +[![Downloads](https://img.shields.io/pypi/dm/mcp-synology)](https://pypi.org/project/mcp-synology/) + MCP server for Synology NAS devices. Exposes Synology DSM API functionality as MCP tools that Claude can use. ## Supported Modules From 994e343d990da29ea876db22cc9fd1a3bbeefa7e Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:53:06 -0400 Subject: [PATCH 3/8] Add footer logo with theme-aware rendering Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index acbacf9..def12be 100644 --- a/README.md +++ b/README.md @@ -266,4 +266,8 @@ Live testing against real hardware revealed behaviors the specs couldn't anticip --- -Copyright (c) 2026 Chris Means + + + + + © 2026 Chris Means From 03431d9c7a9d80da8cd500af643ce4fbfcd15184 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:59:55 -0400 Subject: [PATCH 4/8] Document transfer tools in README and CHANGELOG - README: update File Station from 12 to 14 tools, add upload_file and download_file to tier listings - CHANGELOG 0.4.0: add Features section with transfer tools, icons, TestPyPI workflow, and vDSM test framework Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 14 ++++++++++++-- README.md | 6 +++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaa4f9..18af001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,24 @@ - **DSM session/device name** — `SynologyMCP` → `MCPSynology` - **License** — MIT → Apache 2.0 +### Features + +- **File transfer tools** — 2 new tools for uploading and downloading files: + - `upload_file` — upload local files to NAS with overwrite control, custom filenames, and progress reporting (WRITE tier) + - `download_file` — download NAS files to local disk with pre-flight disk space check, streaming writes, partial file cleanup on failure, and progress reporting (READ tier) + - Large file warnings when transfers exceed 1 GB +- **Project icons** — light/dark SVGs, PNGs (16–256px), and favicon.ico exposed via MCP `icons` parameter +- **TestPyPI workflow** — dedicated `test-publish.yml` for manual dispatch; `publish.yml` simplified to tag-only PyPI publishing +- **Virtual DSM test framework** — container-based integration testing with golden image save/restore, Playwright-based DSM wizard automation, and Podman/Docker auto-detection (`tests/vdsm/`) + ### Migration A migration script handles config, state, and keyring automatically: ```bash uv tool install mcp-synology -uv run python scripts/migrate-from-synology-mcp.py # dry run — preview changes -uv run python scripts/migrate-from-synology-mcp.py --apply # apply changes +python scripts/migrate-from-synology-mcp.py # dry run — preview changes +python scripts/migrate-from-synology-mcp.py --apply # apply changes ``` Then update Claude Desktop config: change `"command"` from `"synology-mcp"` to `"mcp-synology"`. diff --git a/README.md b/README.md index def12be..5a4e65e 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ MCP server for Synology NAS devices. Exposes Synology DSM API functionality as M ### File Station -Browse, search, move, copy, delete, and organize files on your NAS. 12 tools across two permission tiers: +Browse, search, transfer, and manage files on your NAS. 14 tools across two permission tiers: -- **READ** — list_shares, list_files, list_recycle_bin, search_files, get_file_info, get_dir_size -- **WRITE** — create_folder, rename, copy_files, move_files, delete_files, restore_from_recycle_bin +- **READ** — list_shares, list_files, list_recycle_bin, search_files, get_file_info, get_dir_size, download_file +- **WRITE** — create_folder, rename, copy_files, move_files, delete_files, restore_from_recycle_bin, upload_file ### System From a124c7183585e8b4b262fe89fb9a0aee8cf23e65 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:12:04 -0500 Subject: [PATCH 5/8] Add new isometric NAS logo and generated icon assets Replace old icon paths with new logo design (isometric cube with drive bays, Docker containers, BitTorrent swarm, and media controls). SVGs in light/dark variants, PNGs at 16-256px, and multi-size favicon. Move icons from src/mcp_synology/icons/ to assets/icons/. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 12 ++--- assets/icons/favicon.ico | Bin 0 -> 521 bytes assets/icons/mcp-synology-dark-128.png | Bin 0 -> 7854 bytes assets/icons/mcp-synology-dark-16.png | Bin 0 -> 532 bytes assets/icons/mcp-synology-dark-256.png | Bin 0 -> 15208 bytes assets/icons/mcp-synology-dark-32.png | Bin 0 -> 1465 bytes assets/icons/mcp-synology-dark-64.png | Bin 0 -> 3611 bytes assets/icons/mcp-synology-light-128.png | Bin 0 -> 7567 bytes assets/icons/mcp-synology-light-16.png | Bin 0 -> 517 bytes assets/icons/mcp-synology-light-256.png | Bin 0 -> 14818 bytes assets/icons/mcp-synology-light-32.png | Bin 0 -> 1387 bytes assets/icons/mcp-synology-light-64.png | Bin 0 -> 3488 bytes assets/icons/mcp-synology-logo-dark.svg | 54 +++++++++++++++++++++++ assets/icons/mcp-synology-logo-light.svg | 54 +++++++++++++++++++++++ src/mcp_synology/server.py | 2 +- 15 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 assets/icons/favicon.ico create mode 100644 assets/icons/mcp-synology-dark-128.png create mode 100644 assets/icons/mcp-synology-dark-16.png create mode 100644 assets/icons/mcp-synology-dark-256.png create mode 100644 assets/icons/mcp-synology-dark-32.png create mode 100644 assets/icons/mcp-synology-dark-64.png create mode 100644 assets/icons/mcp-synology-light-128.png create mode 100644 assets/icons/mcp-synology-light-16.png create mode 100644 assets/icons/mcp-synology-light-256.png create mode 100644 assets/icons/mcp-synology-light-32.png create mode 100644 assets/icons/mcp-synology-light-64.png create mode 100644 assets/icons/mcp-synology-logo-dark.svg create mode 100644 assets/icons/mcp-synology-logo-light.svg diff --git a/README.md b/README.md index 5a4e65e..19f329c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- - - mcp-synology logo + + + mcp-synology logo

@@ -267,7 +267,7 @@ Live testing against real hardware revealed behaviors the specs couldn't anticip --- - - - + + + © 2026 Chris Means diff --git a/assets/icons/favicon.ico b/assets/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..13813f74a7bbe643076f8ac953fdd770d4fc0082 GIT binary patch literal 521 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 literal 0 HcmV?d00001 diff --git a/assets/icons/mcp-synology-dark-32.png b/assets/icons/mcp-synology-dark-32.png new file mode 100644 index 0000000000000000000000000000000000000000..1f2fe072d3af372acdbd6b25b80df5a4346631a3 GIT binary patch literal 1465 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 hU%zU6P2j7>{{l>@KZ!42~y1PXQB?W%_egA*Y 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
Yg=#Cupc!#JL(O-T;DnVi*%$n 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 literal 0 HcmV?d00001 diff --git a/assets/icons/mcp-synology-light-16.png b/assets/icons/mcp-synology-light-16.png new file mode 100644 index 0000000000000000000000000000000000000000..3a307d0b92120ca29e398f66356b98da789f4abe GIT binary patch literal 517 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 literal 0 HcmV?d00001 diff --git a/assets/icons/mcp-synology-light-32.png b/assets/icons/mcp-synology-light-32.png new file mode 100644 index 0000000000000000000000000000000000000000..6a68011d8a27b2b234035bc45447bf895eab9b8d GIT binary patch literal 1387 zcmV-x1(f=UP)}>u+X0 zXTE2Ca|W3i*3-ngW(NSZc2~>r<9G%Dhr3*fYmLISNR$%)Jgz5VyT9F)2$vpeCE&eH zUQVsfSp`tne72g+qeyiIpd(YDOsen5*dto}WjIVbt2r?eo zuDo3C!vH5oQwBcLh^^k9`5oM!xzqTr*1zvHw%D=I^E^j0U$3icy#QB;V>gj;0U-~GvD4#zy3ElJ*HF3 znz*j%jg4-}ksqB6@6_#lh+0(hXn*d+NE{nD+MjEE;@*@%1~@&I0^mSV-mK|41G5?A zB!=2}(glD@AiumBRTJ~hCU;Sm1&IdhHq2;CK{gD1bvp?&__)vQD=d|gSw;kjc&Bbo z-Or4r#6n+DZZFA~%TAc_H^WJo(P#Z$L5Y!wcDJa3qHF?~Pe%a9ZpV`e9c*{^cQ{vP z^sZKCvFVQuMu8ODT9g7n!9R><1pIk_j)DIUZP^ouunJ(&z8^_0&DfnK^=^;%*rzjN z>BVVNDd5}PUS#-2%~T_5bw!(xffCIDlQDJX#FLocALuhE$k|*jZfV3wa^?Nj1z(nD&Dq|oQUau zOtq8};*6jtFS~N$Uc;0EEDSK)Mo#>*jpjcKgV%VnD3`jK|hNh+^L8W7!i7aB4K&UXne543^EcYRuZQ zuW)NY#g=Y{G~6%<+!r|Ad9kG^$6{&@2oyL%M1}>zRKl49u7vBe=DU08=7M;$%bj;? z^RSXvvzl(zj6x8|d)n02K-4UcMm8fZx=Zq|5PC;R5itS8h7my%H!=VK2`~}Uuhnec z%@Y4LW2!M{#Gb@gHi)7Fe@vMx)5Z`4>gf_y0$B;<#4Rf*hXX%QL1oF9T<(hy{uA60e8GE}Ps12M=~QpKkHn zU8rSLbY#Ok>V?Su6Pm;_BD8q8tK?Y5lOuYlt_4HM!Mk;nf7E*=pJ_aHzxa zSL(G50LCjj%75&txp`*`IE^2=O@ycjQdNnJ0~9l zFoU)n8!EV>>|tV|#liP0e$!QBl}G>5LvyQUx_q&_O)UZ>05Rt!CIm0GsskO)Gcv4c zZ5f)g7pHVyq5+spsMW!S;nzBx-7TxHRcivw+jvaJhvJO*=s>W_v4{|%YyANK!`6Mf{tpk`;?zlX;LQL4002ovPDHLkV1m*mmp=di literal 0 HcmV?d00001 diff --git a/assets/icons/mcp-synology-light-64.png b/assets/icons/mcp-synology-light-64.png new file mode 100644 index 0000000000000000000000000000000000000000..28b716c1a3e32223a6bcd37c4a49b7a4abf73b3d GIT binary patch literal 3488 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/mcp-synology-logo-light.svg b/assets/icons/mcp-synology-logo-light.svg new file mode 100644 index 0000000..eff2cf6 --- /dev/null +++ b/assets/icons/mcp-synology-logo-light.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mcp_synology/server.py b/src/mcp_synology/server.py index 4c43cf3..54d72c4 100644 --- a/src/mcp_synology/server.py +++ b/src/mcp_synology/server.py @@ -36,7 +36,7 @@ def _load_instruction(name: str) -> str: _BASE_INSTRUCTIONS = _load_instruction("server.md") -_ICON_BASE_URL = "https://raw.githubusercontent.com/cmeans/mcp-synology/main/src/mcp_synology/icons" +_ICON_BASE_URL = "https://raw.githubusercontent.com/cmeans/mcp-synology/main/assets/icons" def _load_icons() -> list[Icon]: From 6bf3cbdadac4d3f3b87ffab0ca9b8d8632e14598 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:32:25 -0500 Subject: [PATCH 6/8] Fix QA findings: stale tool counts, dead icons, silent exception, uv version - Update File Station tool count from 12 to 14 in CLAUDE.md and project-scaffolding-spec.md (upload_file + download_file were added) - Remove old src/mcp_synology/icons/ directory (dead weight, replaced by assets/icons/) - Add debug logging to pre-flight getinfo exception in transfer.py - Bump setup-uv from v4 to v5 in ci.yml for consistency with other workflows Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 6 +-- CLAUDE.md | 6 +-- docs/specs/project-scaffolding-spec.md | 4 +- .../icons/mcp-synology-logo-dark-128.png | Bin 8228 -> 0 bytes .../icons/mcp-synology-logo-dark-16.png | Bin 650 -> 0 bytes .../icons/mcp-synology-logo-dark-256.png | Bin 15761 -> 0 bytes .../icons/mcp-synology-logo-dark-32.png | Bin 1656 -> 0 bytes .../icons/mcp-synology-logo-dark-48.png | Bin 2779 -> 0 bytes .../icons/mcp-synology-logo-dark-64.png | Bin 3810 -> 0 bytes .../icons/mcp-synology-logo-dark.svg | 42 ------------------ .../icons/mcp-synology-logo-light-128.png | Bin 8035 -> 0 bytes .../icons/mcp-synology-logo-light-16.png | Bin 648 -> 0 bytes .../icons/mcp-synology-logo-light-256.png | Bin 15378 -> 0 bytes .../icons/mcp-synology-logo-light-32.png | Bin 1632 -> 0 bytes .../icons/mcp-synology-logo-light-48.png | Bin 2723 -> 0 bytes .../icons/mcp-synology-logo-light-64.png | Bin 3759 -> 0 bytes .../icons/mcp-synology-logo-light.svg | 42 ------------------ .../modules/filestation/transfer.py | 4 +- 18 files changed, 10 insertions(+), 94 deletions(-) delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-128.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-16.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-256.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-32.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-48.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark-64.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-dark.svg delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-128.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-16.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-256.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-32.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-48.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-light-64.png delete mode 100644 src/mcp_synology/icons/mcp-synology-logo-light.svg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ab311..bc528f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v5 with: python-version: "3.12" - run: uv sync --extra dev @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v5 with: python-version: "3.12" - run: uv sync --extra dev @@ -39,7 +39,7 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - run: uv sync --extra dev diff --git a/CLAUDE.md b/CLAUDE.md index 5788693..d1309e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mcp-synology is an MCP server for Synology NAS devices. It exposes Synology DSM API functionality as MCP tools that Claude can use. Modular, secure (2FA-ready), permission-tiered. Python 3.11+, async throughout, Apache 2.0 licensed. -**Current status:** v0.4.x — File Station (12 tools) + System monitoring (2 tools), CLI, integration tests. +**Current status:** v0.4.x — File Station (14 tools) + System monitoring (2 tools), CLI, integration tests. ## Architecture Layered design: core → modules → server/CLI. - **Core** (`src/mcp_synology/core/`): DSM API client (async httpx), auth manager (session lifecycle, 2FA, keyring), YAML+Pydantic config loader, shared response formatters, typed exception hierarchy -- **Modules** (`src/mcp_synology/modules/`): Feature-specific tool handlers. Each module declares `MODULE_INFO` with API requirements and tool metadata. File Station (12 tools: 6 READ + 6 WRITE), System (2 tools: get_system_info, get_resource_usage) +- **Modules** (`src/mcp_synology/modules/`): Feature-specific tool handlers. Each module declares `MODULE_INFO` with API requirements and tool metadata. File Station (14 tools: 7 READ + 7 WRITE), System (2 tools: get_system_info, get_resource_usage) - **Server** (`src/mcp_synology/server.py`): FastMCP initialization, module loading, startup - **CLI** (`src/mcp_synology/cli/`): click-based package with `serve`, `setup`, `check` subcommands @@ -25,7 +25,7 @@ Modules are domain-split: `listing.py`, `search.py`, `metadata.py`, `operations. - `docs/specs/architecture.md` — layered architecture, module system, auth strategy chain, session lifecycle, credential storage - `docs/specs/project-scaffolding-spec.md` — repo structure, pyproject.toml, CI, testing strategy -- `docs/specs/filestation-module-spec.md` — all 12 File Station tools with parameters, response shapes, error codes +- `docs/specs/filestation-module-spec.md` — all 14 File Station tools with parameters, response shapes, error codes - `docs/specs/config-schema-spec.md` — YAML config structure, validation rules, env var overrides, state file ## Build & Development Commands diff --git a/docs/specs/project-scaffolding-spec.md b/docs/specs/project-scaffolding-spec.md index ee9e9eb..f9cbca6 100644 --- a/docs/specs/project-scaffolding-spec.md +++ b/docs/specs/project-scaffolding-spec.md @@ -52,7 +52,7 @@ mcp-synology/ ├── docs/ │ └── specs/ │ ├── architecture.md # Layered architecture, auth, session lifecycle -│ ├── filestation-module.md # File Station tool specs (12 tools) +│ ├── filestation-module.md # File Station tool specs (14 tools) │ └── config-schema.md # YAML config structure, validation rules │ ├── examples/ @@ -419,7 +419,7 @@ MCP server for Synology NAS devices. Layered architecture: ## Architecture & Design Docs Design decisions and tool specifications live in `docs/specs/`: - `architecture.md` — layered architecture, auth strategy chain, session lifecycle -- `filestation-module.md` — all 12 File Station tools with parameters and response shapes +- `filestation-module.md` — all 14 File Station tools with parameters and response shapes - `config-schema.md` — YAML config structure, validation rules, env var overrides **Read the relevant spec before implementing.** These docs record design decisions diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-128.png b/src/mcp_synology/icons/mcp-synology-logo-dark-128.png deleted file mode 100644 index 522c33004c8ef071f1c8553c6e4e15b9376dd960..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8228 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 Ul3Ua5M}!|xl2endlr|6j9|kg_y8r+H diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-16.png b/src/mcp_synology/icons/mcp-synology-logo-dark-16.png deleted file mode 100644 index e2734ae3a5afe381aed662711c2396b1c0025ee1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 650 zcmV;50(Jd~P)wWKe&zWfviJFGcvz=#t4A0Dvnc;urE-Nf=^sXV(EC4`k|MmLN;YB_=Kf&FX z{W%wP%nD0dxhk7hWg#O-JRFk#*mx8^UkF&)jEhNm8fJa9?jb6G3p2GcLD*}oNVrkL z+4WMS(_ZsCCxDgb`7yL^bb9t8l$HQM8N^0&@Br610QvwLs3Vunl2?^-+*&W6{LVnD zkKHouH?4T6WZuEQrEK-F*L_ZA3=&vt#DXptK_)V=om7s`gxDDHRHo`F08qjmb<_%7 zd<)|)_6BjWXcazpbB9@H64@}oX8|I-;9_H@E~=jqaT@@_Q2C(h+m!cwVEr^k&JBdI zpAv>Z=hm2#HO(u#Sd5b7c>!00T<=NnUX=pH)Mf7%2FZHt^4Dx00Kk6$p=`Yi%pDUA znw?W|OJZ^pN+g*^!7!Hiotc_2)MpQgxzSBWdy?!>v@uZ@?nbKnQNc0SHY%ch_EIMB z5u1549xs@>ClhS78{18#Ns-okF6vG{b#`gsc389vh7s9-lBlT7CgvlsdlV}bP^RECXUnM+-+eoQa%>oQ+vJGHQ=P_FTiOK2U;?trWCf*UJYNa^b4nTe>2{y k4fbZOcz>DapMb0A7yXMIc<=a?ApigX07*qoM6N<$f|1G|kpKVy diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-256.png b/src/mcp_synology/icons/mcp-synology-logo-dark-256.png deleted file mode 100644 index 0009bb67ed76389531da5a0b89bc69db89261ade..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15761 zcmch;g;!MX_Xc`~?(S}+K|s2tq@{c4?v@%Fl#njzlm_XRM!LHj=>~~ApYQMf3-_`X zGpxhA-#$B@{p=`JWm$9-5)=Rc(BAC>`qRD^1 zpyb}>M(9IgcWE7Wbtg-AFB4Y_z{|^v-Nw<*&CJBvg5Am0D)UU31OTW2`HvDB-ak&W zd>~{N4R<*Q?V4-ci9kM;k+iNfHXeRx4T&enYZWU=lK>xIdTOLYyW?Kn*#aBi#Y`Ow zA0Iok=3^rr6%N)%2`sGdns=k_+8qavuW3i}jOp?WA~P%QFRumVZO86uU!E%6vhT@0 zNlHjaIGLVeFF7%e{#<}5G7fo&g9vJvhusE>%p=327$g{g3n0eaW!iVI`kNtqHw*8RPFAgeAsWH zJAISdudlo-+vzEYx|6ObptOB^f%_IhT{KEsAAZQ3UA00py5$B4Xbw#4WALE#m7M-b zu|2#nk$tHuw9Rgf9tJ3?H@3Ie1puNL+fu-jEM_EM^JNn21Ut9!^J)&9Ju)gKbLG86=o~-X<##&%a-H$UhN34;4a|h@BFNMNShAbYE zR~9y#5nw+tjiz9r3jQMbQ7D;%l^JnOg&Hs%;t_prjW)yJ${q9R3v zx13l{&shi*AeW9-T1?QBQO<3a_ny75K+yuLwO|`10TPG%SF{$iVi4$_kS}d zA2sKGs?0AXbLW8eOQoJY<%BQ2m-`pmPRd$Zrb`nqIpi!43-Vzm<^P1pq04p1$9?~Q zLhNBceVX#vVbj-q*GXOIvS>20r*dp;yqW)gqjvBAunv7p41H{#ku}Blo|+4_ueFzB zk=rD4K}+g&5ifMZ@9Q54eI~eE3s?m=W2EupmsiS;Q~jU7h$-upx@A(`0Lm04-E%9x6q57#iKl zWiLVc7aM#C)91P%48s}+osKXH$Qq)2bx4{{RFjO>J7TfKO+8XwiQp-V<4j*#O=j(~ zCrWNK?g-y+?FXUDk*a!bVq}X$zrJ>ayO2V#FE$oeBtH;rN*BU<*5K+YGKI<2kWZ^%-r?N-V-zY`ex(g^-l|uQ#XC2El zt_D}g$2D+p0?6v;QoPkZNwlw)YD;MuOG2+AOD-0qXz^~^%9to|mjzfytf{_hE&PY} zB-7-5BZc!}qJTh6v=YZWlWP!n^b!G%G5`F((g(Th9S!JyBhOsJaB9gI zhf=LFZmIw>Ip=@hx&_H;aa9s{<1OB7p|U|XPoKZ6!Vx?niR}7_n#+6tO*>B>A8~uL zd$H@!4%wUe1;c41Lm3_@-YUQOej++ScH*`u1#zVyt;miVY=`pAwYRdu64P|?nFlpC z3;e-Ps_h_`Jx+ii+PBVh;&<9j5}-&|;6&|ToqG^->+@A*x9(dgs|xOFjy9e?@!OvS z^G8LFRHKtKuX>@BD4eqSlJwyxdEWKE7L;W)K590cf$y5=-zue|%dK;7-uIDJJow|X z1+rUBNT_IGe31mS1`2W*hB1`KDE(|7yxp!@ucwS_aC6j`~E735BYcrm7M z{q~HvHvEOtTM@*W&p>fcfy&NPiZd(_iS1>^nwk?4^m!=)pi1l@{%o1DO55)%d>(DQ zD!xJ#1j`jiJ|)?n{XJAu*phyj0#n{*%{tw;DzLGf_A`eTMsYGwKT|Nl6OBSuTt`8F zBoheF_VlZjMcR0BNEI%`@uVQM5IAc} z^3rB`R3B^Gyx6B2+3wN0L+1PyWA0Zpb=a~&`swvR`CeHu^hiVC&>M6ZS|v0F0db(j z|7B5{#}n7xv(>Dy5xQTl6?i8N4xLCDye9ZWn!??_~e36aF$i(#;&xSBg~<7d5p!Aq`8AW)~d`n zU6~vbiwjZybJ&!jp>_B93E}qw01Q0W@^`~~H}+CGgpYiw4qsBB_o8_g*q`%VCfCwG ztotJ&izKdXexr|suSI`-%ZhBbt2r)JHd^82P@q?8R+t#&Tct=+H#<946JLTTBYs3Q*qw{5tzoh-kiwxUiTND5xeFOM1bVx5hd2HcvfP~ueCAw>j#p@g)`)a%HV z9TSMwfjyrFhmEXQGiU&PV>KUC2rgh>)0b?pfleLJH2twxK|(^r=M&2wXR0fhj>+l+ zw^a(K60X*>JeMeWLJbP}-%qt46@p83RwOICJ#fJPiYD0dj;pSVnde=XjIqr4g9m7- z=3@l94T0EApuo6QhT}#S9v$ zy0IC?Dt-t5nZ3l0X<#R2Ra)|843jvJSBK4zi=I&LYq1E-XzTlxA6M}fsO6AP9uf2p zZ0usoZtL?&0Ls9}pudInut0_Jn_yI1-Kt6i4}Dh1RZ>b4@r64*dQs$-Z5fO3SYfbmmfSfJGYJ?#usdr#Ce!loYL%U{JQcu^baetu-pP>zkL zp|9<$4lw!`MBwo4QK(pHII-a)K*O6kJ~nov8)?Um4R}|hz9k<#?kOP`g1PF@r;`Se z6h=je!o=wZoPU%*#YO#Kmrs~Ww%`ckDx#s)XnS z|91ldtNE3%WuMf(H&t@7h|()!22LyXq-C?1IG29NcudCFfrjbWf#=U z7%CRGoFB$(ao4~je7;&5qQ)_4MkJs%o?2 z69Y;~atL;2vIl=}?;|FF|CJOubE9qiTzjSse7q-hw{~naJl%JqcW4`aUu}?TGj->f z%oo{ed9x9cA-{?HQqz|3`9UiXt-PLP;0SJwFRgn8vgLOgx}nc0`hoT)HQ^$R2RX3h zk{_uzt}7IH*1Yht957_DOk}73KxWp)Za}*qU`2tr@dL!+_2ffk+eKi3;(K7Rnq7D| z;+jykwy)Yof?b5%jt+o_`!@7zSYhSsEE7U0!s*OSqK!|9y)nPk5-X_UU1S7zqP zZ$9W;>vN}Qf&Y45qtlyV2Gp#4$tQT>{1(E|lp6v>FNecdN*!0!<2ka!pRKIF6G{ay zulhAFZ`TA0od)Ck510i=5M0wYmLMe0qo>#-URgwON537d)1Ysi7V)H~roBjZTMMX6 z?o8cmq;H{5dDr#tKRlEK#}YtDr1!wvbmNfp-ma&i7S58l4o}u^OYqLmv7PousqEf2 zsN7)UHXFq&M4A`lARS72d&2L^xfz`PN3+oZv^@PNtE|xDM+E=FI@!~Bg!{UJ;Tt8x zj95pj(Gzw=_3Gw~^6$t4)kZG+AY4hHkP&{BWKT-Vt2$FkVA}`9;5*Hd*62J4VD8{k z_e3xJXV0oX!!7 zxuG_h$rCya&S=7@LhlPB=Y!>9H%ZW|MMwv%BucCn#(MlD0bE#|_SMO&=irKcJ4~HD zsa$7O^qc8T7u&>of2psV#@~z(Ek;lB+datHb$nO>;+M5Y^Vv0p{d`vdr~xV9%#dXFi}_MGk*+iq@qSGaYrF41(Y30zLpng2Q6uN0uvO!0lyyY&@{;p> zL2(UXM@dsop;b>iPNDc`0;*o80$^4PF8Q-6v;k7V7LFVXBl_p_scSo7c|>g12D&9s zRLAtyq*#zT4@V>W1vK!q;Q)9C*4(P z0FGd!Enb_?%ZzlxL3v4MdXl1M%+)I_#4j?3A|Rr?L}tV&Zn$0rYlE)WxGs%tgeW@) z*@s^TmyI(2CcQ}JzKiW&34y3Aym@~*g!=LJjNCL!WGCJKKpAnVwzwh9vx3jf_SC|F zI@RC7P8l8aL|)g0PLu^5WW8On_aCqH?vGZ)llzJlx-2JA%c6{C4+Vsn3Mk)?D;gj& z_ON95SIR(x^jJ>~4AbGppt%^?5JqZw&v(>0L5EB3=f$*?ZPCOvS9@bn*D%wsQbSM> zt}CH?ic0YPumMsxQGLDZcf0h$f~1o4B0Ht0)iv=Ri;89Q1g0kBTe`#g=m5wEMT0?j zXN=GexJqY4OCcsWut0sjE7;OZG_du{P799)Vzry|?1(sn?&x(n*rlacL5QuuX^Ky! zNPWx&6g7tQLZp^+9m%5a@*;EeXH1-JkWhpmNGd0Q;qk(@JS7%%iyaKBpo#H{Bl4yZ z-EL8&TgK$@ssPw5@U-9m z#sSxKN%Tqgk2>qLzRMszHa)@#Xl4s*o}pfRyVlj|(ffQDih+fEWP37+e%P9J!w6?V z7uY^m^;5)NmvNwS?RkiCFWq zLt{l-nzt_qFHS5Dtpp8?68~mS3_~$n{Mc<$n`=r}NFc0A)PQ>5$2NR=d@>;(bW$rt z_^f#kM(@Q%EKH!5up5ysoA%xgZhtJJbj}t!wyD-^<-6jJxk_sqcfAGjZ=F7z0Z2m| z@3t_IHgFoSX7;M83%r-@2@LBGat+-RWOKw5V4M^TyU=%av9y!7-#$GhgfEq@lk&tb zfP>uN@4{2~Ef=|iZ>Ot(>bm>^)XJax~w^#g|HF#wu1Q zLI%wI{NIrD6|Dmckm`wnfoOj9E;N;qd;A3RtT<%^M_1Pz->>y8DjgQ^ou_&#Nt zTuy(4d4(}yClAE)Pv;P?Q<=m7ybxq z9D%++Zl|&3;=E&lQ=W<%eM7XFLJ>^On$x){#1bX`+&KXrU6?*9HCs^EH;0(xGY_wg&_zH;UYB` z3f~|uR?}Xp{@2(?*pvG`ybD1G14gyhmEgv2eM_9e%nMDXHP>Vx`jp2XVkBgd0dy&~ zB}|&>U+PDd`Tm^w=eSI_EYfd!j>%mrn>;fK%0J26HDm%UDqB59{30_!rEAp+tk?e0 z7OzO8T#M}A*QvHKh~5~BnF~;4Qx>J$G)dN68)yzjs^(hk zgyZ1LtOdex>z-RSnuK~hr=YMTqs0v}9dZq!K?GzK*p_5^Gaf#J8E|apO1BUV>Vs)a zaN!WQ1GOW8WUYF#)~@`-umueO0@^Zdfj;qp59rR1TOePaj|3>pjG8V#dV7|G*mE zM%4*l?d>8Jl)ybA0hMlDbb*rJWG@DbZ`NFY;ORX5sC_mZdzY0hcKuQ1W?DbvClt*6 zEYqb{&JLh$JTH3hibc>FNZpV8AY0eqNpbvc2WRTl?S*9@Pc#u_<64}b?Wl1WT#u0( zV(lliis1>x5PwQe!p6_xGX0Pb_4>Z0!&*j`rSpcV(|=!Qei7w4xX7;6_`jEcs#0np z0MZfBhs$mVEEqWZm7h&VHV4BPwqlQ$OQ3(<(SRK_LO4_cERns%r;*?P(={)p`oQ2G z-mDwxi4>UytYhZS{$%Q@!iSL1yt4IC_PEDN`*PbL{tTtXz0E=2Wjy%PTBWk0p;k_o zWacAOw8awqLAT8HLYjzddbH^M)BX3mKw=NWa#M1p6J()oZ_K=zWHSmR<0V-~X+w_9 zFS+a!U96<(E-K>H@Xc70@pGYWM;f#&WW+juc=cxynKJ1&G5D1|4>^8&FMrCZk#_HZ zy62rkM+ekb+SH5e;6+SD%ugZ$lxmFTA8r;rBNo}2^IXx%R#Ika;s>XrfF_EcWN5Q5 zl?t;lkU&v0S1*NVAzCfN$cPZX+cZOulJ8J8vD->j6?@}Pwq*$c)3Usf*<^rK_60S% zza+82UBC~&1)01sDV;W@=@rKTvq*nvw)@X66K1EEfc3b(DuvlDg{t3T4MOdnpNF@} zyEn6If_QNbE4F-mAVd1Xzi2ylEo7hYE(e2|2H?9f(hR1VhU;L)bvd5oYB1KsNODoKcXQfX2k+$Ox zy=8yx=%)V>HFts4zG_b=D$s7bt?5wwXoTU!j)v9OpK}_U$+KD}A8{|fHS~z6Y{eL7 zC>bcrM5@kp#_AS<7e4E863;=cWp+PDipVB_fQ12z74H6SOoKV~f-4)LuqN{Qyz(;~ zEkoBUt+lOPI#DG~ z*OxouR>J;=e~~KRKKvq6x5Z*MmxVS~b3DCBY3lcl7&GAl2McxgNd<(d>~NDM#faS9 z0T_ej0%ab3rk-Jj5cOS5xSNxVGhr8Sws=1-@_D~!A4rkjkmV3AYGz$=o(o1bX5wYsI+JLG5~kM#`Gg8YIEXbN z86BsxiKiZnUpfCcl4k}7&2zps+g*4kjWCwzUP*|{>Y+Im^x$(n99=x;RDPxZ;>j5K zmY6`vP;>uon@;LT=hszJfrXW~k;CwD;Y=Dz`PrXIv~owB9Xn(pqCRDqvk>Q2g9TI) zzAP0fss{vMMd2Z{5S8J&Loh5B2cpKDEKST@LL(obBWdH*B8t5lfKq&I5YUSCHeIGV z^!yv?hujg`dmlz@Q9|LDXkC6KpS^nPbr(|Mj3|i>{O>~-A{MmJx8tePxV4U%iqqktjJ;30bs`uQwXfSLoHjHUp1|k8*WVrZj>%qo05oz;U;GY*b))fU zzxti2hcn2NuU^s03xeUw>}H^?h6$y6hL07XAqXB>j*_c_d0;nR)J=#I-q;dz-|0OB z7pM**0f2ZbX5T%ieSxT&n|5E7u;|YW}Jb*=&P&zaEbCQ5h`sl`6=I3O>&liON@{W3_?2 z3&0bRmx@iV+y--2ttO3ND`d!2!NE0Xly9k+vom7c?K9~EH~NM|(bctWBsRcY@e13+ z6&2w`m4pFv(rf+zVR~zj5<~2uPjr7LH&h+d*C4*gN_B!dC_q2nTuZ|2dvg*UvNuhv zWr*^3x`r0K0x-`Z_errEvJX-sY48uY=Jd2GHkXGw(5;1mb0&f|kf|feV*H~%OzOg@ zM$9;cIkR9JnQpGEy1bvcvQod8eHs@N*rC1ny`wPi^%d)m8AL7o4{bp6u*VgH6iNezrK`$J#KuAZd-J4U&L0<`t&Jst)nS&5N6>-T?q| zod380aa!t`Bka5gUKPMB);28m1BSstMi?LINtbO_AdItZKVl9XqzqF8b#pefXeOpH z&YY4P)rqhNpDix^)yek6O8eIp##wT?9=ZC;AyK*G&WN8NjzVp6w+K}`-0U3ooe`s> zO>>-h#N;ImC(5H!Y7Vhs7ZQOdqK{lstfIpvE8WttF0U^Z;Hnux_1vsi%WZj+ykWb^ zC@7x++XIjMj{rsZvL){ie9=|2$YEOs^mLVN)5Q{gn43B!@BkN0$}jJx&m0a>-Ko}S z0ouv>q4+mu2w@LDX{IZzHK> z?#r?4Tl(nj+A09orVQAAWD4Pt?Y4;O(V|+fp53P>OM{*0iZB1-f7+p2@toH&#ojwc zb4FcL31*x;S^SvSmYL3OGv{BGrDe!X!T%nCjHvT6r@%eAjh*rFu$e`2d0FO^##vuA zPFzqRBkKT3DrWr-w3WR77=$6lr+3(l;{r`Dp~HOrs-T6DW{^O$T9Y3moFEi*ic=VI zFcg51OYe*EP7AaO3{skS!xt{WiIrj_LD?oIEWi{@^ZtAM@z1L%BLxwe26zoLpoJ5V>VT$+hE(AxAd5EC~Rh+!f=wMV7bFC zXTKyMo`-nXa{JM|UvKz9usuSLPNvG+iMks^WhB1!<>T9r#J6D8y2%!w5E~|dclYu) z+OzW4nuDu#ZRWN({`&w*PR?&ZvSCEZunC!2myEH`g6x5VL$ea1?=}ew4J8)lgFQbe zteeXbb~{V9`QobT;0+tM{&goNUC$D*o#UlITd2`oc)>%=yds*1+B^pZp7;L%oA1xI zsq94$yWYO=f-yF1084%nT04i#3%#wgyQNWI{$r%KQ^HMGh8iXBVk&x3AaH3_x#pG| z`0ar_PmI5rzDU>k$4cC5n}zV$)o~n4O2fdNfo#em;x5U@-Y#1+9gn&8s#d7O!BCt^ zJ%cFc%LgejcsjjEvDR3!&<{Az&GcRF>YFQ%x$z^R}UxWJC+s zTVD**igjGI7kj2~CvQDLqWWGb(|>K-kMWNjNTPEI{81=DJ~&m37bL1HxX<=-7gLa+ zU$8FQss=p!ZcairHri`k6|IoiA5f=dUl7md-SEPnh{Qw~$KzFz0Nj!SIPo4uzz$@s z>+9G<`IREBV24t-{+UyA?8c|MR($C;^k`)a;`VB}(f4&^Tx= z!BLuyraN=f;9YMyk^|=E#XGX^%iT-q9D=R*F064{UULrNnqKZ-uwR@ZcItY-kq+{` zio?xmkr`a~^@&XQ#YeEKU}Q_AAjf<5E}LFFn(g0_&qD55JJjuAHD*$1Ib46mD%W$l zp)vHupP$)9V#j|~HjuYUb8kv=_mLmsS?rXS#;IVMK%SYqe@QPa<0Am;OrXBC+_6KG z@QN7VNUP9XpwZ^)^P2QVBIBkupxvUAn5nx~A4C!#KMuBDt;2+EqkSp?3$szR;7?A- zM2oUX3^3A(snhymAdywzQ7TLZYQ5B(DEzYZC=6W``}L+o;DeV(30HV+&|H)p7h1T6 zzGWoNVZUV|Yvc33oz~p8hk?2*O0PI>Wa2yPNt&xW27lN+He0mNJ~V(uKVRQNi|9cL<9FU29gQx|gXS(t~PB6Z7|yG{!W z;BvF|1lVKt(7!L{1^Wm8zT@5E``j*9dHf8!JLMm{nM#kQ4_ahQ-XDHC7~0|ci!N9t zeO=bmPM6-oW%}HFJMmp?Mr4ed0VPZc$L39~{7mkH-(F=auH~o7*3ulKLO(=gFUg0L zxEm1BIX4^*i81O0$m?yvyi>JgvKETIqb9dek>P&#!&Stjd&JhOi^Jnc3cJStWu=cj zh~tal=RFam>sm|Q5*B_w@5S=x+R6#hnLj%{Aggz#G*RLNWorVi^Gs5GHKD-J{emY{A@BtIAFDyh%qiH*1ra!>`#785|3*$uDmdRbhk zT(|?DE2HtsnM9dm?!RFvaH6oN7QIZW$UYqZ_M@ku@@rA!0kHWLv}Ne(M=JlAmh_yR zl+~GE*+DapSi?lt6mx=jNP$NBiQjnIDZ@1&4-du@t62y#2mcSfG|qB&Ek;y}ZW~Sh z{KFvtGlA=Z4w=DwiQjwKnYnO9X#+J19JeN-1RPvdSs`(M+*pbh8qo~CbhkQk%35Tu zu!GB$$;J?Y9sFsb5?Jy138Pswk<*78(&C!+H~PMTon(zXIS`-v2lfwN*r#BwNAsSG zg4xS82{iT{Z0AiD1TvIE|6Gca6B2Ab&|yZD6kJ5{)1&unnVpH11jB`2$=l>`1gRrc zo%fe1ME=$5zsHf-SoX4g&A2vFu@8x> z3BLf5xhOudRyDwu^;i7h4j0eAPHNWND>vH3x-II+%Pl0o!O;Mkt<#VEGzGEm2Hgva zPFtr4$q7gH5)+2*(^dxII?%hvVX7E=gmZbXv4p~6YmZ|!2+|hE0j-}xSJ8##hph&i zJ7&Y-kW~1cOkn(TUkIS=y5keeIeUa3`X36RV81(cAwxkW!UZ&eo^(Sw>@``>UhyE@ zn>s|n{vs?>1J8!l8U%aYM{{4Z1k7aK9HTFP<(|UzIWWBr=h+R-^P{c6hj|b;lR|V) z>yHx}*?+E4jl=wnz&bSgU8Q>SS6pL|IFKjfN0KvlWWgH$;F5RZjMmI8jH*hdD>(11 zVPEiTJx3Cj-h&*cqTE1J3&P)xc3Y`Hl$QDm@1u7>8|ftizazdo{*bI5dEkuBa@n^# zsQ*O=c1W*Ax8uNQLhEI7HxT@>dSj6zYU>ZD=me?}EiE_bbVAEoIJKhr&)#pjIOoCs zOVu#i7>lKQ*!CRM=9L0Hnzdpc>(#X<*sBJ{#(;Ryn6* zc43e>rB`dpp9l+gL^@w;W4r~CQl?{|OO`YMvqQv`#P|D*lI2c}FkPvS7C7rNi-E<& zv0>=XbXKAsft##)r20z zRD=C%8*w-4F3EAmff>K)`63%_Xvj89|vCGb@Dc@vqp zIh2KQM5iN=7ucI?ktck%lV|S3p+I=bB{y+jzG>%PXJ0pe`i0FLRldj4lcTug@O!np z+O72440v!5gqSVL%U-ajOh2SYxqwg11v7siDT9NG(hdG|rqRa}d0Se3Cz{(S+xH1S z?7?5+k%KlJ8N}c=%kdt9$e?+p86uy3(fW#Mm#LnuP=8^#fbOiWKlmJVA5H~wAB?_h z7b+;pRq1F+jE$D z*_LD@37NOG-v<^$ZM?{V_&`(Ub;(ckRhixuR$`@{xy9yln!s#&4clUjk<0usfNXq1G|7~@| zHsx%HdT8e=I@ovLA2qogGd|5lao; zsLc6Jrr9DWV=y+^Wb@06gcZoozD{dR%VW;aer`@V7d`6o2idSLC$D{L zS&q?1mr49;x>FoNf|%#Fq~aR|MXoR)TIw{tZT?ZVsvr^U1UmU7E37gtQu$Syf-<_> ziz*8>R*YKL<#66|5XYtjtC^XPceTICat{7Pgf83OQ+utpqLn71gpfC*fGFJv&Qt}! z#1fpzd&DVNUTE+yf-VMr*6WqH@pK#NNGi?x$%(mn{|%2&be44Gj+*U{3GlQjuTm@F zYbifV((|wYXSfj@{gd+E8M?`L`WbX2oh3;P;F?#6Ai}B0fxRMMGd%#y!gOQ z6Eh(R-?K9YrQc@05;1)GKlrc>Y5*eODluKC$&B%y9dq^Vb(*PwyOX`r;(+ zm=JuskO{kY&h_?iu{DF-gM`>a>7f7>A|w_I@_()zAGTHZ&N>}Nv7097di7GITYtWx zbqNoK6L~el(QUIPw1^{QSXPb<3DsAjwIw;3lFvK$75+k#t^FjezO&qsyP}nL=3C!O z|JIRo!bE%p{aT5hJTw_5UUl3NIX6Lc%3{8eCnJPsn1qypqPM2mM69)@v5XY^0@sP& zhkHBotvKX7kQJH|lQekc=UKg=9ZSG0*7UJ>S{xWGoPz>=rgN1!MsRCp;WKj;N7Cdn z)WBHoXWgVc*JGY#?m9FNt+grphmV!5EMxpyYojNh0PDUo=+0ncB0kz8p;G&eg{@?8 zb(E?@`OkYuSa&?(jC(GQBX?wq*b>XVp|r=X#{nkNh90Eha`G$l!RvG;O7UH=H%Ft+ zvc3BS8JY{;xe&sHR)XyVt=WD3>5Sja+%_wq*M&oV3sXW3W5#mmV%cQAp3@}K%K4F{ zkcn?Sqg`%8<`qe^SrN#PH*J8yic%;5u|RZGrf|mklkUo@4#Kj4M*F+l$SZ0pOb%K<)$Sj1VuWXgoFTDI!~TEtli6l8 zQ!g30l*Tn?XJQ!c2B4vf0nfTWM=n{UgN$Bk2V6y^W70?qaI?X7@YN2!mmP{X-;{`?EJNvG|rdKY3GDUTnG`1l6Ii9F0;@=X+OXV8Zhj^&z zb2JY(B*X-Mc4*#zZ=TJ2bnUaC5KCFcVe9o16ipz8MpC#B@KyxISU4wAO95C~<^Lwx z3Om&d8_s`-Ij+lPxS8_YVvFZQ!0v6pJe*dHgf|vk5&^yvI`G|FuH;F}(bwtyxMe2R zf^T`%Y5xODV~_*qBZAh5Z{<0E;6PGT4eRL`!w_|o<=TtcxRf9* zT0aHuN0-6|FHp7Q>3FBtBFbc)vsIb!CFirR%AX*Gy+=Mb&1xr2#jK(CxCv3Tasg9B zXSTPYyFblcLR8N%zzv?d9(E&s)9deIYDZJ|(c*ZGKOAh{XyPykVJ&J_)eRZTJjL}z zF*1fk#GpTdsP14i{%vDy(xeC4yq>(Kp!koJ0}JEIgfI`ATB#WR>o8)VZ7cv5YyBgG z(gV*5z7+&uaVN%)T10f+dHizKzA;vtC-u>`oh#cIII?P{doEw5`@oR zk##&s_4nfuv4* zt5)-JA}{a2l<+sfQ?S#gC{GmmIc@nSnH`egI)ZcxxeHpV>nG&lAQLBukX7d)I<7!! z5%&geehvaa(PNeW+=TmRz-$QTlHF!p*M85|c${xJCqf&m=Rs;#t4Zhtdm9=$B0d^s zcA_SksIzaj=+TVva}6{KaI{;81-sbl_9!F-ewNS9hot97mxknC#pC! z+MUee`3KbzO)kAN&?L?g1K&2jau?a)hiGHSw9J766 z+dOw2%{vO;vUshV#EMSaixrW>P~kwiBllW;Kb4j`bO>8MM{X-G(}gXl3>@o1%PtP#c_HbG3WXGyI$C2Xo_@GKsnI0-2fK_q2}>7n-t&B!JyimEI+6|y zyNrHkaEQAavxNSi^r!zxla#1|($IEvJUBil{zGDIJa!c|w5n8et;>A>6f-#o_3xn5 z6lTMRKyBD9HRvy_!MnY#80}Z+xr$a?GePtJTBH2en)AaLbbxa9C`JRlPED@_#rd*tHYrd_+JC$< z=(q`rMF6WK)>mXb55}>=iq;uk=utx1WB>h+GZ7y$4AlpD`-1KV6m%qY$IACmy4=kV zVD|9#k3<0sr{I->APf{b&<~u!yQ^<$Q$#onG7Ml0prCl^NBcoG;eJ%5zFUv=ge2 zUZJ-ohzGT&#s9d?Q2tyKqxi?q!!rGxDMr4^nXe^OKc*C*$N7!|#L%5hSnzDVG3iVH zy5Rnkto|As%>2J`75Gg!Qv#i@9lfijhEG>>@eF9V&z&ZTKF!2P0wjfLZZ)O^`W^xb zy;^G=y^nuaCGj)T^u2uiZ@xvp?m`{e1ZcSt7jf-z8B_zhE|w{n1apgjjSjGbcJ~#! z>A#_#);)&42eL9)TReWsNkkeySKj^09=NOwfFhx6@1IT!upkUGKYcM6H~^Pd^R{CG zikCa6Z;En(j_H{)ESSGp+he$C}#+(vVx390f5fZ zOASdJaT2iBu*8duI$U;jT({!~hGjT6xur?YrieDwM0wXTu0W6mc);7cTV zReB!N4%ja^{aTyb0Hth&KzVp&gK|$a0Mf$%*yz;{ zx(y=G0n{3^h3cz(7Ab($?YV2qd!^8^Hx|53bhVMk#Of*wmcSGLLsd0Nu%b#5kQnY7P_+1a4zngnQxcKQZ;7AgsGSQm< z#hz4>#Tpk%OOs9G^>TJ-eIXb3fp;RO@$3ETc^sH4orhl^Zwfz2o3^sT=zI+;o-?yP zAN(Cf5CUBvoJ~}WO2G#)B_vYvz9qO2%>yebL*iTBh*a zQNn;5q{p(NZ-8Q9&KvpMK{AJpgd9A%I5i;BL&+yAv)Bn_8;-AJd;IeMcGl4$1%2M< zAyl-Wu&TA$ZV`7S$nDtiU)_jjpVlM=3Z-~l-L1u3j7_f=n8>l!(etSKH1=oi?f+XD m90$O|izrh6|NS}ohA5+v{Q96JIs+}I2IQrcKUPVCga04Y2eM%R diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-32.png b/src/mcp_synology/icons/mcp-synology-logo-dark-32.png deleted file mode 100644 index 15b2b7c539b2978d6eb8b4e45d85ed0d99956ac4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1656 zcmV-;28a2HP) z>Mxr6FfqSm$&~|r#toX<#UEaI0t^2afU)D)=w}9|Xa) z{^PmBeOEscfUy(xPKvbGlQFWN%IpdNA!BO}cz30~F-SzdN@OfVzkm5)HMK5){eNmk zEnyFs3L=`cCcl%&YBo3kSPkM09H5q#4)t>64uz_rYMwhy$;Ai=_o>Sp=Z?fzZV6!Q zbQqG0a1q%dUMD05h-4KD3kbg)#kftdgE#8ZF?hleyZ7o@~} zWbzCYPyhKy`423AhRz^2{ESS#SrlmHIXY@ZzP1{Gxo2aLi9QYpYQuAxT~Wk@IRgMB z`meGAmFsj<(9^DRF9QA6-4=Jd>wssS$y`61=d0do1AR9b4`xZfv?OnL0mPCS=5V&!y4^d4XB?y{5*;Oi`}-J z(#B33`O6$+59jiAbIV~_>9G*oP!I>%BS8r&PXdMz%A-xwK*UF*`E_M^+>T3E`8NcKi``qQN%=Qxj^+MC5%z6)$X57371<$Ps5Ut#w zt9Zqmxyj}0jkb>gI94!yE=KvDyT6~a9R+|W@hOOXQWO5F;y)7AEh6$+%Jv8`#KE-_ z&su>A!0ufWfH^ZI8Dc6&BI|{Y<||zwdEpFzGeTi^i1CL`D9qAxO5r$A53`0(gQ%Bs zu1ILsL@p!HH0JXTB7VRb?KJ_^B{8(eSZA^n50=jRRb+}g&8v;5exR4ni%?+V8XfR}o$lOr^2Qynv0=gm{bcg7kWpmdW>?5p z0tB%M0`-ywPSW(KYg<(U$Fd))AVUD4BEyq%(o5_mF5e;6o(wTI2dUXKcx?mkBZm0LqwV zc9tCOhuJ7)pEs5n1iX_|JsBiCAfQpscF6*F-NI4`NNmAE94%C&3nX`LW~#>*n)GWe z{A#qg8PKays`7)x@nO&OabgG&vqx?_*#%eQDDf+7*ugMZup6S-MPf@`Q`dKVU>tqc zSvaf|TF&W|<)-V+0)Pgh=+z#!%x=X1A`QAJ0~ADXiAGq6%sC{ScC-ZY>+t*nISn_0lxd1m3wyl{cD^q3wKqzDgasyPZrpUUr zE9!qEa8?|jF@XFDbn(wLw`p(*N<7mM&sJHq?G6Tiyi&4{2xYMfUazeB`gd zf0f{9kkbi%O&e@3N}tSLlMP4lfiobLO;09DFTIeSr=}T;6rT~%nL&Kz>6_}8&kDfe zC%TVn;#~yBw26Q2B${X-T7yEHo`*!Dqqpz_8t*PlYrCOFs`l_gKBBOmYB!{{?JdnD z@TNIKein+$sPX998;94P7Qmp4GO0{+Mi~Jrpu|Kc(UeZKuB38C9{^H_h58JXJP~Q| z%pBo7$;@>OfB>@eKiWT{aE7{nuO&`7Z=2}Q;h-aHrCqcDP>IiIX?q8?Q|YIYRO@y~ zx=PH5tG)Ph2isy%n0-Phd)MAv{n^nV>F7=faGAzsLRr9M^YwsiimL2B6@VkbE2N@) z7RlI#|3x3RuWHLEwHAqn#p(e12ZKmMT8f2g-ATog8WvW07vRt~onJ=z>lT|NM-dMI*S>a@RRDgQN9 z+?iTONEPa1aC{B`RHAk5r-~yZZGcc8AJjfoSggqR6ULxE=o;Im@;4CRunx%h4iLYM zs_s>7c@Rk-VE7|l$Ro!XhV_y++I^^MFy{L;fKH-0@|Z)Vg_lx`)_t4C%sE>4qr+)f zhw~lk@oND0g80KuWgjFE4pqaz1ak7#Xw!_hY#tb^n_HLzszH{Y+p3#=APIo+3%J&e|uPIU4+Mf8DIxi6da403a={(DpQchUfdRP`eY zCcXgT3zmlO8FKlu$@J0mCFSYq`e59zj8(1a?*ejgMU=Q8L9Ru_D2Odo zIpIrxQUGnETGcT)_XF@U@l62Vtk7GoS^h>sCftYrm>l0Vnf-{`XkYQU(dl|+l=w%_ z^0$Bh0J3layZA|nWD(3`a6gSBO05r>oEpGLRo^W@8)h#SAA4mgke1_X;8>HxuF9r= zdP3$hzbsk(2Mu_hK)%=s{|fnFR2H}z!k>s>Pt6vPoJNr!O0vD?zN<4`J(cgluTbK? z!m>5H&NU2Q6A7ETGO=!|&JJg;Dn{m5=tG)yrl<+R_c--0Lt_U3ghOlVu0yf8Zt;$| zzGa3RNZm;B7S5MSk{@N*Po=W+#B1;yDu+uGN+wgT^k1j!%_q z1_F3jwp!9Fisb#mX1fMlhstONOCv+|oeVhCpD1hxE?gy?8V)El%5u?kAquuq@tP1x z4D$lA7vkS#%0(DAREXt=@>q}5EkcaF@!&-Ssk2%mc92=$#9!nm0{W7LPavv`17(LQyrBD^YN(zW9e+8a+E$|cJJGy4>41cs zCtywT^F6r9bajuN}r-v*jO39GHYhd6L`hj&)aFM_+eb?HCyNU-Qcs_$IQO)i8 zvFBZ8+X>YPI6i%XBkD!gmAfS8i{X4r$ekiG6DG67V@sFxTQuF18o!e!ABRYeDBD3) z1EZg^n1gPEwcp9z@Q0Y~rv;n`0B{F@O(716m@lTLrL1?!9~VIYvY!V_&0_(C^C-HN ztplR7CESB}K1|<0U||+~MhfaMyBYx?;T2G3so?3;0yqMaCf)(@uduYz+L)$R%_g~r z!iMB)NjSq*<_ozovZ)qcLc;~eBhW*sc999&R^)qXHkLxJweY1uc^fVtBV!*^dO_N* zA$(m))pm_o0?5D*>B-kZrF5>?-S*qB4EOT&+VKjSZUX>C>?nfG)|T@rN-22y_?MGe z-p~#&gnMO*#R~Z`if?6L3xeXx=M^k&Tm!N3B)Y8jxWS1Uih=v$Q!nP~2>0guP<%h3 zw>UKRlXgBpxX5mRyOYMeR5?*g?_PA>WAByQ3&)>90gR)?%s&$0LBXUOkjnHEQDO^--C{N>;pauh z)(+`WVa(2f706XDI-d=di!|XCL5vmZn<-GicnkEz!>>tCC2m;Vzm(uT5t2;tND~u# zPB5BmH0pe@wCOq(o!!KNFI5=&4Jv1Jn!b_1+Yue2?cHISq~t8+ zXnkQD-Lfac3j-!yH~^87sk4(qnZ zQSYkTyENj8&TJ)oY{SWb91Rc-9Zt50(ak4qXT-sMu^MLwl&V-ySkZ`O0lr-h)L(t} zb`0D*pAx_k<0B{0vIx#~os3Vp!9QjOBJO2i3xR@wFyRuV>~C`Xpkg&kyY3n>Y^r7G z;Zr%WKJC-#B~SL3i{MrPHcQujt2IC2<8{IsXt|rFv#A`T=^E`~u_`{SE~}rqx%!vW zxidK9`+=oTw$~_dH{p9ZI{Z&%|2s#@>!Sg*#Lr8__Kx}4oXSliI!~bwKeZx1b0+@J z+6~QQnd_Jtpo|F1%~NHR)6rSKBReK@32KN9d-$G{$tb6zb8^WC0N4LFhGnApJPAdw z{`^zR@SZ2qr=xRr%{q50+5moqf?u4w745n5bIxW!09m@ac>)5i{bYUX*}9cH!OsW( hO1#hd0O0-B{{nlt4U}p9u*3iW002ovPDHLkV1jW)Pow|< diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark-64.png b/src/mcp_synology/icons/mcp-synology-logo-dark-64.png deleted file mode 100644 index 807cc064d1382af3ef6bdbf57ac03a7b6e618acd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3810 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 Y1sQonG@Vki-~a#s07*qoM6N<$f>)<0t^fc4 diff --git a/src/mcp_synology/icons/mcp-synology-logo-dark.svg b/src/mcp_synology/icons/mcp-synology-logo-dark.svg deleted file mode 100644 index 14039d5..0000000 --- a/src/mcp_synology/icons/mcp-synology-logo-dark.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-128.png b/src/mcp_synology/icons/mcp-synology-logo-light-128.png deleted file mode 100644 index 7681b1832de73f8815ed907b7568afb15a4ea89c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8035 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 diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-16.png b/src/mcp_synology/icons/mcp-synology-logo-light-16.png deleted file mode 100644 index d99f76569d869f8d77ae44410d93416fa14c441c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 648 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

xz#cdS8u4M2xy-zu3es2g_TFS5#bRDgXYYusw` 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 NHDyhuY6Z*C{||U(WKRG9 diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-32.png b/src/mcp_synology/icons/mcp-synology-logo-light-32.png deleted file mode 100644 index 1bb2f0edaf58707eb391542e3bd2dcaec8fb749a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1632 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 diff --git a/src/mcp_synology/icons/mcp-synology-logo-light-64.png b/src/mcp_synology/icons/mcp-synology-logo-light-64.png deleted file mode 100644 index 919cde1ed833bf855956df7f780cbb05aaee84ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3759 zcmV;g4p8xlP)h6iuKS$leK*N&zITI3s6dQS%7-vjlz<<|fTGeWML?y4wzYnBI#y@pF4FT>8Y}!gN>&SopR6V+4Fe;$m4`?WOU^ME7Nu7n8bBP!}v;zWbsj553UX zUQhxST{m!&EB*pOFw(!(u&tee;*zQ=7fG=~#20s8)c4aD3gZhx!2Ih5PG{hKlAcsl zUvXgf*tLY#Da?({rEtqs?IgrJ%YMEB$0O;Q!3&q&Bw$7X&lmyx6s%{>_P0WIF`)-q zEq>inZtZ_xLhBT7%zYef?|7aoS#D_LT+q&P>ic^J`>xRYQO^kOLnWYZ-9UG%;O`}! zV6t_ssJ=^tPueMcsg!n1r=Mt-Ml~<~isA|g&yd)Za`n$ooxf<;q2NDg0>&#k3@JRe zh0<3#aZIJoBGW`84E~RJ!KuL{3 zYsUeHX^K{lTXS$yaPU4Rm69}Q?P|id4mEOgOu)1i-P+4d9A;IyxA%$QzK+WhF+Vxc zqtPx6YpO3=?6*d4cjm~!MUE*6m`u?-F~L%fp(D{~K3SB8=~3{kumLU5tFrgrRA&oY zXkJ=VjgAA^bR)-v1kC^X;8I}ZZ2-=p_>)$O8xJ%DJ2j;`XHQLs?HT<|<{_W!&C%Zg zfSgyLlqtjGL}sZhhGI!XV*>z0qpq56=L%X2;ufImrrj6yJWrn{UJPmY5|chpsfWOw zhw3~4(5o`$JOH2#a*j^R=4Z=k03i7j$<&=|pOZc2vR#(s+UJ;IK?3II+UJnIG@+W8 zS}~8>cO8l)hIGtc$J$j*;7&?Cl)eBBUosJ{JTY25Eb+q(bj$qehK1MU$L0B#hf2hl zRSiE;@(V`)W`A+J4*+V9!nwJPfao?<03a|t`cMLdVMh%#4S?+_)!6mMt^hUwKtlT^ zmU)?o6#-qVh+B;|ar!+O$VmQ80N*sG(<;eYcY`@Z+YNg8*fkZ)DYq}pR(?v9Gg|cb6~qTd zogcL%b>(a??ZlVqjez|h&LcwtKBhTlX@nnc`tU7C7=U$sw61+C03`St_oP7j^>o{3 zu8KowPqA z1dO+tiW~=0^zoL+6AbzHgC!t7jZhn80Duwn4uo(@0vJ3xLeDT`U^)&QY~+|y(GARD zrvg|NW0IJypQ&W4J@nE;1exFee_fT@H*xEfV_3@ba<#u z{eUhhRby`DepwaeE*kEX)B}i0F^qJ9fcv6Kw^}J3XKx&d0CZ143*gv06)e(G$@oguG1tjW z5O(5Oe$B89;^HuSD5Cxj2$<3m8rIUZ&;$Pq#7-DsuSdz^7g zK)X?B+3Zxv)&y;*RA9jC1?~*uZUPHTQH0jr1$oQp6yW@TrmS9dEP|xfA~rxsGM!?; zqt!4TueQ9kT$#OuZ{K>jg0CHDvp%LwjdtC zs9Ypx+>lW9k=|6s&2_C^@XE6BbO=WWAxPKE3;_h8u`w@4@Ihy+M8l2%Y#F2eq0D{{ zNK2B+L(gGB7;pv5c$yq}tvLi?|rShv1xDfzSTg%Pz=^~p_Yz485;94T?hPcxb z7mM*f9SnfZH6ZP&+W>Bg3fbCmPJ*?yfAK*a2&4hD<5(K*B6t^!+Y)$AL^K3EK3N|T z+B1%vWtm3Ley_VWfYHAeiVy%RrND5b z5I|dtQ?jLz5C}YPqeRql70yFtzep)P=B1n^VC8|@CX@iJhNU_J->p&l;Xs1D!oZg~ zy)D@zEIh>wFAu~|80bng7O(Fw#?c(XZM71X2~#hK4sAJH*;7LK0)od=cqI&y(qnrG z2-Yy$fDm0;jbxirZV@2F9> z!}x5Dpp!*CL!`FPga~$Gq#gkA0OiMk=?o$tA&lG65+qx{aTM6=XROl|kHWOAQ+8>Dgd4M+?ut2muZt-P$EU1t!f?=sLzqtFIeLp@>Umb0a zFTB2g6$(BW#1ar!0lr~6L_Ehi;B^7~2ml~{BA^8V{uKZc^CwOIaL|72^2Y%nBfX5^ z@j)_Tw9;(i%Vpd_fux$Ee(j3;lGzF?I5*5psa!sI!HcG>L{IC0M9($b`z-N8kX{PK z)k?VMV2PNBfbk2M6gY{Nj*Y}2mDsndOAz9UOcqamZ@4yQvo#Xl%)rjt^FI##$&!wD zm=EfROy9bJ?p9s?97?YN^zCTqw&_%b_S${~$SvoDH-b3I3pdloujZJDIL5$IvTlW| zKgmuca29AAtX=)n&Px_PJzWTgbV!OOx2Z23!^N zsZWyZKLsNFsi^hO0_y?OVp{YU=?6K~gr&CtSZ(C>yVm!9L!ay<+n?NKBo7cEyl44s z0B&1&?T(XWnlj@m z0doY*VZy4}!ZG7vuu~h%eK+-#{*M8`;QD1Z%@&SXn*#q|>~|kd&NhJAw%-vj+W= - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/mcp_synology/modules/filestation/transfer.py b/src/mcp_synology/modules/filestation/transfer.py index 0b1d186..204d065 100644 --- a/src/mcp_synology/modules/filestation/transfer.py +++ b/src/mcp_synology/modules/filestation/transfer.py @@ -132,8 +132,8 @@ async def download_file( files = info.get("files", []) if files: nas_file_size = files[0].get("additional", {}).get("size", 0) - except Exception: # noqa: BLE001 - pass # Best-effort — download will catch disk space issues via Content-Length + except Exception as e: # noqa: BLE001 + logger.debug("Pre-flight getinfo failed: %s", e) if nas_file_size: free_space = shutil.disk_usage(local_dir).free From 4bf851a4e2da32c606f78cf053d89369ced3c62a Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:47:24 -0500 Subject: [PATCH 7/8] Move icons from assets/icons/ to src/mcp_synology/icons/ Icons belong under the package so they're included in the wheel. Update all GitHub raw URL references in README and server.py. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 12 ++++++------ {assets => src/mcp_synology}/icons/favicon.ico | Bin .../icons/mcp-synology-dark-128.png | Bin .../icons/mcp-synology-dark-16.png | Bin .../icons/mcp-synology-dark-256.png | Bin .../icons/mcp-synology-dark-32.png | Bin .../icons/mcp-synology-dark-64.png | Bin .../icons/mcp-synology-light-128.png | Bin .../icons/mcp-synology-light-16.png | Bin .../icons/mcp-synology-light-256.png | Bin .../icons/mcp-synology-light-32.png | Bin .../icons/mcp-synology-light-64.png | Bin .../icons/mcp-synology-logo-dark.svg | 0 .../icons/mcp-synology-logo-light.svg | 0 src/mcp_synology/server.py | 2 +- test-data/awareness.db | Bin 0 -> 24576 bytes 16 files changed, 7 insertions(+), 7 deletions(-) rename {assets => src/mcp_synology}/icons/favicon.ico (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-dark-128.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-dark-16.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-dark-256.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-dark-32.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-dark-64.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-light-128.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-light-16.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-light-256.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-light-32.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-light-64.png (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-logo-dark.svg (100%) rename {assets => src/mcp_synology}/icons/mcp-synology-logo-light.svg (100%) create mode 100644 test-data/awareness.db diff --git a/README.md b/README.md index 19f329c..5a4e65e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- - - mcp-synology logo + + + mcp-synology logo

@@ -267,7 +267,7 @@ Live testing against real hardware revealed behaviors the specs couldn't anticip --- - - - + + + © 2026 Chris Means diff --git a/assets/icons/favicon.ico b/src/mcp_synology/icons/favicon.ico similarity index 100% rename from assets/icons/favicon.ico rename to src/mcp_synology/icons/favicon.ico diff --git a/assets/icons/mcp-synology-dark-128.png b/src/mcp_synology/icons/mcp-synology-dark-128.png similarity index 100% rename from assets/icons/mcp-synology-dark-128.png rename to src/mcp_synology/icons/mcp-synology-dark-128.png diff --git a/assets/icons/mcp-synology-dark-16.png b/src/mcp_synology/icons/mcp-synology-dark-16.png similarity index 100% rename from assets/icons/mcp-synology-dark-16.png rename to src/mcp_synology/icons/mcp-synology-dark-16.png diff --git a/assets/icons/mcp-synology-dark-256.png b/src/mcp_synology/icons/mcp-synology-dark-256.png similarity index 100% rename from assets/icons/mcp-synology-dark-256.png rename to src/mcp_synology/icons/mcp-synology-dark-256.png diff --git a/assets/icons/mcp-synology-dark-32.png b/src/mcp_synology/icons/mcp-synology-dark-32.png similarity index 100% rename from assets/icons/mcp-synology-dark-32.png rename to src/mcp_synology/icons/mcp-synology-dark-32.png diff --git a/assets/icons/mcp-synology-dark-64.png b/src/mcp_synology/icons/mcp-synology-dark-64.png similarity index 100% rename from assets/icons/mcp-synology-dark-64.png rename to src/mcp_synology/icons/mcp-synology-dark-64.png diff --git a/assets/icons/mcp-synology-light-128.png b/src/mcp_synology/icons/mcp-synology-light-128.png similarity index 100% rename from assets/icons/mcp-synology-light-128.png rename to src/mcp_synology/icons/mcp-synology-light-128.png diff --git a/assets/icons/mcp-synology-light-16.png b/src/mcp_synology/icons/mcp-synology-light-16.png similarity index 100% rename from assets/icons/mcp-synology-light-16.png rename to src/mcp_synology/icons/mcp-synology-light-16.png diff --git a/assets/icons/mcp-synology-light-256.png b/src/mcp_synology/icons/mcp-synology-light-256.png similarity index 100% rename from assets/icons/mcp-synology-light-256.png rename to src/mcp_synology/icons/mcp-synology-light-256.png diff --git a/assets/icons/mcp-synology-light-32.png b/src/mcp_synology/icons/mcp-synology-light-32.png similarity index 100% rename from assets/icons/mcp-synology-light-32.png rename to src/mcp_synology/icons/mcp-synology-light-32.png diff --git a/assets/icons/mcp-synology-light-64.png b/src/mcp_synology/icons/mcp-synology-light-64.png similarity index 100% rename from assets/icons/mcp-synology-light-64.png rename to src/mcp_synology/icons/mcp-synology-light-64.png diff --git a/assets/icons/mcp-synology-logo-dark.svg b/src/mcp_synology/icons/mcp-synology-logo-dark.svg similarity index 100% rename from assets/icons/mcp-synology-logo-dark.svg rename to src/mcp_synology/icons/mcp-synology-logo-dark.svg diff --git a/assets/icons/mcp-synology-logo-light.svg b/src/mcp_synology/icons/mcp-synology-logo-light.svg similarity index 100% rename from assets/icons/mcp-synology-logo-light.svg rename to src/mcp_synology/icons/mcp-synology-logo-light.svg diff --git a/src/mcp_synology/server.py b/src/mcp_synology/server.py index 54d72c4..4c43cf3 100644 --- a/src/mcp_synology/server.py +++ b/src/mcp_synology/server.py @@ -36,7 +36,7 @@ def _load_instruction(name: str) -> str: _BASE_INSTRUCTIONS = _load_instruction("server.md") -_ICON_BASE_URL = "https://raw.githubusercontent.com/cmeans/mcp-synology/main/assets/icons" +_ICON_BASE_URL = "https://raw.githubusercontent.com/cmeans/mcp-synology/main/src/mcp_synology/icons" def _load_icons() -> list[Icon]: diff --git a/test-data/awareness.db b/test-data/awareness.db new file mode 100644 index 0000000000000000000000000000000000000000..cfef8cdb68bea5e2b2aea0b0b29e1f5769ff73f0 GIT binary patch literal 24576 zcmeI#(QDH{9Ki8Q)}}J7+l%BOFZb3JHlg?=z8GDEVeDMZa7CoV<`0G zm~Z|^zG*H^#2(AG>O%0lzy42(QGnLj#cr>dN%Ofz>|CZUGGS)h?V<&S;TMY z6x(u{qx+^|**(WNt#ovykLJogmSLCE7l{wnEPKZ>;!0xOeKC~A%CtJ@a#H=={oXTL zw&NH-9;M;?ahasZWfr*t_o?rdbCp}`@>+?b;zi&c1@hHk@1;8!$`{^nyAqlYLPzJJ z{ry1p4}HHHR~XzHcbup&RayVHzF!1Y Date: Mon, 6 Apr 2026 17:47:29 -0500 Subject: [PATCH 8/8] Remove accidentally committed test-data/awareness.db Co-Authored-By: Claude Opus 4.6 (1M context) --- test-data/awareness.db | Bin 24576 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test-data/awareness.db diff --git a/test-data/awareness.db b/test-data/awareness.db deleted file mode 100644 index cfef8cdb68bea5e2b2aea0b0b29e1f5769ff73f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 zcmeI#(QDH{9Ki8Q)}}J7+l%BOFZb3JHlg?=z8GDEVeDMZa7CoV<`0G zm~Z|^zG*H^#2(AG>O%0lzy42(QGnLj#cr>dN%Ofz>|CZUGGS)h?V<&S;TMY z6x(u{qx+^|**(WNt#ovykLJogmSLCE7l{wnEPKZ>;!0xOeKC~A%CtJ@a#H=={oXTL zw&NH-9;M;?ahasZWfr*t_o?rdbCp}`@>+?b;zi&c1@hHk@1;8!$`{^nyAqlYLPzJJ z{ry1p4}HHHR~XzHcbup&RayVHzF!1Y