4c6de3bfc7
- background_tasks: SYNO.FileStation.BackgroundTask::list (v3) — paginated table of active/recent copy/move/delete/extract/compress tasks - list_snapshots: SYNO.FileStation.Snapshot::list (v2) — Btrfs snapshots per share; maps error 400 to a clear Btrfs-required message - 20 new tests (107 total) - SPEC.md and CLAUDE.md updated (26 tools) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
717 lines
26 KiB
Markdown
717 lines
26 KiB
Markdown
# 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 `POST /webapi/entry.cgi` with `application/x-www-form-urlencoded` body 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.3.4 — 26 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 |
|
||
|
||
### Thumbnail & Favorites
|
||
|
||
| Tool | Description |
|
||
|------|-------------|
|
||
| `get_thumbnail` | Fetch a thumbnail for an image/video file as base64 |
|
||
| `list_favorites` | List all FileStation user favorites |
|
||
| `add_favorite` | Add a path to FileStation favorites |
|
||
| `delete_favorite` | Remove a path from FileStation favorites |
|
||
|
||
### Background Tasks & Snapshots
|
||
|
||
| Tool | Description |
|
||
|------|-------------|
|
||
| `background_tasks` | List FileStation background tasks (copy, move, delete, etc.) |
|
||
| `list_snapshots` | List Btrfs snapshots for a shared folder (requires Btrfs volume) |
|
||
|
||
---
|
||
|
||
### 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.
|
||
|
||
---
|
||
|
||
#### `get_thumbnail`
|
||
**Parameters:**
|
||
| Name | Type | Required | Default | Description |
|
||
|------|------|----------|---------|-------------|
|
||
| `path` | str | yes | — | Share-relative path to the image or video file |
|
||
| `size` | str | no | `large` | `small`, `medium`, `large`, or `original` |
|
||
|
||
**Returns:** JSON `{filename, size_bytes, content_base64}` (JPEG bytes encoded as base64).
|
||
|
||
**DSM call:** `SYNO.FileStation.Thumb::get` (POST, v2)
|
||
|
||
> DSM returns image bytes directly when the file has a thumbnail. Non-image content-type
|
||
> indicates a DSM error envelope — the tool parses and surfaces the error code.
|
||
|
||
---
|
||
|
||
#### `list_favorites`
|
||
**Parameters:**
|
||
| Name | Type | Required | Default | Description |
|
||
|------|------|----------|---------|-------------|
|
||
| `offset` | int | no | 0 | Pagination offset |
|
||
| `limit` | int | no | 200 | Max items to return |
|
||
|
||
**Returns:** Table with name, path, type, status, real path, modified time.
|
||
|
||
**DSM call:** `SYNO.FileStation.Favorite::list` (v2, `additional=["real_path","size","time"]`)
|
||
|
||
---
|
||
|
||
#### `add_favorite`
|
||
**Parameters:**
|
||
| Name | Type | Required | Description |
|
||
|------|------|----------|-------------|
|
||
| `path` | str | yes | Share-relative path to pin |
|
||
| `name` | str | yes | Display label for the favorite |
|
||
|
||
**Returns:** `"Added favorite '{name}' → {path}"` or error.
|
||
|
||
**DSM call:** `SYNO.FileStation.Favorite::add` (v2, `index=-1`)
|
||
|
||
---
|
||
|
||
#### `delete_favorite`
|
||
**Parameters:**
|
||
| Name | Type | Required | Description |
|
||
|------|------|----------|-------------|
|
||
| `path` | str | yes | Share-relative path of the favorite to remove |
|
||
|
||
**Returns:** `"Deleted favorite for {path}"` or error.
|
||
|
||
**DSM call:** `SYNO.FileStation.Favorite::delete` (v2)
|
||
|
||
---
|
||
|
||
#### `background_tasks`
|
||
**Parameters:**
|
||
| Name | Type | Required | Default | Description |
|
||
|------|------|----------|---------|-------------|
|
||
| `offset` | int | no | 0 | Pagination offset |
|
||
| `limit` | int | no | 100 | Max tasks to return (capped at 200) |
|
||
|
||
**Returns:** Table with task ID, type, status, path, and file progress. Includes total count
|
||
and a pagination hint when more results are available.
|
||
|
||
**DSM call:** `SYNO.FileStation.BackgroundTask::list` (v3)
|
||
|
||
> Only the `list` method is available on this NAS — tasks cannot be stopped or cleared
|
||
> via this API.
|
||
|
||
---
|
||
|
||
#### `list_snapshots`
|
||
**Parameters:**
|
||
| Name | Type | Required | Default | Description |
|
||
|------|------|----------|---------|-------------|
|
||
| `share_path` | str | yes | — | Share-relative root path (e.g. `/docker`) |
|
||
| `offset` | int | no | 0 | Pagination offset |
|
||
| `limit` | int | no | 100 | Max snapshots to return (capped at 500) |
|
||
|
||
**Returns:** Table with snapshot ID, creation time, description, and locked flag. Includes
|
||
total count and a pagination hint when more results are available.
|
||
|
||
**DSM call:** `SYNO.FileStation.Snapshot::list` (v2, `folder_path={share_path}`)
|
||
|
||
> **Btrfs required:** DSM returns error 400 when the share is not on a Btrfs volume.
|
||
> The tool maps this to a clear message: *"Snapshots not available — requires Btrfs-formatted
|
||
> volume."*
|
||
|
||
---
|
||
|
||
## 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 | Non-supported additional field | "DSM rejected additional fields — check parameter format." |
|
||
| 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 |
|