From 9fc5a3d68c4312c739b125c0e2c1d3605f145cdb Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 14 Apr 2026 07:51:51 +0200 Subject: [PATCH] feat: initial project structure --- .claudeignore | 1 + .gitignore | 8 + CLAUDE.md | 95 +++++ README.md | 40 ++ SPEC.md | 385 ++++++++++++++++++ pyproject.toml | 49 +++ src/mcp_synology_filestation/__init__.py | 3 + src/mcp_synology_filestation/__main__.py | 6 + src/mcp_synology_filestation/auth.py | 2 + src/mcp_synology_filestation/client.py | 2 + src/mcp_synology_filestation/config.py | 2 + src/mcp_synology_filestation/server.py | 2 + .../tools/__init__.py | 0 .../tools/filestation.py | 2 + tests/__init__.py | 0 15 files changed, 597 insertions(+) create mode 100644 .claudeignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 pyproject.toml create mode 100644 src/mcp_synology_filestation/__init__.py create mode 100644 src/mcp_synology_filestation/__main__.py create mode 100644 src/mcp_synology_filestation/auth.py create mode 100644 src/mcp_synology_filestation/client.py create mode 100644 src/mcp_synology_filestation/config.py create mode 100644 src/mcp_synology_filestation/server.py create mode 100644 src/mcp_synology_filestation/tools/__init__.py create mode 100644 src/mcp_synology_filestation/tools/filestation.py create mode 100644 tests/__init__.py diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..feead5b --- /dev/null +++ b/.claudeignore @@ -0,0 +1 @@ +reference/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1da286e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# .gitignore +reference/ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +dist/ +*.egg-info/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7fbdb0d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md — mcp-synology-filestation + +Development context for this project. + +## Project + +`mcp-synology-filestation` — MCP server exposing Synology FileStation as Claude tools. +Sibling project to `mcp-synology-container`. + +## Infrastructure + +- **NAS:** `https://dsm.gecheckt.de` +- **Config file:** `~/.config/mcp-synology-filestation/config.yaml` +- **Credentials:** OS keyring, service name `mcp-synology-filestation` +- **Gitea:** `https://gitea.gecheckt.de/marcus/mcp-synology-filestation` +- **Local code:** `D:\Dev\Projects\mcp-synology-filestation` +- **Runtime:** Python 3.12+, `uv`, MCP SDK, `httpx`, `keyring`, `click`, `rich` + +## Deploy Workflow + +1. Commit and push via Claude Code. +2. `uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git` +3. Restart Claude Desktop. + +## Toolchain + +| Task | Command | +|------|---------| +| Format | `ruff format src/ tests/` | +| Lint | `ruff check src/ tests/` | +| Tests | `pytest` | +| Install dev | `uv sync --dev` | +| Run server | `uv run mcp-synology-filestation serve` | +| Setup | `uv run mcp-synology-filestation setup` | + +## Code Standards + +- **Type hints** on all public functions and methods. +- **Docstrings** (English) on all public modules, classes, and functions. +- **Async-first:** use `httpx.AsyncClient` throughout; never `requests`. +- **Formatter:** `ruff format` (line length 100). +- **Linter:** `ruff check` — fix all warnings before committing. + +## Security Rules + +- Never log or write credentials, session IDs, or passwords to disk or stderr. +- Never store passwords in the config YAML — OS keyring only. +- Mask sensitive query parameters (`_sid`, `passwd`) before logging request URLs. + +## Destructive Operation Rules + +- `delete`, `move` with `overwrite=True`, `copy` with `overwrite=True`, and `upload` with + `overwrite=True` are considered destructive. +- The `delete` tool MUST require `confirmed=True` to proceed. Without it, return a preview + message that describes exactly what would be deleted — never silently proceed. +- For overwrite scenarios in `move`/`copy`/`upload`, include a warning in the tool description + and default `overwrite` to `False`. + +## Error Handling Rules + +- DSM error codes must be mapped to human-readable messages. See SPEC.md for the full map. +- Never surface raw Python stack traces in tool return values. +- Session expiry (codes 106, 107, 119): transparent single retry with re-login. +- Network errors: return "Cannot reach NAS at {host} — check connectivity." +- Auth failures (400–403): return message + "Run `mcp-synology-filestation setup` to + reconfigure credentials." + +## Tool Return Format + +- All tools return `str`. +- Use `rich.table.Table` (rendered to string) for tabular data. +- Include item counts and pagination hints where relevant. +- Error messages are prefixed with `Error:` for easy recognition by Claude. + +## Module Structure + +``` +src/mcp_synology_filestation/ +├── __init__.py # __version__ +├── __main__.py # entry point +├── server.py # create_server(config, client) → FastMCP +├── client.py # FileStationClient (async httpx wrapper) +├── auth.py # AuthManager: keyring, env vars, 2FA, login/logout +├── config.py # AppConfig, ConnectionConfig, load_config, save_config +├── cli.py # click: setup / check / serve +└── tools/ + ├── __init__.py + └── filestation.py # register_filestation(mcp, config, client) +``` + +## Implemented Tools + +_(none yet — pending implementation approval)_ + +See [SPEC.md](SPEC.md) for the full planned tool set. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f57298 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# mcp-synology-filestation + +MCP server for Synology FileStation — browse, search, transfer, and manage files +on your NAS via Claude. + +## Status + +Work in progress. See [SPEC.md](SPEC.md) for the planned tool set. + +## Planned Tools + +| Tool | Description | +|------|-------------| +| `list_shares` | List all shared folders | +| `list_dir` | Directory contents with pagination and sorting | +| `get_info` | File or folder metadata | +| `search` | Recursive pattern search | +| `download` | Download a file (base64 content) | +| `create_folder` | Create a new directory | +| `rename` | Rename a file or folder | +| `move` | Move to a new location | +| `copy` | Copy to a new location | +| `delete` | Delete a path (requires confirmation) | +| `upload` | Upload a file from base64 content | + +## Setup + +```bash +uv tool install git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git +mcp-synology-filestation setup +``` + +## Development + +```bash +uv sync --dev +uv run pytest +uv run ruff check src/ +uv run ruff format src/ +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e1ac2e2 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,385 @@ +# Technical Specification: mcp-synology-filestation + +## Overview + +MCP server exposing Synology FileStation as Claude tools. Communicates with the DSM Web API +over HTTPS. All file operations target a single NAS instance configured at setup time. + +--- + +## Architecture + +``` +CLI (click) + └── AppConfig + AuthManager + └── FileStationClient (httpx, async) + └── FastMCP server + └── tools/filestation.py (register_filestation) +``` + +- **Transport:** MCP stdio (Claude Desktop integration) +- **HTTP:** httpx async, HTTPS only in production +- **Session:** DSM session ID (`_sid`) injected into every request; transparent re-auth on + session expiry (codes 106, 107, 119) with exactly one retry +- **Config:** `~/.config/mcp-synology-filestation/config.yaml` +- **Credentials:** OS keyring (service: `mcp-synology-filestation`) + +--- + +## Auth Flow + +1. `mcp-synology-filestation setup` collects NAS host, port, HTTPS flag, username, password. +2. Credentials stored in OS keyring; connection config written to YAML. +3. On `serve`, `AuthManager.login()` calls `SYNO.API.Auth::login` and holds the session ID + in memory only — never written to disk or logged. +4. If login requires 2FA: user runs `setup` interactively; the OTP + device token are handled + by the setup wizard; the device token is stored in keyring for subsequent logins. +5. Credential resolution order: env vars → OS keyring → (forbidden: plaintext config). + +### Environment variable overrides + +| Variable | Purpose | +|----------|---------| +| `SYNOLOGY_HOST` | Override NAS hostname | +| `SYNOLOGY_USERNAME` | Override DSM username | +| `SYNOLOGY_PASSWORD` | Override DSM password (not stored) | + +--- + +## DSM API Endpoints + +All requests use `GET /webapi/entry.cgi` with query parameters unless noted. + +| API | Version | Methods used | +|-----|---------|--------------| +| `SYNO.API.Auth` | 7 | `login`, `logout` | +| `SYNO.FileStation.Info` | 2 | `get` | +| `SYNO.FileStation.List` | 2 | `list_share`, `list` | +| `SYNO.FileStation.Stat` | 3 | `get` | +| `SYNO.FileStation.Search` | 2 | `start`, `list`, `stop`, `clean` | +| `SYNO.FileStation.Download` | 2 | `download` | +| `SYNO.FileStation.Upload` | 3 | `upload` (POST multipart) | +| `SYNO.FileStation.CreateFolder` | 2 | `create` | +| `SYNO.FileStation.Rename` | 2 | `rename` | +| `SYNO.FileStation.CopyMove` | 3 | `start`, `status`, `stop` | +| `SYNO.FileStation.Delete` | 2 | `start`, `status`, `stop` | + +### Async task pattern (CopyMove, Delete, Search) + +DSM returns a `taskid` from `start`; the client polls `status` with exponential backoff +(initial 200 ms, max 2 s, timeout 60 s) until `finished=true`, then calls `stop`/`clean`. + +--- + +## Tools + +### Read-only + +#### `list_shares` +List all shared folders visible to the authenticated user. + +**Parameters:** none + +**Returns:** Formatted table of share names, paths, and volume usage. + +**DSM call:** `SYNO.FileStation.List::list_share` +``` +additional=["real_path","volume_status"] +``` + +--- + +#### `list_dir` +List contents of a directory with optional pagination and sorting. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | Absolute path on the NAS | +| `offset` | int | no | 0 | Number of items to skip | +| `limit` | int | no | 100 | Max items to return (max 500) | +| `sort_by` | str | no | `name` | `name`, `size`, `user`, `group`, `mtime`, `atime`, `crtime`, `posix`, `type` | +| `sort_direction` | str | no | `asc` | `asc` or `desc` | + +**Returns:** Table of entries (name, type, size, modified time). Includes total count for +pagination context. + +**DSM call:** `SYNO.FileStation.List::list` +``` +folder_path={path}, offset={offset}, limit={limit}, +sort_by={sort_by}, sort_direction={sort_direction}, +additional=["real_path","size","time","perm","type"] +``` + +--- + +#### `get_info` +Get detailed metadata for one or more files or folders. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str or list[str] | yes | Absolute path(s) on the NAS | + +**Returns:** Per-path details: type, size, owner, group, permissions, timestamps, real path. + +**DSM call:** `SYNO.FileStation.Stat::get` +``` +path={comma-joined paths}, +additional=["real_path","size","time","perm","owner","mount_point_type","type"] +``` + +--- + +#### `search` +Search for files matching a pattern within a directory. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | Root directory to search from | +| `pattern` | str | yes | — | Filename glob pattern (e.g. `*.log`) | +| `recursive` | bool | no | true | Search subdirectories | +| `max_results` | int | no | 200 | Cap on returned matches | + +**Returns:** List of matching paths with type, size, and modification time. + +**DSM calls:** `SYNO.FileStation.Search::start` → poll `::list` → `::stop` + `::clean` +``` +start: folder_path={path}, recursive={recursive}, pattern={pattern} +list: taskid={taskid}, offset=0, limit={max_results} +``` + +--- + +#### `download` +Download a single file and return its content. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str | yes | Absolute path to the file on the NAS | + +**Returns:** Object with `filename`, `size`, `content_base64` (base64-encoded file bytes). +Files larger than 10 MB return an error suggesting `sftp` instead. + +**DSM call:** `SYNO.FileStation.Download::download` (streaming GET) +``` +path={path}, mode=download +``` + +--- + +### Write (require explicit confirmation where noted) + +#### `create_folder` +Create a new directory (and optionally all intermediate parents). + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | Parent directory path | +| `name` | str | yes | — | New folder name | +| `create_parents` | bool | no | false | Create missing parent directories | + +**Returns:** Path of the created folder or an error message. + +**DSM call:** `SYNO.FileStation.CreateFolder::create` +``` +folder_path={path}, name={name}, force_parent={create_parents} +``` + +--- + +#### `rename` +Rename a file or directory. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str | yes | Absolute path to the item | +| `new_name` | str | yes | New filename (not a full path) | + +**Returns:** New absolute path after rename. + +**DSM call:** `SYNO.FileStation.Rename::rename` +``` +path={path}, name={new_name} +``` + +--- + +#### `move` +Move a file or directory to a new location. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `src` | str | yes | — | Source absolute path | +| `dst` | str | yes | — | Destination directory path | +| `overwrite` | bool | no | false | Overwrite if destination exists | + +**Returns:** Destination path on success, or a descriptive error. + +**DSM call:** `SYNO.FileStation.CopyMove::start` (async task) +``` +path={src}, dest_folder_path={dst}, overwrite={overwrite}, remove_src=true +``` + +--- + +#### `copy` +Copy a file or directory to a new location. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `src` | str | yes | — | Source absolute path | +| `dst` | str | yes | — | Destination directory path | +| `overwrite` | bool | no | false | Overwrite if destination exists | + +**Returns:** Destination path on success, or a descriptive error. + +**DSM call:** `SYNO.FileStation.CopyMove::start` (async task) +``` +path={src}, dest_folder_path={dst}, overwrite={overwrite}, remove_src=false +``` + +--- + +#### `delete` +**Destructive — requires `confirmed=True`.** + +Delete a file or directory. Without confirmation, returns a preview of what would be deleted. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | Absolute path to delete | +| `confirmed` | bool | yes | false | Must be `true` to proceed | + +**Returns:** +- `confirmed=false`: Preview message listing the path and item type. +- `confirmed=true`: Success message or error detail. + +**DSM call:** `SYNO.FileStation.Delete::start` (async task) +``` +path={path}, recursive=true, accurate_progress=false +``` + +--- + +#### `upload` +Upload a file to the NAS from base64-encoded content. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | Destination directory path on the NAS | +| `filename` | str | yes | — | Filename to create | +| `content_base64` | str | yes | — | Base64-encoded file content | +| `overwrite` | bool | no | false | Overwrite if a file with this name already exists | +| `create_parents` | bool | no | true | Create missing parent directories | + +**Returns:** Full path of the uploaded file or an error message. +Files exceeding 50 MB should not be uploaded via MCP; return a clear error. + +**DSM call:** `SYNO.FileStation.Upload::upload` (POST multipart/form-data) +``` +path={path}, create_parents={create_parents}, overwrite={overwrite}, +file= +``` + +--- + +## Error Handling Strategy + +### Principles +1. DSM error codes are mapped to human-readable messages before surfacing to Claude. +2. Never expose raw stack traces or session IDs in tool responses. +3. Auth errors (codes 400–403) trigger a clear message with a hint to run `setup`. +4. Session expiry errors (106, 107, 119) are retried once transparently; if the retry also + fails, the user sees "Session expired — please restart the MCP server." +5. Network errors (timeouts, connection refused) are reported as + "Cannot reach NAS at {host} — check connectivity." +6. Unknown DSM error codes are reported as "DSM error {code}: {raw_message}". + +### DSM error code map + +| Code | Meaning | User message | +|------|---------|--------------| +| 100 | Unknown error | "DSM reported an unknown error." | +| 101 | Invalid parameter | "Invalid parameter in request." | +| 102 | API does not exist | "This DSM API is not available on your NAS." | +| 103 | Method does not exist | "This DSM API method is not supported." | +| 105 | Permission denied | "Permission denied — check your DSM account privileges." | +| 106 | Session timeout | (transparent re-auth) | +| 107 | Session displaced | (transparent re-auth) | +| 119 | Invalid session | (transparent re-auth) | +| 400 | Invalid password | "Login failed — check username and password (run setup)." | +| 401 | Guest or disabled account | "DSM account is disabled." | +| 403 | 2FA required | "Two-factor authentication required — run setup." | +| 404 | 2FA failed | "OTP code incorrect." | +| 408 | Device token required | "Device token required — run setup again." | +| 1800 | File not found | "File or folder not found: {path}" | +| 1801 | No write permission | "No write permission for: {path}" | +| 1802 | File exists | "A file already exists at this path." | +| 1803 | Disk quota exceeded | "Disk quota exceeded." | +| 1804 | No space left | "No space left on the target volume." | +| 1805 | Filename too long | "Filename too long." | +| 1806 | Illegal filename characters | "Filename contains illegal characters." | +| 1807 | File is read-only | "File is read-only." | + +--- + +## Configuration Model + +```yaml +# ~/.config/mcp-synology-filestation/config.yaml +connection: + host: dsm.gecheckt.de + port: 443 + https: true + verify_ssl: true + timeout: 30 + +auth: + username: marcus + # password: never stored here — OS keyring only +``` + +### AppConfig fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `connection.host` | str | required | NAS hostname or IP | +| `connection.port` | int | 443/5000 | DSM port | +| `connection.https` | bool | true | Use HTTPS | +| `connection.verify_ssl` | bool | true | Verify TLS cert | +| `connection.timeout` | int | 30 | Request timeout (seconds) | +| `auth.username` | str | required | DSM username | + +`base_url` is derived as `{https_scheme}://{host}:{port}`. +`keyring_service` is fixed to `"mcp-synology-filestation"`. + +--- + +## CLI Subcommands + +### `setup` +Interactive wizard: +1. Prompt for NAS host, port, HTTPS, SSL verify. +2. Prompt for username and password (masked). +3. Attempt login; detect 2FA requirement. +4. If 2FA: prompt OTP, request device token, store token in keyring. +5. Verify FileStation API availability (`SYNO.FileStation.List`). +6. Save config to `~/.config/mcp-synology-filestation/config.yaml`. +7. Print Claude Desktop snippet. + +### `check` +Validate config, resolve credentials, test login, list available FileStation APIs. + +### `serve` +Load config and credentials, create `FileStationClient` (lazy — no immediate connection), +create and run `FastMCP("mcp-synology-filestation")` over stdio. Uses `anyio.run()` for +Windows compatibility. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a3fc40 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "mcp-synology-filestation" +version = "0.1.0" +description = "MCP server for Synology FileStation" +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", + "respx>=0.21", +] + +[project.scripts] +mcp-synology-filestation = "mcp_synology_filestation.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_synology_filestation"] + +[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"] +asyncio_mode = "auto" + +[dependency-groups] +dev = [ + "ruff>=0.15.10", + "pytest>=8.0", + "pytest-asyncio>=0.24", + "respx>=0.21", +] diff --git a/src/mcp_synology_filestation/__init__.py b/src/mcp_synology_filestation/__init__.py new file mode 100644 index 0000000..01aa34e --- /dev/null +++ b/src/mcp_synology_filestation/__init__.py @@ -0,0 +1,3 @@ +"""MCP server for Synology FileStation.""" + +__version__ = "0.1.0" diff --git a/src/mcp_synology_filestation/__main__.py b/src/mcp_synology_filestation/__main__.py new file mode 100644 index 0000000..c4a78f3 --- /dev/null +++ b/src/mcp_synology_filestation/__main__.py @@ -0,0 +1,6 @@ +"""Allow running as: python -m mcp_synology_filestation.""" + +from mcp_synology_filestation.cli import main + +if __name__ == "__main__": + main() diff --git a/src/mcp_synology_filestation/auth.py b/src/mcp_synology_filestation/auth.py new file mode 100644 index 0000000..78a84d9 --- /dev/null +++ b/src/mcp_synology_filestation/auth.py @@ -0,0 +1,2 @@ +"""Credential management: OS keyring, env var overrides, 2FA device token.""" +# Implementation pending approval — see SPEC.md diff --git a/src/mcp_synology_filestation/client.py b/src/mcp_synology_filestation/client.py new file mode 100644 index 0000000..869251b --- /dev/null +++ b/src/mcp_synology_filestation/client.py @@ -0,0 +1,2 @@ +"""Async HTTP client for Synology DSM / FileStation API.""" +# Implementation pending approval — see SPEC.md diff --git a/src/mcp_synology_filestation/config.py b/src/mcp_synology_filestation/config.py new file mode 100644 index 0000000..ab4d69e --- /dev/null +++ b/src/mcp_synology_filestation/config.py @@ -0,0 +1,2 @@ +"""Application configuration: YAML loading, validation, env var overrides.""" +# Implementation pending approval — see SPEC.md diff --git a/src/mcp_synology_filestation/server.py b/src/mcp_synology_filestation/server.py new file mode 100644 index 0000000..0816802 --- /dev/null +++ b/src/mcp_synology_filestation/server.py @@ -0,0 +1,2 @@ +"""MCP server factory: creates and configures the FastMCP instance.""" +# Implementation pending approval — see SPEC.md diff --git a/src/mcp_synology_filestation/tools/__init__.py b/src/mcp_synology_filestation/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py new file mode 100644 index 0000000..c689273 --- /dev/null +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -0,0 +1,2 @@ +"""FileStation MCP tool registrations.""" +# Implementation pending approval — see SPEC.md diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29