# 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`, `getinfo` | | `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` | | `SYNO.FileStation.Compress` | 3 | `start`, `status`, `stop` | | `SYNO.FileStation.Extract` | 2 | `start`, `status`, `stop` | | `SYNO.FileStation.DirSize` | 2/1 | `start` (v2), `status` (v1) | | `SYNO.FileStation.MD5` | 2/1 | `start` (v2), `status` (v1) | | `SYNO.FileStation.CheckPermission` | 3 | `write` | | `SYNO.FileStation.Sharing` | 3 | `create`, `list`, `delete` | ### Async task pattern (CopyMove, Delete, Search, Compress, Extract) 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`. Implemented in `_poll_task()`. ### One-shot task pattern (DirSize, MD5) `DirSize` and `MD5` differ from the async task pattern: DSM delivers `finished=true` exactly once, then immediately discards the result (subsequent polls return error 599). Implemented in `_start_and_poll_oneshot()`: 1. Call `start`. 2. Poll `status` repeatedly until `finished=true` or timeout. 3. On 599: if within the first ~8 s after start, the background service may still be warming up ("cold start") — restart the task and try again (up to 6 restarts). 4. On `finished=true`: return the data and stop polling (never poll again). **Cold-start behaviour:** After a period of inactivity, DSM's DirSize/MD5 background service takes ~6–8 s to initialise. During this window every `status` poll returns error 599. The correct recovery is to wait a moment, then restart the task. Retrying the same `taskid` does not help — the service must be cold-started via a new `start` call. **DirSize `status` version:** Must use `version=1`. Using `version=2` always returns 599 regardless of service state. --- ## Tools (v0.2.10 — 20 tools) ### Read-only | Tool | Description | |------|-------------| | `list_shares` | List all shared folders with volume usage | | `list_dir` | List directory contents with pagination and sorting | | `get_info` | Get detailed metadata for one or more paths | | `check_exist` | Check if one or more paths exist (Yes/No table) | | `search` | Search for files by glob pattern with async polling | | `download` | Download a file as base64 (max 10 MB) | | `dir_size` | Total size, file count, folder count for directories | | `get_md5` | Compute MD5 checksum of a file | ### Write | Tool | Description | |------|-------------| | `create_folder` | Create a new folder (optionally with parent dirs) | | `rename` | Rename a file or folder | | `copy` | Copy a file or folder (async polling, overwrite=False default) | | `move` | Move a file or folder (async polling, overwrite=False default) | | `delete` | Delete a file or folder — requires confirmed=True | | `upload` | Upload base64-encoded content to a path (max 50 MB) | | `compress` | Compress paths into a ZIP or 7z archive | | `extract` | Extract a ZIP or 7z archive to a destination folder | ### Permission & Sharing | Tool | Description | |------|-------------| | `check_permission` | Check write permission for a filename in a directory | | `create_sharing_link` | Create a public sharing link (optional password + expiry) | | `list_sharing_links` | List all sharing links (paginated table) | | `delete_sharing_link` | Delete a sharing link by ID | --- ### Tool details #### `list_shares` **Parameters:** none **Returns:** Formatted table of share names, paths, and volume usage. **DSM call:** `SYNO.FileStation.List::list_share` ``` additional=["volume_status"] ``` > **Note:** DSM returns share paths directly (e.g. `/dev`, `/data`). The `path` field from the > response is used as-is — do not prepend the volume prefix. --- #### `list_dir` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `path` | str | yes | — | Share-relative path (e.g. `/dev`, `/data/photos`) | | `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=["size","time"] ``` > **Path requirement:** `folder_path` must be a share path as returned by `list_share` > (e.g. `/dev`, `/data`). Volume paths (`/volume1/dev`) are **not accepted** and cause DSM > error 408. > > **`additional` format:** Must be a JSON array serialised as a string > (`json.dumps(["size","time"])` → `'["size", "time"]'`). A comma-separated string > (`"size,time"`) is silently ignored by DSM. --- #### `get_info` **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | str | yes | One or more share-relative paths, comma-separated | **Returns:** Table with type, size, owner, group, permissions (octal), modification time, creation time, and real volume path for each requested item. **DSM call:** `SYNO.FileStation.List::getinfo` ``` path=json.dumps([paths...]), additional=["real_path","size","time","perm","owner","type"] ``` --- #### `check_exist` **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | str | yes | One or more share-relative paths, comma-separated | **Returns:** Yes/No table per path. **DSM call:** `SYNO.FileStation.List::getinfo` — entries with `name=null` are non-existent. --- #### `search` **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` --- #### `download` **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | str | yes | Share-relative file path | **Returns:** JSON `{filename, size, content_base64}`. Files > 10 MB return an error. **DSM call:** `SYNO.FileStation.Download::download` (streaming GET, `mode=download`) --- #### `dir_size` **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | str | yes | Comma-separated share-relative paths | **Returns:** Table with total size, file count, folder count. **DSM calls:** `SYNO.FileStation.DirSize::start` (v2) → poll `::status` (v1) > See *One-shot task pattern* above for polling behaviour and cold-start recovery. --- #### `get_md5` **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | str | yes | Share-relative file path | **Returns:** `MD5 of {path}: {hash}` **DSM calls:** `SYNO.FileStation.MD5::start` (v2) → poll `::status` (v1) --- #### `create_folder` **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 | **DSM call:** `SYNO.FileStation.CreateFolder::create` --- #### `rename` **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | str | yes | Current share-relative path | | `new_name` | str | yes | New filename (bare name, not full path) | **DSM call:** `SYNO.FileStation.Rename::rename` --- #### `copy` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `src` | str | yes | — | Source path | | `dst` | str | yes | — | Destination directory path | | `overwrite` | bool | no | false | Overwrite if destination exists | **DSM call:** `SYNO.FileStation.CopyMove::start` (async, `remove_src=false`) --- #### `move` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `src` | str | yes | — | Source path | | `dst` | str | yes | — | Destination directory path | | `overwrite` | bool | no | false | Overwrite if destination exists | **DSM call:** `SYNO.FileStation.CopyMove::start` (async, `remove_src=true`) --- #### `delete` **Destructive — requires `confirmed=True`.** **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `path` | str | yes | — | Path to delete | | `confirmed` | bool | yes | false | Must be `true` to proceed | **Returns:** - `confirmed=false`: Preview of what would be deleted. - `confirmed=true`: Success message or error. **DSM call:** `SYNO.FileStation.Delete::start` (async, `recursive=true`) --- #### `upload` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `path` | str | yes | — | Destination directory path | | `filename` | str | yes | — | Filename to create | | `content_base64` | str | yes | — | Base64-encoded file content | | `overwrite` | bool | no | false | Overwrite if file exists | | `create_parents` | bool | no | true | Create missing parent directories | **Returns:** Full path of uploaded file. Files > 50 MB are rejected. **DSM call:** `SYNO.FileStation.Upload::upload` (POST multipart/form-data) --- #### `compress` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `paths` | list[str] | yes | — | Paths to compress | | `dest_file_path` | str | yes | — | Output archive path incl. filename | | `level` | str | no | `moderate` | `store`/`fastest`/`fast`/`normal`/`moderate`/`maximum` | | `mode` | str | no | `add` | `add`/`update`/`refreshen` | | `format` | str | no | `zip` | `zip` or `7z` | | `password` | str | no | `""` | Archive password | **DSM call:** `SYNO.FileStation.Compress::start` (async, v3) --- #### `extract` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `file_path` | str | yes | — | Archive path on NAS | | `dest_folder_path` | str | yes | — | Destination directory | | `overwrite` | bool | no | false | Overwrite existing files | | `keep_dir` | bool | no | true | Preserve directory structure | | `create_subfolder` | bool | no | false | Extract into a subfolder | | `password` | str | no | `""` | Archive password | **DSM call:** `SYNO.FileStation.Extract::start` (async, v2) --- #### `check_permission` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `path` | str | yes | — | Directory to check | | `filename` | str | yes | — | Filename to check write access for | | `overwrite` | bool | no | false | Check overwrite permission | | `create_only` | bool | no | false | Check create-only permission | **Returns:** `"Permission granted: write {filename} into {path}"` or `"Error: …"` **DSM call:** `SYNO.FileStation.CheckPermission::write` (v3) > **Parameter format:** `path` and `filename` are passed as plain strings (no `json.dumps`). > On success, DSM returns `{"blSkip": false}` — the tool maps this to a human-readable message. --- #### `create_sharing_link` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `path` | str | yes | — | File or folder path to share | | `password` | str | no | `""` | Password to protect the link | | `date_expired` | str | no | `""` | Expiry date (`YYYY-MM-DD`) | | `date_available` | str | no | `""` | Availability start date (`YYYY-MM-DD`) | **Returns:** Sharing URL + link ID, with password-protection flag. **DSM call:** `SYNO.FileStation.Sharing::create` (v3) > `path` must be `json.dumps()`-encoded. The `date_expired`/`date_available` fields are not > echoed back in the create response — confirm via `list_sharing_links` if needed. --- #### `list_sharing_links` **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `offset` | int | no | 0 | Pagination offset | | `limit` | int | no | 100 | Max links to return | **Returns:** Table with ID, URL, path, owner, expiry, status. Includes total count and pagination hint when more results are available. **DSM call:** `SYNO.FileStation.Sharing::list` (v3) --- #### `delete_sharing_link` **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `link_id` | str | yes | Sharing link ID (from `list_sharing_links`) | **Returns:** `"Deleted sharing link: {link_id}"` or error. **DSM call:** `SYNO.FileStation.Sharing::delete` (v3) > `link_id` must be passed as `json.dumps(link_id)` in the `id` parameter. --- ## 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. 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." | | 599 | Background service not ready | (handled by `_start_and_poll_oneshot` — restart task) | | 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." | --- ## DSM API Quirks ### Parameter encoding - **JSON array parameters** (`path`, `additional`, etc.) must be serialised with `json.dumps()`. A plain Python list or comma-separated string is silently ignored by DSM — the field will be absent from the response. - **`path` for multi-item operations** (e.g. `List::getinfo`, `DirSize::start`): always pass as `json.dumps(["/path1", "/path2"])`, even for a single path. - **`path` for single-item operations** (e.g. `CopyMove::start`, `Rename::rename`, `Sharing::create`): pass as `json.dumps("/path")` (a JSON-encoded string). - **`Sharing::delete` `id` parameter:** must be `json.dumps(link_id)` — plain string is not accepted. - **`CheckPermission::write` `path` and `filename`:** pass as plain strings (no `json.dumps`). - **Share paths vs. volume paths:** always use share paths (`/docker`, `/homes/marcus`). Volume paths (`/volume1/docker`) cause DSM error 408. ### APIs confirmed NOT available on this NAS | API | Status | Workaround | |-----|--------|------------| | `SYNO.FileStation.Stat` | Not in API registry | Use `List::getinfo` | | `SYNO.FileStation.CheckExist` | Returns error 400 | Use `List::getinfo` (entries with `name=null` don't exist) | ### FastMCP `outputSchema` / tools/list payload limit Do **not** add `-> str` return type annotations to `@mcp.tool()` functions. FastMCP infers `outputSchema` from the annotation, which doubles the tools/list payload size. Claude Desktop truncates the tool list when the payload exceeds its limit, making some tools invisible. Omitting the return annotation suppresses `outputSchema` generation. ### DirSize / MD5 one-shot behaviour `DirSize` and `MD5` tasks are consumed on first read: `finished=true` is returned exactly once, then the task is deleted and all subsequent `status` polls return error 599. Implementation: `_start_and_poll_oneshot()` — never call `status` a second time after receiving `finished=true`. ### DirSize cold-start After a period of inactivity, DSM's DirSize background service needs ~6–8 s to initialise. During cold start, `status` returns 599 on every poll. The fix is to wait briefly and **restart** the task (new `start` call) — polling the original `taskid` again does not help. `_start_and_poll_oneshot()` retries up to 6 task restarts before giving up. ### DirSize `status` API version `SYNO.FileStation.DirSize::status` must be called with `version=1`. Using `version=2` always returns error 599 regardless of whether the background service is running. --- ## 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. --- ## Roadmap ### v0.2 — complete (20 tools) All tools shipped in v0.2.10: **Group 1:** `list_shares`, `list_dir`, `get_info`, `check_exist`, `search`, `download` **Group 2:** `create_folder`, `rename`, `copy`, `move`, `delete`, `upload`, `compress`, `extract`, `dir_size`, `get_md5` **Group 3:** `check_permission`, `create_sharing_link`, `list_sharing_links`, `delete_sharing_link` ### v0.3 — candidates | Tool | API | Notes | |------|-----|-------| | `get_thumbnail` | `SYNO.FileStation.Thumb` | Return base64 thumbnail for image/video | | `list_favorites` | `SYNO.FileStation.Favorite` | List user-pinned favourite paths | | `add_favorite` | `SYNO.FileStation.Favorite` | Pin a path as favourite | | `delete_favorite` | `SYNO.FileStation.Favorite` | Remove a favourite | | `list_snapshots` | `SYNO.FileStation.Snapshot` | List Btrfs snapshots for a share | | `background_tasks` | `SYNO.FileStation.BackgroundTask` | List/cancel background operations |