Initial implementation
This commit is contained in:
+19
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.*) |
|
||||||
+196
@@ -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
|
||||||
+274
@@ -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`.
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""MCP server for Synology Container Manager."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""MCP tool modules for Synology Docker API."""
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user