From a0c1b6ed93adcb14aba5dc34d985727bcfd1aea4 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Mon, 13 Apr 2026 14:22:37 +0200 Subject: [PATCH] Initial implementation --- .gitignore | 19 + CLAUDE.md | 92 +++++ README.md | 185 +++++++++ SPEC.md | 242 ++++++++++++ docs/setup.md | 196 ++++++++++ docs/tools.md | 274 +++++++++++++ pyproject.toml | 39 ++ src/mcp_synology_container/__init__.py | 3 + src/mcp_synology_container/auth.py | 191 +++++++++ src/mcp_synology_container/cli.py | 317 +++++++++++++++ src/mcp_synology_container/config.py | 192 ++++++++++ src/mcp_synology_container/dsm_client.py | 362 ++++++++++++++++++ .../modules/__init__.py | 1 + src/mcp_synology_container/modules/compose.py | 324 ++++++++++++++++ .../modules/containers.py | 215 +++++++++++ src/mcp_synology_container/modules/images.py | 145 +++++++ .../modules/projects.py | 227 +++++++++++ src/mcp_synology_container/server.py | 40 ++ tests/__init__.py | 0 tests/test_auth.py | 167 ++++++++ tests/test_config.py | 168 ++++++++ tests/test_modules/__init__.py | 0 tests/test_modules/test_compose.py | 236 ++++++++++++ tests/test_modules/test_containers.py | 173 +++++++++ tests/test_modules/test_images.py | 154 ++++++++ tests/test_modules/test_projects.py | 163 ++++++++ 26 files changed, 4125 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 docs/setup.md create mode 100644 docs/tools.md create mode 100644 pyproject.toml create mode 100644 src/mcp_synology_container/__init__.py create mode 100644 src/mcp_synology_container/auth.py create mode 100644 src/mcp_synology_container/cli.py create mode 100644 src/mcp_synology_container/config.py create mode 100644 src/mcp_synology_container/dsm_client.py create mode 100644 src/mcp_synology_container/modules/__init__.py create mode 100644 src/mcp_synology_container/modules/compose.py create mode 100644 src/mcp_synology_container/modules/containers.py create mode 100644 src/mcp_synology_container/modules/images.py create mode 100644 src/mcp_synology_container/modules/projects.py create mode 100644 src/mcp_synology_container/server.py create mode 100644 tests/__init__.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_config.py create mode 100644 tests/test_modules/__init__.py create mode 100644 tests/test_modules/test_compose.py create mode 100644 tests/test_modules/test_containers.py create mode 100644 tests/test_modules/test_images.py create mode 100644 tests/test_modules/test_projects.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd6c9db --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Dependencies +.venv/ + +# Build +dist/ +*.egg-info/ + +# Python +__pycache__/ +*.pyc +*.pyo + +# Tools +.ruff_cache/ +.pytest_cache/ +.python-version + +# Reference material (not part of this project's source) +reference/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..718aac1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md – Arbeitsanweisung für mcp-synology-container + +## Deine Aufgabe + +Implementiere das Projekt `mcp-synology-container` vollständig gemäß `SPEC.md`. + +Lies `SPEC.md` jetzt vollständig, bevor du anfängst. + +--- + +## Referenzmaterial + +Im Ordner `reference/` findest du zwei Referenzprojekte. Nutze sie aktiv: + +### `reference/cmeans/` +Geklontes Repo von `cmeans/mcp-synology` (GitHub). +Übernimm daraus die Implementierungsmuster für: +- Auth-Flow und 2FA-Device-Token-Flow (`auth.py`) +- OS-Keyring-Integration (`keyring` library) +- CLI-Struktur mit `click` (`cli.py`) +- Config laden/speichern mit YAML (`config.py`) +- Credential-Auflösungsreihenfolge (env vars → config → keyring) +- `setup`-Wizard mit `rich` für formatierte Ausgabe +- Generierung des Claude-Desktop-Config-Snippets + +Passe alle übernommenen Muster an unseren Use-Case an. +Kopiere keinen Code blind – verstehe ihn und adaptiere ihn. + +### `reference/n4s4/docker_api.py` +Einzelne Datei aus `N4S4/synology-api` (GitHub). +Nutze sie als Referenz für die konkreten DSM API-Calls: +- Wie `SYNO.Docker.Project` aufgerufen wird (list, start, stop) +- Wie `SYNO.Docker.Container` aufgerufen wird (list, logs, exec) +- Wie `SYNO.Docker.Image` aufgerufen wird (list) +- Welche Parameter und Response-Strukturen die APIs erwarten + +Implementiere **keinen** eigenen Wrapper um `synology-api` – +baue einen eigenen schlanken HTTP-Client in `dsm_client.py` mit `httpx`. + +--- + +## Reihenfolge der Implementierung + +Arbeite in dieser Reihenfolge. Schließe jeden Schritt vollständig ab bevor du weitermachst: + +1. **Projektstruktur anlegen** – alle Ordner und leere `__init__.py`-Dateien, `pyproject.toml` +2. **`config.py`** – Config laden, speichern, validieren +3. **`auth.py`** – Keyring-Integration, Login gegen DSM API, 2FA-Device-Token-Flow +4. **`dsm_client.py`** – HTTP-Client mit Session-Management, Auto-Re-Login +5. **`cli.py`** – `setup`, `check`, `serve` Befehle +6. **`modules/projects.py`** – SYNO.Docker.Project Tools +7. **`modules/containers.py`** – SYNO.Docker.Container Tools +8. **`modules/compose.py`** – Compose-Datei lesen/schreiben via FileStation API +9. **`modules/images.py`** – SYNO.Docker.Image Tools +10. **Tests** – für jeden Schritt +11. **`README.md`** – Installationsanleitung und Tool-Übersicht +12. **`docs/setup.md`** und **`docs/tools.md`** + +--- + +## Wichtige Implementierungsregeln + +- **Confirmation vor destruktiven Operationen:** `stop_project`, `redeploy_project`, + `exec_in_container`, `update_image_tag`, `update_env_var`, `update_compose` müssen + eine Bestätigung vom Nutzer einholen bevor sie ausgeführt werden. Nutze dafür den + MCP-eigenen `confirm`-Mechanismus. + +- **Nach Compose-Änderungen:** Immer automatisch `redeploy_project` vorschlagen. + +- **Fehlerbehandlung:** Alle DSM API-Fehler sauber abfangen und als verständliche + Fehlermeldung zurückgeben. Keine Python-Stacktraces zum Nutzer. + +- **Keine Secrets in Logs:** Passwörter, Tokens und Session-IDs niemals in + stderr-Ausgaben schreiben. + +- **HTTPS:** `verify_ssl: true` ist der Standard. Nur auf expliziten Wunsch deaktivierbar. + +- **Compose-Pfade:** Beide Varianten erkennen – `docker-compose.yml` und `compose.yml`. + +- **Type Hints:** Konsequent in allen Funktionen verwenden. + +- **Docstrings:** Für alle öffentlichen Funktionen und Klassen. + +--- + +## Projekt-Konventionen + +- Sprache: Python 3.12+ +- Formatter: `ruff format` +- Linter: `ruff check` +- Tests: `pytest` +- Alle Texte (Docstrings, Kommentare, README): Englisch diff --git a/README.md b/README.md new file mode 100644 index 0000000..1dde188 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# mcp-synology-container + +An MCP (Model Context Protocol) server for managing Docker projects on a Synology NAS via Container Manager. Enables Claude Desktop to list, start, stop, redeploy, and modify Docker Compose projects directly. + +## Features + +- **Project management**: list, start, stop, redeploy Container Manager projects +- **Container inspection**: list containers, view status and resource usage, fetch logs +- **Compose file editing**: read, modify image tags, update env vars, or replace compose files entirely +- **Image update checks**: see which images have updates available +- **2FA support**: device token flow for Synology accounts with OTP enabled +- **OS keyring integration**: credentials stored securely, never in config files +- **Confirmation required** for all destructive operations (stop, redeploy, exec, compose writes) + +## Requirements + +- Python 3.12+ +- Synology NAS with DSM 7.x and Container Manager installed +- `pip` or `uv` for installation + +## Installation + +```bash +pip install mcp-synology-container +``` + +Or with `uv`: + +```bash +uv tool install mcp-synology-container +``` + +## Setup + +Run the interactive setup wizard: + +```bash +mcp-synology-container setup +``` + +The wizard will: +1. Ask for your NAS hostname/IP, port, and HTTPS settings +2. Ask for your DSM username and password +3. Handle 2FA if enabled (stores device token in OS keyring) +4. Save the config to `~/.config/mcp-synology-container/config.yaml` +5. Print the Claude Desktop configuration snippet + +### Claude Desktop configuration + +Add the snippet to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "synology-container": { + "command": "mcp-synology-container", + "args": ["serve"] + } + } +} +``` + +## CLI Commands + +### `mcp-synology-container setup` + +Interactive setup: configure connection and store credentials. + +```bash +mcp-synology-container setup +mcp-synology-container setup --verbose +``` + +### `mcp-synology-container check` + +Test the connection and verify all required APIs are available. + +```bash +mcp-synology-container check +mcp-synology-container check --config /path/to/config.yaml +``` + +Exit code 0 = OK, 1 = error. + +### `mcp-synology-container serve` + +Start the MCP server (used by Claude Desktop). + +```bash +mcp-synology-container serve +mcp-synology-container serve --config /path/to/config.yaml +``` + +## Configuration + +Config file: `~/.config/mcp-synology-container/config.yaml` + +```yaml +schema_version: 1 +alias: HomeNAS # Optional display name +connection: + host: dsm.example.com + port: 443 + https: true + verify_ssl: true +compose_base_path: /volume1/docker # Base path for compose projects on NAS +``` + +### Environment variable overrides + +| Variable | Description | +|---|---| +| `SYNOLOGY_HOST` | NAS hostname or IP | +| `SYNOLOGY_PORT` | DSM port | +| `SYNOLOGY_HTTPS` | Use HTTPS (true/false) | +| `SYNOLOGY_VERIFY_SSL` | Verify SSL cert (true/false) | +| `SYNOLOGY_USERNAME` | DSM username | +| `SYNOLOGY_PASSWORD` | DSM password | + +Credentials from environment variables take priority over the keyring. + +## MCP Tools + +See [docs/tools.md](docs/tools.md) for the full tool reference. + +### Projects + +| Tool | Description | Confirmation | +|---|---|---| +| `list_projects` | List all Container Manager projects | — | +| `get_project_status` | Detailed project status | — | +| `start_project` | Start a project | — | +| `stop_project` | Stop a project | required | +| `redeploy_project` | Pull images + restart project | required | + +### Containers + +| Tool | Description | Confirmation | +|---|---|---| +| `list_containers` | List containers (optionally filtered by project) | — | +| `get_container_status` | Status, uptime, resource limits | — | +| `get_container_logs` | Fetch container log output | — | +| `exec_in_container` | Execute command in container | required | + +### Compose Files + +| Tool | Description | Confirmation | +|---|---|---| +| `read_compose` | Read the compose file of a project | — | +| `update_image_tag` | Update image tag for a service | required | +| `update_env_var` | Add/update an environment variable | required | +| `update_compose` | Replace entire compose file | required | + +### Images + +| Tool | Description | Confirmation | +|---|---|---| +| `check_image_updates` | Check for available image updates | — | + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run tests +python -m pytest tests/ -v + +# Lint +ruff check src/ tests/ + +# Format +ruff format src/ tests/ +``` + +## Security + +- Credentials are stored in the OS keyring only (never in config files) +- All destructive operations require explicit confirmation (`confirmed=True`) +- HTTPS with certificate verification is enabled by default +- Session IDs and passwords are never written to stderr logs + +## License + +MIT diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..ee474f8 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,242 @@ +# Projektspezifikation: mcp-synology-container + +## Ziel + +Ein MCP-Server (Model Context Protocol) für die Verwaltung von Docker-Projekten auf einer +Synology DiskStation via Container Manager. Der Server ermöglicht es Claude Desktop, +Container-Projekte direkt zu verwalten – inklusive Lesen und Bearbeiten von +`docker-compose.yml`-Dateien sowie Lifecycle-Management der Projekte und Container. + +--- + +## Technologie-Stack + +| Komponente | Entscheidung | +|---|---| +| Sprache | Python 3.12+ | +| Package Manager | `uv` | +| MCP Framework | `mcp` (Anthropic MCP SDK) | +| HTTP-Client | `httpx` | +| Credential Storage | OS-Keyring (`keyring` library) | +| Config Format | YAML | +| Logging | stderr only (kein File-Logging) | + +--- + +## Projektstruktur + +``` +mcp-synology-container/ +├── src/ +│ └── mcp_synology_container/ +│ ├── __init__.py +│ ├── cli.py # CLI-Einstiegspunkt: setup, check, serve +│ ├── config.py # Config laden/speichern (YAML) +│ ├── auth.py # Keyring-Integration, 2FA-Device-Token-Flow +│ ├── dsm_client.py # HTTP-Client gegen Synology DSM API +│ └── modules/ +│ ├── __init__.py +│ ├── projects.py # SYNO.Docker.Project +│ ├── containers.py # SYNO.Docker.Container +│ ├── compose.py # Compose-Datei lesen/schreiben via FileStation API +│ └── images.py # SYNO.Docker.Image +├── tests/ +│ ├── __init__.py +│ ├── test_config.py +│ ├── test_auth.py +│ └── test_modules/ +│ ├── test_projects.py +│ ├── test_containers.py +│ ├── test_compose.py +│ └── test_images.py +├── docs/ +│ ├── setup.md +│ └── tools.md +├── pyproject.toml +├── README.md +└── CHANGELOG.md +``` + +--- + +## pyproject.toml + +```toml +[project] +name = "mcp-synology-container" +version = "0.1.0" +description = "MCP server for Synology Container Manager" +requires-python = ">=3.12" +dependencies = [ + "mcp>=1.0.0", + "httpx>=0.27.0", + "pyyaml>=6.0", + "keyring>=25.0.0", + "click>=8.1.0", + "rich>=13.0.0", +] + +[project.scripts] +mcp-synology-container = "mcp_synology_container.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +--- + +## Konfigurationsdatei + +Pfad: `~/.config/mcp-synology-container/config.yaml` + +```yaml +schema_version: 1 +alias: HomeNAS # optionaler Anzeigename in Claude Desktop +connection: + host: dsm.gecheckt.de + port: 443 + https: true + verify_ssl: true +compose_base_path: /volume1/docker # Basis-Pfad für alle Compose-Projekte auf der NAS +``` + +Credentials (Host, Username, Password, optional Device-Token für 2FA) werden +**nicht** in der Config-Datei gespeichert, sondern ausschließlich im OS-Keyring. + +**Credential-Auflösungsreihenfolge:** +1. Umgebungsvariablen (`SYNOLOGY_HOST`, `SYNOLOGY_USERNAME`, `SYNOLOGY_PASSWORD`) +2. Config-Datei (nur nicht-sensitive Werte) +3. OS-Keyring (Credentials) + +--- + +## CLI-Befehle + +### `mcp-synology-container setup` + +Interaktiver Setup-Wizard: +1. Fragt Host, Port, HTTPS, Benutzername, Passwort ab +2. Prüft ob 2FA aktiv ist – wenn ja: OTP abfragen, Device-Token speichern +3. Speichert Credentials im OS-Keyring +4. Schreibt `config.yaml` +5. Gibt fertiges Claude-Desktop-Config-Snippet aus: + +```json +{ + "mcpServers": { + "synology-container": { + "command": "mcp-synology-container", + "args": ["serve"] + } + } +} +``` + +### `mcp-synology-container check` + +- Lädt Config und Credentials +- Stellt Testverbindung zur DSM API her +- Gibt Verbindungsstatus, DSM-Version und verfügbare APIs aus +- Exit-Code 0 = OK, 1 = Fehler + +### `mcp-synology-container serve` + +- Startet den MCP-Server im stdio-Modus +- Registriert alle Tools +- Läuft bis SIGTERM/SIGINT + +--- + +## MCP-Tools + +### Projekte (`projects.py`) + +| Tool | Beschreibung | Confirmation required | +|---|---|---| +| `list_projects` | Alle Container-Manager-Projekte mit Status auflisten | nein | +| `get_project_status` | Detaillierten Status eines Projekts abrufen | nein | +| `start_project` | Projekt starten | nein | +| `stop_project` | Projekt stoppen | **ja** | +| `redeploy_project` | Projekt neu deployen (pull + down + up) | **ja** | + +**Redeploy-Ablauf:** +1. Compose-Datei lesen +2. `docker compose pull` (neues Image ziehen) +3. `docker compose down` +4. `docker compose up -d` +5. Deploy-Output streamen und zurückgeben + +### Container (`containers.py`) + +| Tool | Beschreibung | Confirmation required | +|---|---|---| +| `list_containers` | Alle Container eines Projekts auflisten | nein | +| `get_container_status` | Status, Uptime, Ressourcennutzung eines Containers | nein | +| `get_container_logs` | Log-Ausgabe abrufen (mit optionalem `tail`-Parameter) | nein | +| `exec_in_container` | Befehl in laufendem Container ausführen | **ja** | + +### Compose-Datei (`compose.py`) + +Compose-Dateien liegen unter `{compose_base_path}/{projektname}/docker-compose.yml` +(oder `compose.yml` – beide Varianten werden erkannt). + +| Tool | Beschreibung | Confirmation required | +|---|---|---| +| `read_compose` | Compose-Datei eines Projekts lesen | nein | +| `update_image_tag` | Image-Tag eines Services aktualisieren | **ja** | +| `update_env_var` | Umgebungsvariable eines Services hinzufügen oder ändern | **ja** | +| `update_compose` | Beliebige Änderung an der Compose-Datei (vollständiger neuer Inhalt) | **ja** | + +**Wichtig:** Nach Änderungen an der Compose-Datei schlägt der Server automatisch vor, +das Projekt neu zu deployen (`redeploy_project`). + +### Images (`images.py`) + +| Tool | Beschreibung | Confirmation required | +|---|---|---| +| `check_image_updates` | Verfügbare Updates für alle Images eines Projekts prüfen | nein | + +--- + +## Authentifizierung & Session-Management + +- Login via `SYNO.API.Auth` (DSM Web API) +- Session-Token wird in-memory gehalten (kein persistentes Speichern) +- Automatischer Re-Login bei abgelaufener Session (401-Response) +- 2FA: Device-Token-Flow + - Device-Token wird im OS-Keyring gespeichert + - Kein OTP-Prompt im laufenden Betrieb + - Re-Bootstrap via `setup` wenn Token revoked + +--- + +## DSM API-Endpunkte + +| Funktion | API | +|---|---| +| Auth | `SYNO.API.Auth` | +| Projekte | `SYNO.Docker.Project` (list, start, stop, start_stream) | +| Container | `SYNO.Docker.Container` (list, start, stop, logs) | +| Container exec | `SYNO.Docker.Container` (exec) | +| Images | `SYNO.Docker.Image` (list) | +| Compose-Dateien lesen | `SYNO.FileStation.Download` | +| Compose-Dateien schreiben | `SYNO.FileStation.Upload` | + +--- + +## Sicherheitsregeln + +- Credentials niemals in Config-Datei, nur im OS-Keyring +- Confirmation-Pflicht für alle destruktiven Operationen (stop, redeploy, exec, compose-update) +- HTTPS mit Zertifikatsvalidierung standardmäßig aktiv (`verify_ssl: true`) +- Keine Secrets in stderr-Ausgaben + +--- + +## Referenzprojekte (zur Orientierung bei der Implementierung) + +| Projekt | Zweck | +|---|---| +| `cmeans/mcp-synology` (GitHub) | Referenz für Auth-Flow, Keyring-Integration, CLI-Struktur, Config-System | +| `N4S4/synology-api` `docker_api.py` (GitHub) | Referenz für DSM API-Calls (SYNO.Docker.*) | diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..5af667f --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,196 @@ +# Setup Guide + +## Prerequisites + +- Python 3.12 or higher +- A Synology NAS running DSM 7.x with **Container Manager** installed +- Network access to the NAS from the machine running Claude Desktop + +## Installation + +### With pip + +```bash +pip install mcp-synology-container +``` + +### With uv + +```bash +uv tool install mcp-synology-container +``` + +Verify installation: + +```bash +mcp-synology-container --help +``` + +--- + +## Running Setup + +```bash +mcp-synology-container setup +``` + +The wizard will prompt you for: + +| Prompt | Example | Notes | +|---|---|---| +| NAS hostname or IP | `192.168.1.100` or `nas.example.com` | | +| Use HTTPS? | `y` | Recommended | +| Port | `443` | Default for HTTPS | +| Verify SSL certificate? | `y` | Disable only for self-signed certs | +| Base path for compose projects | `/volume1/docker` | Where your compose projects live on the NAS | +| Alias | `HomeNAS` | Optional friendly name | +| DSM username | `admin` | DSM account with Container Manager access | +| DSM password | | Hidden input | + +### If 2FA is enabled + +If your DSM account has OTP enabled, the setup wizard will ask for your OTP code and store a device token in the OS keyring. Subsequent logins will use the device token — no OTP prompts during normal operation. + +If your device token is revoked, run `setup` again to re-bootstrap. + +--- + +## Verifying the Connection + +```bash +mcp-synology-container check +``` + +Example output: + +``` +Host: nas.example.com:443 +HTTPS: True +Verify SSL: True +Compose: /volume1/docker + +Credentials: found (user=admin, 2FA=yes) +API info: fetched successfully +Login: successful + +Required APIs: + SYNO.Docker.Container: v1-v1 ✓ + SYNO.Docker.Container.Log: v1-v1 ✓ + SYNO.Docker.Image: v1-v1 ✓ + SYNO.Docker.Project: v1-v1 ✓ + SYNO.FileStation.Download: v1-v2 ✓ + SYNO.FileStation.Upload: v1-v3 ✓ + +All checks passed. +``` + +Exit code 0 means everything is working. Exit code 1 means a problem was detected. + +--- + +## Configuring Claude Desktop + +After running `setup`, the wizard prints a configuration snippet. Add it to your Claude Desktop config file: + +**macOS/Linux**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "synology-container": { + "command": "mcp-synology-container", + "args": ["serve"] + } + } +} +``` + +Restart Claude Desktop after editing the config. + +--- + +## Config File Location + +The config is saved to: + +``` +~/.config/mcp-synology-container/config.yaml +``` + +Example config: + +```yaml +# Generated by mcp-synology-container setup +schema_version: 1 +alias: HomeNAS +connection: + host: nas.example.com + port: 443 + https: true + verify_ssl: true +compose_base_path: /volume1/docker +``` + +**Credentials are NOT stored in this file.** They are stored in the OS keyring. + +--- + +## Alternative: Environment Variables + +If you prefer not to use the keyring (e.g. in Docker or CI environments), set these environment variables: + +```bash +export SYNOLOGY_HOST=192.168.1.100 +export SYNOLOGY_USERNAME=admin +export SYNOLOGY_PASSWORD=yourpassword +``` + +Environment variables take priority over the keyring. + +You can also specify the config path: + +```bash +export MCP_SYNOLOGY_CONTAINER_CONFIG=/path/to/config.yaml +``` + +Or pass it explicitly: + +```bash +mcp-synology-container serve --config /path/to/config.yaml +``` + +--- + +## DSM User Permissions + +The DSM account used by the MCP server needs: +- Access to **Container Manager** (DSM > Control Panel > User > Applications) +- **Read/Write** access to the shared folder where compose projects are stored + +For read-only use (listing projects and viewing logs), read access is sufficient. + +--- + +## Troubleshooting + +**Connection refused / timeout** +- Check NAS hostname and port +- Verify the NAS is reachable from your machine +- Try `mcp-synology-container check --verbose` + +**Login failed** +- Run `mcp-synology-container setup` to re-enter credentials +- Check DSM > Control Panel > Security > Auto Block for IP blocks + +**2FA fails** +- Run `mcp-synology-container setup` again to get a fresh device token +- If your account has app-specific 2FA enforcement, ensure the device token was saved + +**Container Manager APIs not found** +- Ensure Container Manager is installed and running in DSM Package Center +- The package may appear as "Docker" in older DSM versions + +**SSL certificate errors** +- For self-signed certificates: run `setup` again and answer `n` to "Verify SSL certificate?" +- Alternatively set `verify_ssl: false` in your config file diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..81ebd8d --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,274 @@ +# Tool Reference + +This document describes all MCP tools provided by mcp-synology-container. + +Tools marked **confirmation required** will return a description of the action and ask you to call the tool again with `confirmed=True` before executing. This prevents accidental destructive operations. + +--- + +## Project Tools + +### `list_projects` + +List all Container Manager projects with their current status. + +**Parameters:** none + +**Returns:** Formatted table with project name, status, path, and container count. + +**Example output:** +``` +Projects: + + myapp + Status: RUNNING + Path: /volume1/docker/myapp + Containers: 2 + + database + Status: STOPPED + Path: /volume1/docker/database + Containers: 0 +``` + +--- + +### `get_project_status` + +Get detailed status of a specific project. + +**Parameters:** +- `project_name` (string, required): Name of the project + +**Returns:** Detailed status including ID, path, timestamps, container IDs, and services. + +--- + +### `start_project` + +Start a stopped Container Manager project. + +**Parameters:** +- `project_name` (string, required): Name of the project to start + +**Returns:** Success or error message. + +--- + +### `stop_project` + +Stop a running Container Manager project. Stops all containers in the project. + +**Confirmation required.** + +**Parameters:** +- `project_name` (string, required): Name of the project to stop +- `confirmed` (boolean, default `false`): Set to `true` to confirm + +**Returns:** Confirmation prompt or success message. + +--- + +### `redeploy_project` + +Redeploy a project by pulling latest images, stopping, and restarting. + +**Confirmation required.** + +**Parameters:** +- `project_name` (string, required): Name of the project to redeploy +- `confirmed` (boolean, default `false`): Set to `true` to confirm + +**Steps performed:** +1. Pull latest images (`SYNO.Docker.Project` build) +2. Stop all containers +3. Start all containers + +**Returns:** Confirmation prompt or step-by-step progress output. + +--- + +## Container Tools + +### `list_containers` + +List containers, optionally filtered by project. + +**Parameters:** +- `project_name` (string, optional): Filter by project name. Lists all containers if omitted. + +**Returns:** Formatted list with container name, status, and image. + +--- + +### `get_container_status` + +Get detailed status and resource information for a container. + +**Parameters:** +- `container_name` (string, required): Name of the container + +**Returns:** Status, running state, image, start/stop times, memory limits, and environment variable count. + +--- + +### `get_container_logs` + +Fetch log output from a container. + +**Parameters:** +- `container_name` (string, required): Name of the container +- `tail` (integer, default `100`): Number of recent log lines to return +- `keyword` (string, optional): Filter logs to lines containing this keyword + +**Returns:** Log lines with timestamps and stream tags (stdout/stderr). + +**Example:** +``` +Logs for myapp_web (showing 50 of 312): + +2025-01-01T10:00:00Z [stdout] Server started on port 8080 +2025-01-01T10:00:01Z [stderr] Warning: deprecated config option +``` + +--- + +### `exec_in_container` + +Execute a shell command in a running container. + +**Confirmation required.** + +**Parameters:** +- `container_name` (string, required): Name of the container +- `command` (string, required): Shell command to execute +- `confirmed` (boolean, default `false`): Set to `true` to confirm + +**Returns:** Confirmation prompt, or exit code and command output. + +--- + +## Compose File Tools + +Compose files are accessed via the Synology FileStation API. The server looks for the following filenames under `{compose_base_path}/{project_name}/`: + +- `docker-compose.yml` +- `docker-compose.yaml` +- `compose.yml` +- `compose.yaml` + +### `read_compose` + +Read the compose file of a project. + +**Parameters:** +- `project_name` (string, required): Name of the project + +**Returns:** File path and full YAML content. + +--- + +### `update_image_tag` + +Update the image tag for a service in the compose file. + +**Confirmation required.** + +**Parameters:** +- `project_name` (string, required): Name of the project +- `service_name` (string, required): Name of the service in the compose file +- `new_tag` (string, required): New image tag (e.g. `"latest"`, `"1.2.3"`) +- `confirmed` (boolean, default `false`): Set to `true` to confirm + +**Returns:** Confirmation prompt showing before/after, or success message with redeploy suggestion. + +**Example confirmation prompt:** +``` +About to update service 'web' in project 'myapp': + Before: nginx:1.24 + After: nginx:1.25 + +Call this tool again with confirmed=True to apply the change. +``` + +After applying: suggests running `redeploy_project`. + +--- + +### `update_env_var` + +Add or update an environment variable for a service. + +**Confirmation required.** + +**Parameters:** +- `project_name` (string, required): Name of the project +- `service_name` (string, required): Name of the service +- `var_name` (string, required): Environment variable name +- `var_value` (string, required): New value +- `confirmed` (boolean, default `false`): Set to `true` to confirm + +Supports both list format (`- KEY=VALUE`) and dict format (`KEY: VALUE`) in the compose file. + +After applying: suggests running `redeploy_project`. + +--- + +### `update_compose` + +Replace the entire compose file with new content. + +**Confirmation required.** + +**Parameters:** +- `project_name` (string, required): Name of the project +- `new_content` (string, required): Complete new YAML content (must contain a `services` key) +- `confirmed` (boolean, default `false`): Set to `true` to confirm + +Validates that the content is valid YAML with a `services` key before writing. + +After applying: suggests running `redeploy_project`. + +--- + +## Image Tools + +### `check_image_updates` + +Check which images have updates available. + +**Parameters:** +- `project_name` (string, optional): Filter to images used by this project. Checks all images if omitted. + +**Returns:** List of images with update status. Images with the `upgradable` flag from the NAS are highlighted. + +**Example output:** +``` +Image update status for project 'myapp': + +Updates available (1): + nginx:1.24 (50 MiB) ← UPDATE AVAILABLE + +Up to date (1): + postgres:15 (80 MiB) +``` + +--- + +## Confirmation Pattern + +Tools that modify state require confirmation to prevent accidents. The pattern is: + +1. Call the tool without `confirmed`: + ``` + stop_project(project_name="myapp") + ``` + Returns a description of what will happen. + +2. Call again with `confirmed=True`: + ``` + stop_project(project_name="myapp", confirmed=True) + ``` + Executes the operation. + +This applies to: `stop_project`, `redeploy_project`, `exec_in_container`, `update_image_tag`, `update_env_var`, `update_compose`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..55c994e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "mcp-synology-container" +version = "0.1.0" +description = "MCP server for Synology Container Manager" +requires-python = ">=3.12" +dependencies = [ + "mcp>=1.0.0", + "httpx>=0.27.0", + "pyyaml>=6.0", + "keyring>=25.0.0", + "click>=8.1.0", + "rich>=13.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[project.scripts] +mcp-synology-container = "mcp_synology_container.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_synology_container"] + +[tool.ruff] +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "TCH"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/mcp_synology_container/__init__.py b/src/mcp_synology_container/__init__.py new file mode 100644 index 0000000..f054651 --- /dev/null +++ b/src/mcp_synology_container/__init__.py @@ -0,0 +1,3 @@ +"""MCP server for Synology Container Manager.""" + +__version__ = "0.1.0" diff --git a/src/mcp_synology_container/auth.py b/src/mcp_synology_container/auth.py new file mode 100644 index 0000000..0f05054 --- /dev/null +++ b/src/mcp_synology_container/auth.py @@ -0,0 +1,191 @@ +"""Authentication manager: credentials, keyring, login, 2FA device token. + +Credential resolution order: +1. Environment variables (SYNOLOGY_USERNAME, SYNOLOGY_PASSWORD) +2. OS Keyring (set by 'setup' command) + +Session tokens are held in memory only — never persisted to disk. +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mcp_synology_container.config import AppConfig + from mcp_synology_container.dsm_client import DsmClient + +logger = logging.getLogger(__name__) + +# DSM Auth API error code for 2FA required +_ERROR_2FA_REQUIRED = 403 + +# Keyring keys +_KEY_USERNAME = "username" +_KEY_PASSWORD = "password" +_KEY_DEVICE_TOKEN = "device_token" + + +class AuthenticationError(Exception): + """Raised when authentication fails.""" + + def __init__(self, message: str, code: int | None = None) -> None: + self.code = code + super().__init__(message) + + +class AuthManager: + """Manages DSM credentials and session lifecycle.""" + + def __init__(self, config: AppConfig) -> None: + self._config = config + + def resolve_credentials(self) -> tuple[str, str, str | None]: + """Resolve username, password, and optional device token. + + Returns: + Tuple of (username, password, device_token_or_None). + + Raises: + AuthenticationError: If no credentials are available. + """ + username: str | None = None + password: str | None = None + device_token: str | None = None + + # 1. Environment variables (highest priority) + username = os.environ.get("SYNOLOGY_USERNAME") + password = os.environ.get("SYNOLOGY_PASSWORD") + if username: + logger.debug("Username from env var SYNOLOGY_USERNAME") + if password: + logger.debug("Password from env var SYNOLOGY_PASSWORD") + + # 2. OS Keyring + if not username or not password: + try: + import keyring + + service = self._config.keyring_service + kr_user = keyring.get_password(service, _KEY_USERNAME) + kr_pass = keyring.get_password(service, _KEY_PASSWORD) + kr_device = keyring.get_password(service, _KEY_DEVICE_TOKEN) + + if kr_user and not username: + username = kr_user + logger.debug("Username from keyring") + if kr_pass and not password: + password = kr_pass + logger.debug("Password from keyring") + if kr_device and not device_token: + device_token = kr_device + logger.debug("Device token from keyring") + except Exception: + logger.debug("Keyring not available or failed") + + if not username or not password: + msg = ( + "No credentials found. Run 'mcp-synology-container setup' to store " + "credentials in the OS keyring, or set SYNOLOGY_USERNAME and " + "SYNOLOGY_PASSWORD environment variables." + ) + raise AuthenticationError(msg) + + return username, password, device_token + + def store_credentials(self, username: str, password: str) -> bool: + """Store credentials in the OS keyring. + + Returns: + True on success, False if keyring is unavailable. + """ + try: + import keyring + + service = self._config.keyring_service + keyring.set_password(service, _KEY_USERNAME, username) + keyring.set_password(service, _KEY_PASSWORD, password) + logger.debug("Credentials stored in keyring: service=%s", service) + return True + except Exception as e: + logger.warning("Failed to store credentials in keyring: %s", e) + return False + + def store_device_token(self, device_token: str) -> None: + """Store 2FA device token in the OS keyring.""" + import keyring + + service = self._config.keyring_service + keyring.set_password(service, _KEY_DEVICE_TOKEN, device_token) + logger.debug("Device token stored in keyring: service=%s", service) + + async def login(self, client: DsmClient) -> str: + """Authenticate against DSM API and return session ID. + + Handles both simple login and 2FA device token flow. + The caller is responsible for storing the returned SID. + + Args: + client: DsmClient instance to use for the request. + + Returns: + Session ID string. + + Raises: + AuthenticationError: If login fails. + """ + username, password, device_token = self.resolve_credentials() + + params: dict[str, Any] = { + "account": username, + "passwd": password, + "format": "sid", + } + + if device_token: + logger.debug("Login: using 2FA device token") + params["device_id"] = device_token + params["device_name"] = "MCPSynologyContainer" + else: + logger.debug("Login: simple (no 2FA device token)") + + from mcp_synology_container.dsm_client import SynologyError + + try: + data = await client.request("SYNO.API.Auth", "login", version=6, params=params) + except SynologyError as e: + if e.code == _ERROR_2FA_REQUIRED: + raise AuthenticationError( + "2FA is required but no device token is available. " + "Run 'mcp-synology-container setup' to complete 2FA setup.", + code=_ERROR_2FA_REQUIRED, + ) from e + raise AuthenticationError(str(e), code=e.code) from e + + sid: str | None = data.get("sid") + if not sid: + raise AuthenticationError("Login succeeded but no session ID was returned.") + + logger.info("Authenticated as '%s'", username) + return sid + + async def logout(self, client: DsmClient) -> None: + """Log out the current session. + + Args: + client: DsmClient instance with active session. + """ + if not client.sid: + return + + logger.debug("Logging out session") + try: + from mcp_synology_container.dsm_client import SynologyError + + await client.request("SYNO.API.Auth", "logout", version=6, params={}) + except SynologyError: + logger.debug("Logout failed (session may have already expired)") + finally: + client.sid = None diff --git a/src/mcp_synology_container/cli.py b/src/mcp_synology_container/cli.py new file mode 100644 index 0000000..278c71c --- /dev/null +++ b/src/mcp_synology_container/cli.py @@ -0,0 +1,317 @@ +"""CLI entry point: setup, check, serve commands.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +from typing import Any + +import click + +logger = logging.getLogger(__name__) + + +def _configure_logging(level: str = "warning") -> None: + """Configure stderr logging.""" + numeric = getattr(logging, level.upper(), logging.WARNING) + logging.basicConfig( + level=numeric, + format="%(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + ) + + +@click.group() +def main() -> None: + """MCP server for Synology Container Manager.""" + + +# ────────────────────────────────────────────────────────────────────────── +# setup +# ────────────────────────────────────────────────────────────────────────── + + +@main.command() +@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging") +def setup(verbose: bool) -> None: + """Interactive setup wizard: configure connection and store credentials.""" + _configure_logging("debug" if verbose else "warning") + asyncio.run(_run_setup()) + + +async def _run_setup() -> None: + """Interactive setup flow.""" + from mcp_synology_container.auth import AuthManager, AuthenticationError + from mcp_synology_container.config import AppConfig, ConnectionConfig, CONFIG_PATH, save_config + from mcp_synology_container.dsm_client import DsmClient, SynologyError + + click.echo("=== mcp-synology-container setup ===\n") + + # Connection details + host = click.prompt("NAS hostname or IP") + use_https = click.confirm("Use HTTPS?", default=True) + default_port = 443 if use_https else 5000 + port = click.prompt("Port", default=default_port, type=int) + + verify_ssl = True + if use_https: + verify_ssl = click.confirm("Verify SSL certificate?", default=True) + + compose_base = click.prompt( + "Base path for compose projects on NAS", + default="/volume1/docker", + ) + alias_input = click.prompt("Alias (friendly name, optional)", default="", show_default=False) + alias: str | None = alias_input.strip() or None + + config = AppConfig( + schema_version=1, + connection=ConnectionConfig( + host=host, + port=port, + https=use_https, + verify_ssl=verify_ssl, + ), + compose_base_path=compose_base, + alias=alias, + ) + + click.echo() + username = click.prompt("DSM username") + password = click.prompt("DSM password", hide_input=True) + + # Store in keyring + auth = AuthManager(config) + keyring_ok = auth.store_credentials(username, password) + if keyring_ok: + click.echo("Credentials stored in OS keyring.") + else: + click.echo( + click.style("Warning: keyring unavailable.", fg="yellow") + + " Set SYNOLOGY_USERNAME and SYNOLOGY_PASSWORD environment variables instead." + ) + + # Test connection + click.echo("\nTesting connection...") + base_url = config.base_url + + try: + async with DsmClient(base_url, verify_ssl=verify_ssl) as client: + await client.query_api_info() + + # Attempt login with possible 2FA handling + params: dict[str, Any] = { + "account": username, + "passwd": password, + "format": "sid", + } + try: + data = await client.request("SYNO.API.Auth", "login", version=6, params=params) + sid = data.get("sid") + except SynologyError as e: + if e.code == 403: + # 2FA required + click.echo( + click.style("2FA is enabled.", fg="yellow") + + " Enter the OTP code from your authenticator app." + ) + otp_code = click.prompt("OTP code") + data = await client.request( + "SYNO.API.Auth", + "login", + version=6, + params={ + "account": username, + "passwd": password, + "otp_code": otp_code, + "enable_device_token": "yes", + "device_name": "MCPSynologyContainer", + "format": "sid", + }, + ) + sid = data.get("sid") + device_token = data.get("did", "") + if device_token and keyring_ok: + auth.store_device_token(device_token) + click.echo(click.style("2FA device token stored in keyring.", fg="green")) + else: + click.echo(click.style(f"Login failed: {e}", fg="red"), err=True) + sys.exit(1) + + if sid: + client.sid = sid + click.echo(click.style("Login successful!", fg="green")) + # Logout cleanly + try: + await client.request("SYNO.API.Auth", "logout", version=6, params={}) + except SynologyError: + pass + else: + click.echo(click.style("Login failed: no session ID returned.", fg="red"), err=True) + sys.exit(1) + + except Exception as e: + click.echo(click.style(f"Connection failed: {e}", fg="red"), err=True) + sys.exit(1) + + # Save config + save_config(config) + click.echo(f"\nConfig saved to {CONFIG_PATH}") + + # Emit Claude Desktop snippet + _emit_desktop_snippet() + + +def _emit_desktop_snippet() -> None: + """Print the Claude Desktop configuration snippet.""" + import shutil + + cmd = shutil.which("mcp-synology-container") or "mcp-synology-container" + snippet = { + "mcpServers": { + "synology-container": { + "command": cmd, + "args": ["serve"], + } + } + } + click.echo("\nAdd this to your Claude Desktop config (claude_desktop_config.json):\n") + click.echo(json.dumps(snippet, indent=2)) + + +# ────────────────────────────────────────────────────────────────────────── +# check +# ────────────────────────────────────────────────────────────────────────── + + +@main.command() +@click.option("--config", "-c", "config_path", type=click.Path(), help="Config file path") +@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging") +def check(config_path: str | None, verbose: bool) -> None: + """Test connection to DSM and print status.""" + _configure_logging("debug" if verbose else "warning") + ok = asyncio.run(_run_check(config_path)) + sys.exit(0 if ok else 1) + + +async def _run_check(config_path: str | None) -> bool: + """Run connectivity check. Returns True on success.""" + from mcp_synology_container.auth import AuthManager, AuthenticationError + from mcp_synology_container.config import load_config + from mcp_synology_container.dsm_client import DsmClient, SynologyError + + try: + config = load_config(config_path) + except (FileNotFoundError, ValueError) as e: + click.echo(click.style(f"Config error: {e}", fg="red"), err=True) + return False + + host = config.connection.host + port = config.connection.port + click.echo(f"Host: {host}:{port}") + click.echo(f"HTTPS: {config.connection.https}") + click.echo(f"Verify SSL: {config.connection.verify_ssl}") + click.echo(f"Compose: {config.compose_base_path}") + if config.alias: + click.echo(f"Alias: {config.alias}") + click.echo() + + auth = AuthManager(config) + try: + username, _, device_token = auth.resolve_credentials() + click.echo(f"Credentials: found (user={username}, 2FA={'yes' if device_token else 'no'})") + except AuthenticationError as e: + click.echo(click.style(f"Credentials: {e}", fg="red"), err=True) + return False + + try: + async with DsmClient(config.base_url, config.connection.verify_ssl) as client: + await client.query_api_info() + click.echo("API info: fetched successfully") + + client.set_auth_manager(auth) + sid = await auth.login(client) + client.sid = sid + click.echo(click.style("Login: successful", fg="green")) + + # Check Docker API availability + docker_apis = [ + "SYNO.Docker.Project", + "SYNO.Docker.Container", + "SYNO.Docker.Container.Log", + "SYNO.Docker.Image", + "SYNO.FileStation.Download", + "SYNO.FileStation.Upload", + ] + click.echo("\nRequired APIs:") + all_ok = True + for api_name in docker_apis: + if api_name in client._api_cache: + info = client._api_cache[api_name] + click.echo(f" {api_name}: v{info['minVersion']}-v{info['maxVersion']} ✓") + else: + click.echo(click.style(f" {api_name}: NOT FOUND", fg="red")) + all_ok = False + + await auth.logout(client) + + if all_ok: + click.echo(click.style("\nAll checks passed.", fg="green")) + else: + click.echo( + click.style( + "\nSome APIs not found. Ensure Container Manager is installed.", fg="yellow" + ) + ) + return all_ok + + except SynologyError as e: + click.echo(click.style(f"DSM error: {e}", fg="red"), err=True) + return False + except Exception as e: + click.echo(click.style(f"Connection error: {e}", fg="red"), err=True) + return False + + +# ────────────────────────────────────────────────────────────────────────── +# serve +# ────────────────────────────────────────────────────────────────────────── + + +@main.command() +@click.option("--config", "-c", "config_path", type=click.Path(), help="Config file path") +def serve(config_path: str | None) -> None: + """Start the MCP server in stdio mode.""" + _configure_logging("warning") + asyncio.run(_run_serve(config_path)) + + +async def _run_serve(config_path: str | None) -> None: + """Initialize and run the MCP server.""" + from mcp_synology_container.auth import AuthManager + from mcp_synology_container.config import load_config + from mcp_synology_container.dsm_client import DsmClient + from mcp_synology_container.server import create_server + + try: + config = load_config(config_path) + except (FileNotFoundError, ValueError) as e: + click.echo(click.style(f"Config error: {e}", fg="red"), err=True) + sys.exit(1) + + async with DsmClient(config.base_url, config.connection.verify_ssl) as client: + await client.query_api_info() + auth = AuthManager(config) + client.set_auth_manager(auth) + # Login on startup + try: + client.sid = await auth.login(client) + except Exception as e: + logger.error("Initial login failed: %s", e) + sys.exit(1) + + mcp_server = create_server(config, client) + logger.info("MCP server starting (stdio mode)") + await mcp_server.run_stdio_async() diff --git a/src/mcp_synology_container/config.py b/src/mcp_synology_container/config.py new file mode 100644 index 0000000..6e736f5 --- /dev/null +++ b/src/mcp_synology_container/config.py @@ -0,0 +1,192 @@ +"""YAML config loading and validation. + +Config file path: ~/.config/mcp-synology-container/config.yaml + +Loading order: +1. Parse YAML file +2. Merge environment variable overrides +3. Validate with dataclasses +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +logger = logging.getLogger(__name__) + +CONFIG_DIR = Path.home() / ".config" / "mcp-synology-container" +CONFIG_PATH = CONFIG_DIR / "config.yaml" +CURRENT_SCHEMA_VERSION = 1 + +# Environment variable overrides (env var -> config key path) +ENV_VAR_MAP: dict[str, str] = { + "SYNOLOGY_HOST": "connection.host", + "SYNOLOGY_PORT": "connection.port", + "SYNOLOGY_HTTPS": "connection.https", + "SYNOLOGY_VERIFY_SSL": "connection.verify_ssl", + "SYNOLOGY_USERNAME": "credentials.username", + "SYNOLOGY_PASSWORD": "credentials.password", +} + + +@dataclass +class ConnectionConfig: + """NAS connection settings.""" + + host: str + port: int = 443 + https: bool = True + verify_ssl: bool = True + + +@dataclass +class AppConfig: + """Top-level application configuration.""" + + schema_version: int + connection: ConnectionConfig + compose_base_path: str = "/volume1/docker" + alias: str | None = None + + @property + def base_url(self) -> str: + """Build base URL for DSM API calls.""" + scheme = "https" if self.connection.https else "http" + return f"{scheme}://{self.connection.host}:{self.connection.port}" + + @property + def keyring_service(self) -> str: + """Keyring service name derived from host.""" + return f"mcp-synology-container/{self.connection.host}" + + +def _merge_env_overrides(raw: dict[str, Any]) -> dict[str, Any]: + """Merge environment variable overrides into raw config dict.""" + for env_var, dotted_path in ENV_VAR_MAP.items(): + value = os.environ.get(env_var) + if value is None: + continue + + logger.debug("Env override: %s -> %s", env_var, dotted_path) + parts = dotted_path.split(".") + target = raw + for part in parts[:-1]: + if part not in target or not isinstance(target[part], dict): + target[part] = {} + target = target[part] + + key = parts[-1] + # Type coercion for known non-string values + if key == "port": + target[key] = int(value) + elif key in ("https", "verify_ssl"): + target[key] = value.lower() in ("true", "1", "yes") + else: + target[key] = value + + return raw + + +def load_config(path: str | Path | None = None) -> AppConfig: + """Load, validate, and return the application config. + + Args: + path: Explicit config file path, or None to use default location. + + Raises: + FileNotFoundError: If config file does not exist. + ValueError: If config is invalid. + """ + config_path = Path(path) if path else CONFIG_PATH + + if not config_path.exists(): + msg = ( + f"Config file not found: {config_path}\n" + "Run 'mcp-synology-container setup' to create it." + ) + raise FileNotFoundError(msg) + + logger.debug("Loading config from %s", config_path) + raw_text = config_path.read_text(encoding="utf-8") + raw: dict[str, Any] = yaml.safe_load(raw_text) or {} + + raw = _merge_env_overrides(raw) + + return _validate_config(raw) + + +def _validate_config(raw: dict[str, Any]) -> AppConfig: + """Validate raw config dict and return AppConfig. + + Args: + raw: Raw config dictionary from YAML. + + Raises: + ValueError: If required fields are missing or invalid. + """ + schema_version = raw.get("schema_version") + if schema_version != CURRENT_SCHEMA_VERSION: + msg = ( + f"Config schema_version is {schema_version!r}, " + f"expected {CURRENT_SCHEMA_VERSION}." + ) + raise ValueError(msg) + + conn_raw = raw.get("connection", {}) + if not conn_raw.get("host"): + msg = ( + "connection.host is required. " + "Set it in the config file or via SYNOLOGY_HOST environment variable." + ) + raise ValueError(msg) + + connection = ConnectionConfig( + host=conn_raw["host"], + port=int(conn_raw.get("port", 443)), + https=bool(conn_raw.get("https", True)), + verify_ssl=bool(conn_raw.get("verify_ssl", True)), + ) + + return AppConfig( + schema_version=schema_version, + connection=connection, + compose_base_path=raw.get("compose_base_path", "/volume1/docker"), + alias=raw.get("alias"), + ) + + +def save_config(config: AppConfig, path: str | Path | None = None) -> None: + """Save AppConfig to YAML file. + + Args: + config: AppConfig instance to save. + path: Target file path, or None to use default location. + """ + config_path = Path(path) if path else CONFIG_PATH + config_path.parent.mkdir(parents=True, exist_ok=True) + + data: dict[str, Any] = { + "schema_version": config.schema_version, + "connection": { + "host": config.connection.host, + "port": config.connection.port, + "https": config.connection.https, + "verify_ssl": config.connection.verify_ssl, + }, + "compose_base_path": config.compose_base_path, + } + if config.alias: + data["alias"] = config.alias + + header = "# Generated by mcp-synology-container setup\n" + config_path.write_text( + header + yaml.dump(data, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) + logger.debug("Config saved to %s", config_path) diff --git a/src/mcp_synology_container/dsm_client.py b/src/mcp_synology_container/dsm_client.py new file mode 100644 index 0000000..a2933e2 --- /dev/null +++ b/src/mcp_synology_container/dsm_client.py @@ -0,0 +1,362 @@ +"""DSM HTTP client with session management and auto-re-login. + +Thin async client wrapping Synology DSM Web API conventions: +- GET-only requests (DSM APIs work with GET params) +- Session ID injection (_sid parameter) +- Automatic re-login on session errors (codes 106, 107, 119) +- File upload via POST multipart (SYNO.FileStation.Upload only) +- File download via streaming GET (SYNO.FileStation.Download) +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +import httpx + +if TYPE_CHECKING: + from mcp_synology_container.auth import AuthManager + +logger = logging.getLogger(__name__) + +# Session error codes that trigger transparent re-auth +_SESSION_ERROR_CODES = frozenset({106, 107, 119}) + +# Parameters to mask in debug logging +_SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"}) + + +class SynologyError(Exception): + """Raised when DSM API returns a non-success response.""" + + def __init__(self, message: str, code: int | None = None) -> None: + self.code = code + super().__init__(message) + + +def _error_message(code: int, api: str = "") -> str: + """Map DSM error code to human-readable message.""" + # Common codes + common = { + 100: "Unknown error", + 101: "Invalid parameter", + 102: "API does not exist on this NAS", + 103: "Method does not exist", + 104: "API version not supported", + 105: "Permission denied — check DSM user permissions", + 106: "Session timeout", + 107: "Session displaced by another login", + 119: "Session invalid", + } + # Auth codes + auth = { + 400: "Incorrect username or password", + 401: "Account disabled", + 402: "Permission denied for this service", + 403: "2FA code required", + 404: "2FA code incorrect or expired", + 407: "Too many failed login attempts — account temporarily locked", + 408: "IP blocked due to excessive failed attempts", + } + # Docker API codes + docker = { + 1: "Project not found", + 2: "Container not found", + } + + if "Auth" in api and code in auth: + return auth[code] + if code in common: + return common[code] + return f"DSM error code {code}" + + +class DsmClient: + """Async HTTP client for Synology DSM API. + + Usage: + async with DsmClient(base_url, verify_ssl=True) as client: + await client.query_api_info() + auth_manager = AuthManager(config) + await auth_manager.login(client) + data = await client.request("SYNO.Docker.Project", "list") + """ + + def __init__( + self, + base_url: str, + verify_ssl: bool = True, + timeout: int = 30, + ) -> None: + self._base_url = base_url.rstrip("/") + self._verify_ssl = verify_ssl + self._timeout = timeout + self._http: httpx.AsyncClient | None = None + self._api_cache: dict[str, dict[str, Any]] = {} + self._sid: str | None = None + self._auth_manager: AuthManager | None = None + self._reauth_lock = asyncio.Lock() + logger.debug( + "DsmClient: base_url=%s verify_ssl=%s timeout=%d", + self._base_url, + verify_ssl, + timeout, + ) + + @property + def sid(self) -> str | None: + """Current session ID.""" + return self._sid + + @sid.setter + def sid(self, value: str | None) -> None: + self._sid = value + + def set_auth_manager(self, auth_manager: AuthManager) -> None: + """Register the AuthManager for automatic re-login on session errors.""" + self._auth_manager = auth_manager + + async def __aenter__(self) -> DsmClient: + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + self._http = httpx.AsyncClient( + verify=self._verify_ssl, + timeout=self._timeout, + ) + return self + + async def __aexit__(self, *args: object) -> None: + if self._http: + await self._http.aclose() + self._http = None + + def _get_http(self) -> httpx.AsyncClient: + if self._http is None: + msg = "DsmClient must be used as an async context manager." + raise RuntimeError(msg) + return self._http + + async def query_api_info(self) -> dict[str, dict[str, Any]]: + """Query SYNO.API.Info to discover all available APIs and cache them. + + Must be called before any other API requests. + + Returns: + Dict mapping API name -> {path, minVersion, maxVersion}. + """ + http = self._get_http() + url = f"{self._base_url}/webapi/query.cgi" + params = { + "api": "SYNO.API.Info", + "version": "1", + "method": "query", + "query": "ALL", + } + + logger.debug("Querying API info from %s", url) + resp = await http.get(url, params=params) + resp.raise_for_status() + body = resp.json() + + if not body.get("success"): + code = body.get("error", {}).get("code", 0) + raise SynologyError(_error_message(code, "SYNO.API.Info"), code=code) + + data: dict[str, Any] = body.get("data", {}) + self._api_cache = { + name: { + "path": info["path"], + "minVersion": info.get("minVersion", 1), + "maxVersion": info.get("maxVersion", 1), + } + for name, info in data.items() + } + logger.debug("API info cached: %d APIs available", len(self._api_cache)) + return self._api_cache + + async def request( + self, + api: str, + method: str, + version: int | None = None, + params: dict[str, Any] | None = None, + *, + _is_retry: bool = False, + ) -> dict[str, Any]: + """Make a GET request to the DSM API. + + Resolves the API path from the cache, injects session ID, + parses the response envelope, and handles errors. + + On session errors (106/107/119), re-authenticates and retries once. + + Args: + api: DSM API name (e.g. "SYNO.Docker.Project"). + method: API method (e.g. "list"). + version: API version. Defaults to maxVersion from API info. + params: Additional query parameters. + + Returns: + Response data dict from the "data" field of the envelope. + + Raises: + SynologyError: On API errors. + """ + http = self._get_http() + + if api not in self._api_cache: + raise SynologyError( + 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["maxVersion"] + url = f"{self._base_url}/webapi/{info['path']}" + + req_params: dict[str, Any] = { + "api": api, + "version": str(resolved_version), + "method": method, + } + if params: + req_params.update(params) + if self._sid: + req_params["_sid"] = self._sid + + # Log with sensitive fields masked + log_params = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in req_params.items()} + retry_tag = " (retry)" if _is_retry else "" + logger.debug("DSM GET%s: %s/%s v%d — %s", retry_tag, api, method, resolved_version, log_params) + + resp = await http.get(url, params=req_params) + resp.raise_for_status() + body = resp.json() + + if body.get("success"): + data: dict[str, Any] = body.get("data") or {} + logger.debug("DSM response: %s/%s — success", api, method) + return data + + code = body.get("error", {}).get("code", 0) + logger.debug("DSM response: %s/%s — error code %d", api, method, code) + + # Transparent re-auth on session errors (one retry only) + if code in _SESSION_ERROR_CODES and not _is_retry and self._auth_manager: + logger.info("Session error %d on %s/%s, re-authenticating...", code, api, method) + async with self._reauth_lock: + self._sid = None + try: + self._sid = await self._auth_manager.login(self) + except Exception as e: + raise SynologyError(f"Re-authentication failed: {e}", code=code) from e + return await self.request(api, method, version, params, _is_retry=True) + + raise SynologyError(_error_message(code, api), code=code) + + async def upload_text( + self, + dest_folder: str, + filename: str, + content: str, + *, + overwrite: bool = True, + ) -> dict[str, Any]: + """Upload text content as a file via SYNO.FileStation.Upload. + + Used for writing compose files to the NAS. + + Args: + dest_folder: Target folder path on NAS (e.g. "/volume1/docker/myapp"). + filename: Name for the uploaded file. + content: Text content to upload. + overwrite: Whether to overwrite existing file. + + Returns: + Response data dict. + """ + api = "SYNO.FileStation.Upload" + http = self._get_http() + + if api not in self._api_cache: + raise SynologyError(f"API '{api}' not found. Call query_api_info() first.", code=102) + + info = self._api_cache[api] + resolved_version = min(info["maxVersion"], 2) # Pin to v2 + 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": "true", + } + query_params: dict[str, str] = {} + if self._sid: + query_params["_sid"] = self._sid + + logger.debug("DSM POST: %s/upload v%d — path=%s filename=%s", api, resolved_version, dest_folder, filename) + + encoded = content.encode("utf-8") + resp = await http.post( + url, + params=query_params, + data=form_data, + files={"file": (filename, encoded, "text/plain")}, + timeout=httpx.Timeout(60.0), + ) + resp.raise_for_status() + body = resp.json() + + if body.get("success"): + return body.get("data") or {} + + code = body.get("error", {}).get("code", 0) + raise SynologyError(_error_message(code, api), code=code) + + async def download_text(self, path: str) -> str: + """Download a text file from the NAS via SYNO.FileStation.Download. + + Args: + path: Full file path on NAS. + + Returns: + File content as string. + """ + api = "SYNO.FileStation.Download" + http = self._get_http() + + if api not in self._api_cache: + raise SynologyError(f"API '{api}' not found. Call query_api_info() first.", code=102) + + info = self._api_cache[api] + resolved_version = info["maxVersion"] + 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 + + log_params = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in params.items()} + logger.debug("DSM GET: %s/download v%d — %s", api, resolved_version, log_params) + + resp = await http.get(url, params=params, timeout=httpx.Timeout(60.0)) + resp.raise_for_status() + + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type: + body = resp.json() + code = body.get("error", {}).get("code", 0) + raise SynologyError(_error_message(code, api), code=code) + + return resp.text diff --git a/src/mcp_synology_container/modules/__init__.py b/src/mcp_synology_container/modules/__init__.py new file mode 100644 index 0000000..a93ef4e --- /dev/null +++ b/src/mcp_synology_container/modules/__init__.py @@ -0,0 +1 @@ +"""MCP tool modules for Synology Docker API.""" diff --git a/src/mcp_synology_container/modules/compose.py b/src/mcp_synology_container/modules/compose.py new file mode 100644 index 0000000..0f67f6d --- /dev/null +++ b/src/mcp_synology_container/modules/compose.py @@ -0,0 +1,324 @@ +"""MCP tools for reading and writing compose files via FileStation API. + +Compose files are read/written via SYNO.FileStation.Download / Upload. +Supported filenames: docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import yaml + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from mcp_synology_container.config import AppConfig + from mcp_synology_container.dsm_client import DsmClient + +logger = logging.getLogger(__name__) + +# Recognized compose file names (in priority order) +_COMPOSE_FILENAMES = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +] + + +def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: + """Register all compose file management tools with the MCP server.""" + + @mcp.tool() + async def read_compose(project_name: str) -> str: + """Read the compose file of a project. + + Args: + project_name: Name of the Container Manager project. + + Returns: + The compose file content as YAML text. + """ + path = await _find_compose_path(client, config, project_name) + if path is None: + return ( + f"No compose file found for project '{project_name}'.\n" + f"Looked in {config.compose_base_path}/{project_name}/ for: " + + ", ".join(_COMPOSE_FILENAMES) + ) + + try: + content = await client.download_text(path) + except Exception as e: + return f"Error reading compose file '{path}': {e}" + + return f"Compose file: {path}\n\n{content}" + + @mcp.tool() + async def update_image_tag( + project_name: str, + service_name: str, + new_tag: str, + confirmed: bool = False, + ) -> str: + """Update the image tag of a service in the compose file. + + After confirming, suggests running redeploy_project. + + Args: + project_name: Name of the Container Manager project. + service_name: Name of the service within the compose file. + new_tag: New image tag (e.g. "latest", "1.2.3"). + confirmed: Must be True to proceed. Set to True to confirm the change. + """ + path = await _find_compose_path(client, config, project_name) + if path is None: + return f"No compose file found for project '{project_name}'." + + try: + content = await client.download_text(path) + except Exception as e: + return f"Error reading compose file: {e}" + + try: + compose = yaml.safe_load(content) + except yaml.YAMLError as e: + return f"Error parsing compose file: {e}" + + services = compose.get("services", {}) or {} + if service_name not in services: + available = ", ".join(sorted(services.keys())) + return f"Service '{service_name}' not found. Available services: {available}" + + current_image: str = services[service_name].get("image", "") + if not current_image: + return f"Service '{service_name}' has no 'image' field." + + # Parse current image into name and tag + if ":" in current_image: + image_name, current_tag = current_image.rsplit(":", 1) + else: + image_name = current_image + current_tag = "latest" + + new_image = f"{image_name}:{new_tag}" + + if not confirmed: + return ( + f"About to update service '{service_name}' in project '{project_name}':\n" + f" Before: {current_image}\n" + f" After: {new_image}\n\n" + f"Call this tool again with confirmed=True to apply the change." + ) + + services[service_name]["image"] = new_image + new_content = yaml.dump(compose, default_flow_style=False, sort_keys=False, allow_unicode=True) + + folder_path = path.rsplit("/", 1)[0] + filename = path.rsplit("/", 1)[1] + try: + await client.upload_text(folder_path, filename, new_content) + except Exception as e: + return f"Error writing compose file: {e}" + + return ( + f"Updated '{service_name}' image in '{project_name}':\n" + f" {current_image} → {new_image}\n\n" + f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change." + ) + + @mcp.tool() + async def update_env_var( + project_name: str, + service_name: str, + var_name: str, + var_value: str, + confirmed: bool = False, + ) -> str: + """Add or update an environment variable in a service's compose definition. + + After confirming, suggests running redeploy_project. + + Args: + project_name: Name of the Container Manager project. + service_name: Name of the service within the compose file. + var_name: Environment variable name. + var_value: New value for the variable. + confirmed: Must be True to proceed. Set to True to confirm the change. + """ + path = await _find_compose_path(client, config, project_name) + if path is None: + return f"No compose file found for project '{project_name}'." + + try: + content = await client.download_text(path) + except Exception as e: + return f"Error reading compose file: {e}" + + try: + compose = yaml.safe_load(content) + except yaml.YAMLError as e: + return f"Error parsing compose file: {e}" + + services = compose.get("services", {}) or {} + if service_name not in services: + available = ", ".join(sorted(services.keys())) + return f"Service '{service_name}' not found. Available services: {available}" + + service = services[service_name] + env_list = service.get("environment") or [] + + # Determine previous value and build description + old_value: str | None = None + if isinstance(env_list, list): + for i, entry in enumerate(env_list): + if isinstance(entry, str) and entry.startswith(f"{var_name}="): + old_value = entry.split("=", 1)[1] + break + elif entry == var_name: + old_value = "(no value)" + break + elif isinstance(env_list, dict): + old_value = str(env_list.get(var_name)) if var_name in env_list else None + + action = "update" if old_value is not None else "add" + + if not confirmed: + if old_value is not None: + return ( + f"About to update environment variable in '{service_name}' ({project_name}):\n" + f" {var_name}={old_value} → {var_name}={var_value}\n\n" + f"Call this tool again with confirmed=True to apply the change." + ) + else: + return ( + f"About to add environment variable to '{service_name}' ({project_name}):\n" + f" {var_name}={var_value}\n\n" + f"Call this tool again with confirmed=True to apply the change." + ) + + # Apply the change + if isinstance(env_list, list): + new_entry = f"{var_name}={var_value}" + updated = False + for i, entry in enumerate(env_list): + if isinstance(entry, str) and entry.startswith(f"{var_name}="): + env_list[i] = new_entry + updated = True + break + elif entry == var_name: + env_list[i] = new_entry + updated = True + break + if not updated: + env_list.append(new_entry) + service["environment"] = env_list + elif isinstance(env_list, dict): + env_list[var_name] = var_value + service["environment"] = env_list + else: + service["environment"] = [f"{var_name}={var_value}"] + + new_content = yaml.dump(compose, default_flow_style=False, sort_keys=False, allow_unicode=True) + + folder_path = path.rsplit("/", 1)[0] + filename = path.rsplit("/", 1)[1] + try: + await client.upload_text(folder_path, filename, new_content) + except Exception as e: + return f"Error writing compose file: {e}" + + return ( + f"{'Updated' if action == 'update' else 'Added'} env var in '{service_name}' ({project_name}):\n" + f" {var_name}={var_value}\n\n" + f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change." + ) + + @mcp.tool() + async def update_compose( + project_name: str, + new_content: str, + confirmed: bool = False, + ) -> str: + """Replace the entire compose file with new content. + + Validates that the content is valid YAML before writing. + After confirming, suggests running redeploy_project. + + Args: + project_name: Name of the Container Manager project. + new_content: Complete new content for the compose file (must be valid YAML). + confirmed: Must be True to proceed. Set to True to confirm the overwrite. + """ + # Validate YAML before anything else + try: + parsed = yaml.safe_load(new_content) + except yaml.YAMLError as e: + return f"Invalid YAML content: {e}" + + if not isinstance(parsed, dict) or "services" not in parsed: + return "Invalid compose file: must be a YAML document with a 'services' key." + + path = await _find_compose_path(client, config, project_name) + if path is None: + # Default to docker-compose.yml if no existing file found + path = f"{config.compose_base_path}/{project_name}/docker-compose.yml" + + service_count = len(parsed.get("services", {})) + + if not confirmed: + return ( + f"About to overwrite compose file for project '{project_name}':\n" + f" Path: {path}\n" + f" Services defined: {service_count}\n\n" + f"Call this tool again with confirmed=True to apply the change." + ) + + folder_path = path.rsplit("/", 1)[0] + filename = path.rsplit("/", 1)[1] + try: + await client.upload_text(folder_path, filename, new_content) + except Exception as e: + return f"Error writing compose file: {e}" + + return ( + f"Compose file updated for project '{project_name}'.\n" + f" Path: {path}\n" + f" Services: {service_count}\n\n" + f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change." + ) + + +async def _find_compose_path( + client: DsmClient, config: AppConfig, project_name: str +) -> str | None: + """Find the compose file path for a project. + + Tries each recognized filename under {compose_base_path}/{project_name}/. + + Args: + client: DsmClient instance. + config: AppConfig with compose_base_path. + project_name: Project name. + + Returns: + Full path to the compose file if found, None otherwise. + """ + base = f"{config.compose_base_path}/{project_name}" + + for filename in _COMPOSE_FILENAMES: + path = f"{base}/{filename}" + try: + # Try to list the file; if it exists, return the path + await client.request( + "SYNO.FileStation.Info", + "get", + params={"path": path, "additional": "[]"}, + ) + logger.debug("Found compose file: %s", path) + return path + except Exception: + continue + + return None diff --git a/src/mcp_synology_container/modules/containers.py b/src/mcp_synology_container/modules/containers.py new file mode 100644 index 0000000..0aa7108 --- /dev/null +++ b/src/mcp_synology_container/modules/containers.py @@ -0,0 +1,215 @@ +"""MCP tools for SYNO.Docker.Container: list, status, logs, exec.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from mcp_synology_container.config import AppConfig + from mcp_synology_container.dsm_client import DsmClient + +logger = logging.getLogger(__name__) + + +def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: + """Register all container management tools with the MCP server.""" + + @mcp.tool() + async def list_containers(project_name: str | None = None) -> str: + """List containers, optionally filtered by project name. + + Args: + project_name: Optional project name to filter containers. + If omitted, lists all containers. + """ + try: + data = await client.request( + "SYNO.Docker.Container", + "list", + params={"limit": "-1", "offset": "0", "type": "all"}, + ) + except Exception as e: + return f"Error listing containers: {e}" + + containers: list[dict[str, Any]] = data.get("containers", []) + if not containers: + return "No containers found." + + # Filter by project if specified + if project_name: + containers = [ + c for c in containers + if c.get("project_name") == project_name + or _container_in_project(c, project_name) + ] + if not containers: + return f"No containers found for project '{project_name}'." + + lines = [f"Containers ({len(containers)} total):", ""] + for container in sorted(containers, key=lambda c: c.get("name", "")): + name = container.get("name", "?") + state = container.get("status", container.get("state", "?")) + image = container.get("image", "?") + lines.append(f" {name}") + lines.append(f" Status: {state}") + lines.append(f" Image: {image}") + lines.append("") + + return "\n".join(lines).rstrip() + + @mcp.tool() + async def get_container_status(container_name: str) -> str: + """Get detailed status, uptime, and resource usage of a container. + + Args: + container_name: Name of the container to inspect. + """ + try: + data = await client.request( + "SYNO.Docker.Container", + "get", + params={"name": container_name}, + ) + except Exception as e: + return f"Error getting container '{container_name}': {e}" + + if not data: + return f"Container '{container_name}' not found." + + return _format_container_detail(container_name, data) + + @mcp.tool() + async def get_container_logs( + container_name: str, + tail: int = 100, + keyword: str | None = None, + ) -> str: + """Get log output from a container. + + Args: + container_name: Name of the container. + tail: Number of recent log lines to return (default 100). + keyword: Optional keyword to filter log lines. + """ + params: dict[str, Any] = { + "name": container_name, + "limit": tail, + "offset": 0, + "sort_dir": "DESC", + } + if keyword: + params["keyword"] = keyword + + try: + data = await client.request( + "SYNO.Docker.Container.Log", + "get", + params=params, + ) + except Exception as e: + return f"Error getting logs for '{container_name}': {e}" + + logs: list[dict[str, Any]] = data.get("logs", []) + if not logs: + return f"No logs found for container '{container_name}'." + + total = data.get("total", len(logs)) + header = f"Logs for {container_name} (showing {len(logs)} of {total}):\n" + + # Logs are returned in DESC order, reverse for chronological display + lines = [] + for entry in reversed(logs): + timestamp = entry.get("created", "") + stream = entry.get("stream", "") + text = entry.get("text", "") + stream_tag = f"[{stream}] " if stream else "" + lines.append(f"{timestamp} {stream_tag}{text}") + + return header + "\n".join(lines) + + @mcp.tool() + async def exec_in_container( + container_name: str, + command: str, + confirmed: bool = False, + ) -> str: + """Execute a command in a running container. + + This executes a shell command inside the container. Use with caution. + Requires confirmation before executing. + + Args: + container_name: Name of the container. + command: Shell command to execute. + confirmed: Must be True to proceed. Set to True to confirm execution. + """ + if not confirmed: + return ( + f"About to run in container '{container_name}':\n" + f" $ {command}\n\n" + f"Call this tool again with confirmed=True to proceed." + ) + + try: + data = await client.request( + "SYNO.Docker.Container", + "exec", + params={ + "name": container_name, + "command": command, + }, + ) + except Exception as e: + return f"Error executing command in '{container_name}': {e}" + + output = data.get("output", "") + exit_code = data.get("exit_code", 0) + + result_lines = [f"Command executed in '{container_name}':"] + result_lines.append(f" Exit code: {exit_code}") + if output: + result_lines.append(" Output:") + result_lines.append(output) + + return "\n".join(result_lines) + + +def _container_in_project(container: dict[str, Any], project_name: str) -> bool: + """Check if a container belongs to a project based on its labels.""" + labels = container.get("labels", {}) or {} + if isinstance(labels, dict): + return labels.get("com.docker.compose.project") == project_name + return False + + +def _format_container_detail(name: str, data: dict[str, Any]) -> str: + """Format container inspect data as human-readable text.""" + state = data.get("State", {}) or {} + config = data.get("Config", {}) or {} + host_config = data.get("HostConfig", {}) or {} + + lines = [ + f"Container: {name}", + f" Status: {state.get('Status', '?')}", + f" Running: {state.get('Running', False)}", + f" Image: {config.get('Image', '?')}", + ] + + if state.get("StartedAt"): + lines.append(f" Started: {state.get('StartedAt')}") + if state.get("FinishedAt") and not state.get("Running"): + lines.append(f" Finished: {state.get('FinishedAt')}") + lines.append(f" Exit code: {state.get('ExitCode', '?')}") + + memory = host_config.get("Memory", 0) + if memory: + mb = memory // (1024 * 1024) + lines.append(f" Memory limit: {mb} MiB") + + env = config.get("Env", []) or [] + if env: + lines.append(f" Env vars: {len(env)}") + + return "\n".join(lines) diff --git a/src/mcp_synology_container/modules/images.py b/src/mcp_synology_container/modules/images.py new file mode 100644 index 0000000..d9e724f --- /dev/null +++ b/src/mcp_synology_container/modules/images.py @@ -0,0 +1,145 @@ +"""MCP tools for SYNO.Docker.Image: list and check for updates.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from mcp_synology_container.config import AppConfig + from mcp_synology_container.dsm_client import DsmClient + +logger = logging.getLogger(__name__) + + +def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: + """Register all image management tools with the MCP server.""" + + @mcp.tool() + async def check_image_updates(project_name: str | None = None) -> str: + """Check for available image updates for a project or all images. + + Queries the local image list and reports which images have the + 'upgradable' flag set by the NAS registry check. + + Args: + project_name: Optional project name to filter images. + If omitted, checks all locally available images. + """ + try: + data = await client.request( + "SYNO.Docker.Image", + "list", + params={"limit": "-1", "offset": "0", "show_dsm": "false"}, + ) + except Exception as e: + return f"Error listing images: {e}" + + images: list[dict[str, Any]] = data.get("images", []) + if not images: + return "No images found." + + # If project_name given, cross-reference with project containers + if project_name: + images = await _filter_images_for_project(client, project_name, images) + if not images: + return f"No images found for project '{project_name}'." + header = f"Image update status for project '{project_name}':\n" + else: + header = f"Image update status (all {len(images)} images):\n" + + upgradable = [img for img in images if img.get("upgradable")] + up_to_date = [img for img in images if not img.get("upgradable")] + + lines = [header] + + if upgradable: + lines.append(f"Updates available ({len(upgradable)}):") + for img in sorted(upgradable, key=lambda x: x.get("repository", "")): + repo = img.get("repository", "?") + tags = ", ".join(img.get("tags", [])) + size_mb = img.get("size", 0) // (1024 * 1024) + lines.append(f" {repo}:{tags} ({size_mb} MiB) ← UPDATE AVAILABLE") + lines.append("") + + if up_to_date: + lines.append(f"Up to date ({len(up_to_date)}):") + for img in sorted(up_to_date, key=lambda x: x.get("repository", "")): + repo = img.get("repository", "?") + tags = ", ".join(img.get("tags", [])) + size_mb = img.get("size", 0) // (1024 * 1024) + lines.append(f" {repo}:{tags} ({size_mb} MiB)") + + if not upgradable: + lines.append("All images are up to date.") + + return "\n".join(lines) + + +async def _filter_images_for_project( + client: DsmClient, + project_name: str, + all_images: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Filter images to those used by a specific project. + + Fetches project details and cross-references container image IDs. + Falls back to name-based matching if project details unavailable. + + Args: + client: DsmClient instance. + project_name: Project name to filter for. + all_images: Full list of images from SYNO.Docker.Image. + + Returns: + Subset of images used by the project. + """ + # Get project details to find used images + try: + # Find project by name + list_data = await client.request("SYNO.Docker.Project", "list") + projects: dict[str, Any] = list_data if isinstance(list_data, dict) else {} + project_entry = next( + (p for p in projects.values() if p.get("name") == project_name), None + ) + + if not project_entry: + return [] + + project_id = project_entry.get("id", "") + # Get project detail which includes container image info + detail_data = await client.request( + "SYNO.Docker.Project", + "get", + params={"id": project_id}, + ) + + containers = detail_data.get("containers", []) or [] + image_ids: set[str] = set() + image_names: set[str] = set() + + for container in containers: + img_id = container.get("Image", "") + if img_id: + image_ids.add(img_id) + cfg_image = container.get("Config", {}).get("Image", "") + if cfg_image: + # Strip tag for name matching + name = cfg_image.split(":")[0] if ":" in cfg_image else cfg_image + image_names.add(name) + + # Match images + result = [] + for img in all_images: + img_id = img.get("id", "") + repo = img.get("repository", "") + if img_id in image_ids or repo in image_names: + result.append(img) + + return result + + except Exception as e: + logger.debug("Could not filter images for project '%s': %s", project_name, e) + # Fallback: return all images + return all_images diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py new file mode 100644 index 0000000..4ebf1a2 --- /dev/null +++ b/src/mcp_synology_container/modules/projects.py @@ -0,0 +1,227 @@ +"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from mcp_synology_container.config import AppConfig + from mcp_synology_container.dsm_client import DsmClient + +logger = logging.getLogger(__name__) + + +def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: + """Register all project management tools with the MCP server.""" + + @mcp.tool() + async def list_projects() -> str: + """List all Container Manager projects with their current status. + + Returns a formatted table of projects including name, status, path, + and container count. + """ + try: + data = await client.request("SYNO.Docker.Project", "list") + except Exception as e: + return f"Error listing projects: {e}" + + projects: dict[str, Any] = data if isinstance(data, dict) else {} + if not projects: + return "No projects found." + + lines = ["Projects:", ""] + for project_id, proj in sorted(projects.items(), key=lambda x: x[1].get("name", "")): + name = proj.get("name", "?") + status = proj.get("status", "?") + path = proj.get("path", "?") + container_count = len(proj.get("containerIds", [])) + lines.append(f" {name}") + lines.append(f" Status: {status}") + lines.append(f" Path: {path}") + lines.append(f" Containers: {container_count}") + lines.append("") + + return "\n".join(lines).rstrip() + + @mcp.tool() + async def get_project_status(project_name: str) -> str: + """Get detailed status of a specific project. + + Args: + project_name: Name of the project to inspect. + """ + project = await _find_project(client, project_name) + if project is None: + return f"Project '{project_name}' not found." + + return _format_project_detail(project) + + @mcp.tool() + async def start_project(project_name: str) -> str: + """Start a Container Manager project. + + Args: + project_name: Name of the project to start. + """ + project = await _find_project(client, project_name) + if project is None: + return f"Project '{project_name}' not found." + + project_id = project.get("id", "") + try: + await client.request( + "SYNO.Docker.Project", + "start", + params={"id": project_id}, + ) + return f"Project '{project_name}' started successfully." + except Exception as e: + return f"Error starting project '{project_name}': {e}" + + @mcp.tool() + async def stop_project(project_name: str, confirmed: bool = False) -> str: + """Stop a running Container Manager project. + + This operation stops all containers in the project. + Requires confirmation before executing. + + Args: + project_name: Name of the project to stop. + confirmed: Must be True to proceed. Set to True to confirm the stop operation. + """ + if not confirmed: + return ( + f"Stopping project '{project_name}' will halt all its containers.\n" + f"Call this tool again with confirmed=True to proceed." + ) + + project = await _find_project(client, project_name) + if project is None: + return f"Project '{project_name}' not found." + + project_id = project.get("id", "") + try: + await client.request( + "SYNO.Docker.Project", + "stop", + params={"id": project_id}, + ) + return f"Project '{project_name}' stopped successfully." + except Exception as e: + return f"Error stopping project '{project_name}': {e}" + + @mcp.tool() + async def redeploy_project(project_name: str, confirmed: bool = False) -> str: + """Redeploy a project: pull latest images, stop, and restart. + + This operation will briefly take the project offline. + Requires confirmation before executing. + + Args: + project_name: Name of the project to redeploy. + confirmed: Must be True to proceed. Set to True to confirm the redeploy. + """ + if not confirmed: + return ( + f"Redeploying project '{project_name}' will:\n" + f" 1. Pull latest images\n" + f" 2. Stop all containers\n" + f" 3. Restart with new images\n\n" + f"Call this tool again with confirmed=True to proceed." + ) + + project = await _find_project(client, project_name) + if project is None: + return f"Project '{project_name}' not found." + + project_id = project.get("id", "") + results = [] + + try: + # Step 1: Pull latest images via build (triggers compose pull) + results.append("Step 1/3: Pulling latest images...") + try: + await client.request( + "SYNO.Docker.Project", + "build", + params={"id": project_id, "force": "true"}, + ) + results.append(" Images pulled.") + except Exception as e: + results.append(f" Warning: pull step failed ({e}), continuing with restart.") + + # Step 2: Stop the project + results.append("Step 2/3: Stopping project...") + await client.request( + "SYNO.Docker.Project", + "stop", + params={"id": project_id}, + ) + results.append(" Project stopped.") + + # Step 3: Start the project + results.append("Step 3/3: Starting project...") + await client.request( + "SYNO.Docker.Project", + "start", + params={"id": project_id}, + ) + results.append(" Project started.") + + results.append(f"\nProject '{project_name}' redeployed successfully.") + + except Exception as e: + results.append(f"Error during redeploy: {e}") + + return "\n".join(results) + + +async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None: + """Find a project by name from the list. + + Args: + client: DsmClient instance. + name: Project name to search for. + + Returns: + Project dict if found, None otherwise. + """ + try: + data = await client.request("SYNO.Docker.Project", "list") + except Exception: + return None + + projects: dict[str, Any] = data if isinstance(data, dict) else {} + for project in projects.values(): + if project.get("name") == name: + return dict(project) + return None + + +def _format_project_detail(project: dict[str, Any]) -> str: + """Format project details as human-readable text.""" + lines = [ + f"Project: {project.get('name', '?')}", + f" ID: {project.get('id', '?')}", + f" Status: {project.get('status', '?')}", + f" Path: {project.get('path', '?')}", + f" Share path: {project.get('share_path', '?')}", + f" Created: {project.get('created_at', '?')}", + f" Updated: {project.get('updated_at', '?')}", + ] + + container_ids = project.get("containerIds", []) + lines.append(f" Containers: {len(container_ids)}") + for cid in container_ids: + lines.append(f" - {cid[:12]}") + + services = project.get("services") or [] + if services: + lines.append(f" Services: {len(services)}") + for svc in services: + lines.append(f" - {svc.get('display_name', '?')}") + + return "\n".join(lines) diff --git a/src/mcp_synology_container/server.py b/src/mcp_synology_container/server.py new file mode 100644 index 0000000..62d5426 --- /dev/null +++ b/src/mcp_synology_container/server.py @@ -0,0 +1,40 @@ +"""MCP server factory: creates and configures the FastMCP instance.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from mcp.server.fastmcp import FastMCP + +if TYPE_CHECKING: + from mcp_synology_container.config import AppConfig + from mcp_synology_container.dsm_client import DsmClient + +logger = logging.getLogger(__name__) + + +def create_server(config: AppConfig, client: DsmClient) -> FastMCP: + """Create and configure the MCP server with all tools registered. + + Args: + config: Application configuration. + client: Authenticated DsmClient instance. + + Returns: + Configured FastMCP server ready to run. + """ + mcp = FastMCP("mcp-synology-container") + + from mcp_synology_container.modules.projects import register_projects + from mcp_synology_container.modules.containers import register_containers + from mcp_synology_container.modules.compose import register_compose + from mcp_synology_container.modules.images import register_images + + register_projects(mcp, config, client) + register_containers(mcp, config, client) + register_compose(mcp, config, client) + register_images(mcp, config, client) + + logger.info("MCP server configured with all tool modules") + return mcp diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..6f8411e --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,167 @@ +"""Tests for auth.py.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from mcp_synology_container.auth import AuthManager, AuthenticationError +from mcp_synology_container.config import AppConfig, ConnectionConfig + + +def make_config(host: str = "nas.local") -> AppConfig: + return AppConfig( + schema_version=1, + connection=ConnectionConfig(host=host, port=443, https=True, verify_ssl=True), + ) + + +def test_resolve_credentials_from_env(monkeypatch): + monkeypatch.setenv("SYNOLOGY_USERNAME", "admin") + monkeypatch.setenv("SYNOLOGY_PASSWORD", "secret") + + config = make_config() + auth = AuthManager(config) + username, password, device_token = auth.resolve_credentials() + + assert username == "admin" + assert password == "secret" + assert device_token is None + + +def test_resolve_credentials_no_credentials(monkeypatch): + monkeypatch.delenv("SYNOLOGY_USERNAME", raising=False) + monkeypatch.delenv("SYNOLOGY_PASSWORD", raising=False) + + config = make_config() + auth = AuthManager(config) + + with patch("keyring.get_password", return_value=None): + with pytest.raises(AuthenticationError, match="No credentials found"): + auth.resolve_credentials() + + +def test_resolve_credentials_from_keyring(monkeypatch): + monkeypatch.delenv("SYNOLOGY_USERNAME", raising=False) + monkeypatch.delenv("SYNOLOGY_PASSWORD", raising=False) + + def mock_get_password(service, key): + data = {"username": "keyring_user", "password": "keyring_pass", "device_token": "tok123"} + return data.get(key) + + config = make_config() + auth = AuthManager(config) + + with patch("keyring.get_password", side_effect=mock_get_password): + username, password, device_token = auth.resolve_credentials() + + assert username == "keyring_user" + assert password == "keyring_pass" + assert device_token == "tok123" + + +def test_store_credentials_success(): + config = make_config() + auth = AuthManager(config) + + with patch("keyring.set_password") as mock_set: + result = auth.store_credentials("user", "pass") + + assert result is True + assert mock_set.call_count == 2 + + +def test_store_credentials_keyring_unavailable(): + config = make_config() + auth = AuthManager(config) + + with patch("keyring.set_password", side_effect=Exception("no keyring")): + result = auth.store_credentials("user", "pass") + + assert result is False + + +@pytest.mark.asyncio +async def test_login_success(): + config = make_config() + auth = AuthManager(config) + + mock_client = AsyncMock() + mock_client.request.return_value = {"sid": "test_session_id"} + + with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)): + sid = await auth.login(mock_client) + + assert sid == "test_session_id" + mock_client.request.assert_called_once() + call_kwargs = mock_client.request.call_args + assert call_kwargs[0][0] == "SYNO.API.Auth" + assert call_kwargs[0][1] == "login" + + +@pytest.mark.asyncio +async def test_login_with_device_token(): + config = make_config() + auth = AuthManager(config) + + mock_client = AsyncMock() + mock_client.request.return_value = {"sid": "test_sid"} + + with patch.object(auth, "resolve_credentials", return_value=("user", "pass", "dev_token")): + sid = await auth.login(mock_client) + + assert sid == "test_sid" + params = mock_client.request.call_args[1]["params"] + assert params["device_id"] == "dev_token" + + +@pytest.mark.asyncio +async def test_login_2fa_required(): + from mcp_synology_container.dsm_client import SynologyError + + config = make_config() + auth = AuthManager(config) + + mock_client = AsyncMock() + mock_client.request.side_effect = SynologyError("2FA required", code=403) + + with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)): + with pytest.raises(AuthenticationError, match="2FA is required"): + await auth.login(mock_client) + + +@pytest.mark.asyncio +async def test_login_no_sid_returned(): + config = make_config() + auth = AuthManager(config) + + mock_client = AsyncMock() + mock_client.request.return_value = {} # No 'sid' key + + with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)): + with pytest.raises(AuthenticationError, match="no session ID"): + await auth.login(mock_client) + + +@pytest.mark.asyncio +async def test_logout(): + config = make_config() + auth = AuthManager(config) + + mock_client = AsyncMock() + mock_client.sid = "active_sid" + + await auth.logout(mock_client) + + mock_client.request.assert_called_once() + assert mock_client.sid is None + + +@pytest.mark.asyncio +async def test_logout_no_session(): + config = make_config() + auth = AuthManager(config) + + mock_client = AsyncMock() + mock_client.sid = None + + await auth.logout(mock_client) + mock_client.request.assert_not_called() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..fe2a4b0 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,168 @@ +"""Tests for config.py.""" + +import pytest +import yaml +from pathlib import Path + +from mcp_synology_container.config import ( + AppConfig, + ConnectionConfig, + _validate_config, + _merge_env_overrides, + load_config, + save_config, +) + + +def test_validate_config_minimal(): + raw = { + "schema_version": 1, + "connection": {"host": "nas.local"}, + } + config = _validate_config(raw) + assert config.connection.host == "nas.local" + assert config.connection.port == 443 # default when https=True + assert config.connection.https is True + assert config.connection.verify_ssl is True + assert config.compose_base_path == "/volume1/docker" + assert config.alias is None + + +def test_validate_config_full(): + raw = { + "schema_version": 1, + "alias": "HomeNAS", + "compose_base_path": "/volume2/containers", + "connection": { + "host": "192.168.1.100", + "port": 5001, + "https": True, + "verify_ssl": False, + }, + } + config = _validate_config(raw) + assert config.alias == "HomeNAS" + assert config.compose_base_path == "/volume2/containers" + assert config.connection.host == "192.168.1.100" + assert config.connection.port == 5001 + assert config.connection.verify_ssl is False + + +def test_validate_config_wrong_schema_version(): + raw = { + "schema_version": 99, + "connection": {"host": "nas.local"}, + } + with pytest.raises(ValueError, match="schema_version"): + _validate_config(raw) + + +def test_validate_config_missing_host(): + raw = { + "schema_version": 1, + "connection": {}, + } + with pytest.raises(ValueError, match="connection.host"): + _validate_config(raw) + + +def test_validate_config_missing_connection(): + raw = {"schema_version": 1} + with pytest.raises(ValueError, match="connection.host"): + _validate_config(raw) + + +def test_merge_env_overrides_host(monkeypatch): + monkeypatch.setenv("SYNOLOGY_HOST", "192.168.1.50") + raw: dict = {"schema_version": 1, "connection": {}} + result = _merge_env_overrides(raw) + assert result["connection"]["host"] == "192.168.1.50" + + +def test_merge_env_overrides_port(monkeypatch): + monkeypatch.setenv("SYNOLOGY_PORT", "8080") + raw: dict = {"schema_version": 1, "connection": {}} + result = _merge_env_overrides(raw) + assert result["connection"]["port"] == 8080 # coerced to int + + +def test_merge_env_overrides_https_true(monkeypatch): + monkeypatch.setenv("SYNOLOGY_HTTPS", "true") + raw: dict = {"schema_version": 1, "connection": {}} + result = _merge_env_overrides(raw) + assert result["connection"]["https"] is True + + +def test_merge_env_overrides_https_false(monkeypatch): + monkeypatch.setenv("SYNOLOGY_HTTPS", "false") + raw: dict = {"schema_version": 1, "connection": {}} + result = _merge_env_overrides(raw) + assert result["connection"]["https"] is False + + +def test_base_url_https(): + config = AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + ) + assert config.base_url == "https://nas.local:443" + + +def test_base_url_http(): + config = AppConfig( + schema_version=1, + connection=ConnectionConfig(host="192.168.1.1", port=5000, https=False, verify_ssl=True), + ) + assert config.base_url == "http://192.168.1.1:5000" + + +def test_keyring_service(): + config = AppConfig( + schema_version=1, + connection=ConnectionConfig(host="mynas.local", port=443, https=True, verify_ssl=True), + ) + assert config.keyring_service == "mcp-synology-container/mynas.local" + + +def test_load_config_file_not_found(): + with pytest.raises(FileNotFoundError): + load_config("/nonexistent/path/config.yaml") + + +def test_save_and_load_config(tmp_path): + config = AppConfig( + schema_version=1, + connection=ConnectionConfig( + host="test.nas.local", + port=5001, + https=True, + verify_ssl=False, + ), + compose_base_path="/data/docker", + alias="TestNAS", + ) + + config_file = tmp_path / "config.yaml" + save_config(config, config_file) + + assert config_file.exists() + loaded = load_config(config_file) + + assert loaded.connection.host == "test.nas.local" + assert loaded.connection.port == 5001 + assert loaded.connection.https is True + assert loaded.connection.verify_ssl is False + assert loaded.compose_base_path == "/data/docker" + assert loaded.alias == "TestNAS" + + +def test_save_config_no_alias(tmp_path): + config = AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + ) + config_file = tmp_path / "config.yaml" + save_config(config, config_file) + + raw = yaml.safe_load(config_file.read_text()) + assert "alias" not in raw diff --git a/tests/test_modules/__init__.py b/tests/test_modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_modules/test_compose.py b/tests/test_modules/test_compose.py new file mode 100644 index 0000000..143a001 --- /dev/null +++ b/tests/test_modules/test_compose.py @@ -0,0 +1,236 @@ +"""Tests for modules/compose.py.""" + +import pytest +from unittest.mock import AsyncMock, patch + +import yaml + + +def make_mock_mcp(): + tools: dict = {} + + class MockMCP: + def tool(self): + def decorator(fn): + tools[fn.__name__] = fn + return fn + return decorator + + return MockMCP(), tools + + +def make_config(): + from mcp_synology_container.config import AppConfig, ConnectionConfig + return AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + compose_base_path="/volume1/docker", + ) + + +SAMPLE_COMPOSE = """ +services: + web: + image: nginx:1.24 + ports: + - "80:80" + environment: + - APP_ENV=production + - LOG_LEVEL=info + db: + image: postgres:15 + environment: + POSTGRES_DB: mydb + POSTGRES_USER: admin +""" + + +@pytest.mark.asyncio +async def test_read_compose(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + # Simulate FileStation.Info success for the first filename + client.request.return_value = {} + client.download_text.return_value = SAMPLE_COMPOSE + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["read_compose"]("myapp") + assert "nginx:1.24" in result + assert "postgres:15" in result + + +@pytest.mark.asyncio +async def test_read_compose_not_found(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + # Simulate all FileStation.Info calls failing + from mcp_synology_container.dsm_client import SynologyError + client.request.side_effect = SynologyError("not found", code=408) + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["read_compose"]("nonexistent") + assert "No compose file found" in result + + +@pytest.mark.asyncio +async def test_update_image_tag_requires_confirmation(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + client.request.return_value = {} + client.download_text.return_value = SAMPLE_COMPOSE + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=False) + assert "confirmed=True" in result + assert "nginx:1.24" in result + assert "nginx:1.25" in result + client.upload_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_image_tag_confirmed(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + client.request.return_value = {} + client.download_text.return_value = SAMPLE_COMPOSE + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=True) + assert "nginx:1.24 → nginx:1.25" in result + assert "redeploy_project" in result + client.upload_text.assert_called_once() + + # Verify the uploaded content has the new tag + uploaded_content = client.upload_text.call_args[0][2] + parsed = yaml.safe_load(uploaded_content) + assert parsed["services"]["web"]["image"] == "nginx:1.25" + + +@pytest.mark.asyncio +async def test_update_image_tag_service_not_found(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + client.request.return_value = {} + client.download_text.return_value = SAMPLE_COMPOSE + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_image_tag"]("myapp", "nonexistent", "1.25", confirmed=True) + assert "not found" in result + assert "web" in result # should list available services + + +@pytest.mark.asyncio +async def test_update_env_var_new_var_list_format(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + client.request.return_value = {} + client.download_text.return_value = SAMPLE_COMPOSE + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_env_var"]("myapp", "web", "NEW_VAR", "value123", confirmed=True) + assert "NEW_VAR=value123" in result + + uploaded_content = client.upload_text.call_args[0][2] + parsed = yaml.safe_load(uploaded_content) + env = parsed["services"]["web"]["environment"] + assert any("NEW_VAR=value123" in str(e) for e in env) + + +@pytest.mark.asyncio +async def test_update_env_var_update_existing_list(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + client.request.return_value = {} + client.download_text.return_value = SAMPLE_COMPOSE + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_env_var"]("myapp", "web", "LOG_LEVEL", "debug", confirmed=True) + assert "LOG_LEVEL=debug" in result + + uploaded_content = client.upload_text.call_args[0][2] + parsed = yaml.safe_load(uploaded_content) + env = parsed["services"]["web"]["environment"] + assert "LOG_LEVEL=debug" in env + assert "LOG_LEVEL=info" not in env + + +@pytest.mark.asyncio +async def test_update_env_var_dict_format(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + client.request.return_value = {} + client.download_text.return_value = SAMPLE_COMPOSE + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + # db service has dict-format environment + result = await tools["update_env_var"]("myapp", "db", "POSTGRES_DB", "newdb", confirmed=True) + assert "POSTGRES_DB=newdb" in result + + uploaded_content = client.upload_text.call_args[0][2] + parsed = yaml.safe_load(uploaded_content) + assert parsed["services"]["db"]["environment"]["POSTGRES_DB"] == "newdb" + + +@pytest.mark.asyncio +async def test_update_compose_invalid_yaml(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_compose"]("myapp", "not: valid: yaml: {{{{", confirmed=True) + assert "Invalid YAML" in result + + +@pytest.mark.asyncio +async def test_update_compose_missing_services_key(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_compose"]("myapp", "version: '3'\n", confirmed=True) + assert "services" in result + + +@pytest.mark.asyncio +async def test_update_compose_requires_confirmation(): + from mcp_synology_container.modules.compose import register_compose + + client = AsyncMock() + client.request.return_value = {} + + mcp, tools = make_mock_mcp() + register_compose(mcp, make_config(), client) + + result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False) + assert "confirmed=True" in result + client.upload_text.assert_not_called() diff --git a/tests/test_modules/test_containers.py b/tests/test_modules/test_containers.py new file mode 100644 index 0000000..94d1aee --- /dev/null +++ b/tests/test_modules/test_containers.py @@ -0,0 +1,173 @@ +"""Tests for modules/containers.py.""" + +import pytest +from unittest.mock import AsyncMock + + +def make_mock_mcp(): + tools: dict = {} + + class MockMCP: + def tool(self): + def decorator(fn): + tools[fn.__name__] = fn + return fn + return decorator + + return MockMCP(), tools + + +def make_config(): + from mcp_synology_container.config import AppConfig, ConnectionConfig + return AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + ) + + +SAMPLE_CONTAINERS_DATA = { + "containers": [ + { + "name": "myapp_web", + "status": "running", + "image": "nginx:alpine", + "project_name": "myapp", + }, + { + "name": "myapp_db", + "status": "running", + "image": "postgres:15", + "project_name": "myapp", + }, + { + "name": "other_svc", + "status": "stopped", + "image": "redis:7", + "project_name": "other", + }, + ] +} + +SAMPLE_LOGS_DATA = { + "logs": [ + { + "created": "2025-01-01T10:00:00Z", + "stream": "stdout", + "text": "Server started", + "docid": "1", + }, + { + "created": "2025-01-01T10:00:01Z", + "stream": "stderr", + "text": "Warning: deprecated option", + "docid": "2", + }, + ], + "total": 2, +} + + +@pytest.mark.asyncio +async def test_list_containers_all(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_CONTAINERS_DATA + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["list_containers"]() + assert "myapp_web" in result + assert "myapp_db" in result + assert "other_svc" in result + + +@pytest.mark.asyncio +async def test_list_containers_filtered_by_project(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_CONTAINERS_DATA + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["list_containers"](project_name="myapp") + assert "myapp_web" in result + assert "myapp_db" in result + assert "other_svc" not in result + + +@pytest.mark.asyncio +async def test_list_containers_empty(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = {"containers": []} + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["list_containers"]() + assert "No containers found" in result + + +@pytest.mark.asyncio +async def test_get_container_logs(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_LOGS_DATA + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["get_container_logs"]("myapp_web", tail=50) + assert "myapp_web" in result + assert "Server started" in result + assert "Warning: deprecated option" in result + + +@pytest.mark.asyncio +async def test_get_container_logs_with_keyword(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = SAMPLE_LOGS_DATA + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + await tools["get_container_logs"]("myapp_web", tail=100, keyword="error") + call_params = client.request.call_args[1]["params"] + assert call_params["keyword"] == "error" + + +@pytest.mark.asyncio +async def test_exec_in_container_requires_confirmation(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=False) + assert "confirmed=True" in result + client.request.assert_not_called() + + +@pytest.mark.asyncio +async def test_exec_in_container_confirmed(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = {"output": "file1.py\nfile2.py", "exit_code": 0} + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True) + assert "file1.py" in result + assert "Exit code: 0" in result diff --git a/tests/test_modules/test_images.py b/tests/test_modules/test_images.py new file mode 100644 index 0000000..22c1dd1 --- /dev/null +++ b/tests/test_modules/test_images.py @@ -0,0 +1,154 @@ +"""Tests for modules/images.py.""" + +import pytest +from unittest.mock import AsyncMock + + +def make_mock_mcp(): + tools: dict = {} + + class MockMCP: + def tool(self): + def decorator(fn): + tools[fn.__name__] = fn + return fn + return decorator + + return MockMCP(), tools + + +def make_config(): + from mcp_synology_container.config import AppConfig, ConnectionConfig + return AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + ) + + +SAMPLE_IMAGES = { + "images": [ + { + "id": "sha256:aaaa", + "repository": "nginx", + "tags": ["1.24"], + "size": 50 * 1024 * 1024, + "upgradable": True, + }, + { + "id": "sha256:bbbb", + "repository": "postgres", + "tags": ["15"], + "size": 80 * 1024 * 1024, + "upgradable": False, + }, + { + "id": "sha256:cccc", + "repository": "redis", + "tags": ["7"], + "size": 30 * 1024 * 1024, + "upgradable": False, + }, + ] +} + + +@pytest.mark.asyncio +async def test_check_image_updates_all(): + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + client.request.return_value = SAMPLE_IMAGES + + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + result = await tools["check_image_updates"]() + assert "nginx:1.24" in result + assert "UPDATE AVAILABLE" in result + assert "postgres:15" in result + + +@pytest.mark.asyncio +async def test_check_image_updates_all_up_to_date(): + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + client.request.return_value = { + "images": [ + {"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"], "size": 50 * 1024 * 1024, "upgradable": False}, + ] + } + + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + result = await tools["check_image_updates"]() + assert "All images are up to date" in result + + +@pytest.mark.asyncio +async def test_check_image_updates_no_images(): + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + client.request.return_value = {"images": []} + + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + result = await tools["check_image_updates"]() + assert "No images found" in result + + +@pytest.mark.asyncio +async def test_check_image_updates_api_error(): + from mcp_synology_container.modules.images import register_images + from mcp_synology_container.dsm_client import SynologyError + + client = AsyncMock() + client.request.side_effect = SynologyError("API unavailable", code=102) + + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + result = await tools["check_image_updates"]() + assert "Error" in result + + +@pytest.mark.asyncio +async def test_check_image_updates_for_project(): + from mcp_synology_container.modules.images import register_images + + project_list = { + "uuid-1": { + "id": "uuid-1", + "name": "myapp", + "status": "RUNNING", + "containerIds": ["abc123"], + } + } + project_detail = { + "containers": [ + {"Image": "sha256:aaaa", "Config": {"Image": "nginx:1.24"}}, + ] + } + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image": + return SAMPLE_IMAGES + if api == "SYNO.Docker.Project" and method == "list": + return project_list + if api == "SYNO.Docker.Project" and method == "get": + return project_detail + return {} + + client.request.side_effect = mock_request + + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + result = await tools["check_image_updates"](project_name="myapp") + assert "myapp" in result + assert "nginx:1.24" in result diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py new file mode 100644 index 0000000..113870d --- /dev/null +++ b/tests/test_modules/test_projects.py @@ -0,0 +1,163 @@ +"""Tests for modules/projects.py.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from mcp_synology_container.modules.projects import _find_project, _format_project_detail + + +SAMPLE_PROJECTS = { + "uuid-1": { + "id": "uuid-1", + "name": "myapp", + "status": "RUNNING", + "path": "/volume1/docker/myapp", + "share_path": "/docker/myapp", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z", + "containerIds": ["abc123def456"], + "services": [{"display_name": "myapp (project)"}], + }, + "uuid-2": { + "id": "uuid-2", + "name": "database", + "status": "STOPPED", + "path": "/volume1/docker/database", + "share_path": "/docker/database", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "containerIds": [], + "services": [], + }, +} + + +@pytest.mark.asyncio +async def test_find_project_found(): + client = AsyncMock() + client.request.return_value = SAMPLE_PROJECTS + + result = await _find_project(client, "myapp") + assert result is not None + assert result["name"] == "myapp" + assert result["status"] == "RUNNING" + + +@pytest.mark.asyncio +async def test_find_project_not_found(): + client = AsyncMock() + client.request.return_value = SAMPLE_PROJECTS + + result = await _find_project(client, "nonexistent") + assert result is None + + +@pytest.mark.asyncio +async def test_find_project_api_error(): + client = AsyncMock() + client.request.side_effect = Exception("API error") + + result = await _find_project(client, "myapp") + assert result is None + + +def test_format_project_detail(): + project = SAMPLE_PROJECTS["uuid-1"] + output = _format_project_detail(project) + + assert "myapp" in output + assert "RUNNING" in output + assert "/volume1/docker/myapp" in output + assert "uuid-1" in output + + +def test_format_project_detail_no_containers(): + project = SAMPLE_PROJECTS["uuid-2"] + output = _format_project_detail(project) + + assert "database" in output + assert "STOPPED" in output + assert "Containers: 0" in output + + +@pytest.mark.asyncio +async def test_list_projects_tool(): + """Test list_projects tool via function registration.""" + from mcp_synology_container.modules.projects import register_projects + from mcp_synology_container.config import AppConfig, ConnectionConfig + + config = AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + ) + client = AsyncMock() + client.request.return_value = SAMPLE_PROJECTS + + tools: dict = {} + + class MockMCP: + def tool(self): + def decorator(fn): + tools[fn.__name__] = fn + return fn + return decorator + + register_projects(MockMCP(), config, client) + assert "list_projects" in tools + + result = await tools["list_projects"]() + assert "myapp" in result + assert "database" in result + assert "RUNNING" in result + + +@pytest.mark.asyncio +async def test_stop_project_requires_confirmation(): + from mcp_synology_container.modules.projects import register_projects + from mcp_synology_container.config import AppConfig, ConnectionConfig + + config = AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + ) + client = AsyncMock() + tools: dict = {} + + class MockMCP: + def tool(self): + def decorator(fn): + tools[fn.__name__] = fn + return fn + return decorator + + register_projects(MockMCP(), config, client) + + result = await tools["stop_project"]("myapp", confirmed=False) + assert "confirmed=True" in result + client.request.assert_not_called() + + +@pytest.mark.asyncio +async def test_redeploy_project_requires_confirmation(): + from mcp_synology_container.modules.projects import register_projects + from mcp_synology_container.config import AppConfig, ConnectionConfig + + config = AppConfig( + schema_version=1, + connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), + ) + client = AsyncMock() + tools: dict = {} + + class MockMCP: + def tool(self): + def decorator(fn): + tools[fn.__name__] = fn + return fn + return decorator + + register_projects(MockMCP(), config, client) + + result = await tools["redeploy_project"]("myapp", confirmed=False) + assert "confirmed=True" in result + client.request.assert_not_called()