Files
mcp-synology-filestation/SPEC.md
T
marcus 8b2f07d9c3 revert: restore _poll_task and dir_size/get_md5 to 0.2.2 state
All changes since 0.2.2 to _poll_task, dir_size, and get_md5 (window_timeout,
_poll_oneshot, start_and_poll_immediately) are reverted. The 0.2.2 behaviour
worked reliably for small directories and is the last known-good baseline.

The remaining known limitation (occasional 599 on large directories) is
documented in SPEC.md. Retry the operation as a workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:18:43 +02:00

429 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |
### 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=["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`
List contents of a directory with optional pagination and sorting.
**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 — the `additional` field will be absent from
> every file entry.
>
> **`SYNO.FileStation.Stat`:** Not available on this NAS's API registry. Use
> `SYNO.FileStation.List::getinfo` for per-path metadata instead.
---
#### `get_info`
Get detailed metadata for one or more files or folders.
**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={comma-joined paths},
additional=["real_path","size","time","perm","owner","type"]
```
> **Note:** `SYNO.FileStation.Stat` is not available on all NAS firmware versions and is
> absent from this NAS's API registry. `SYNO.FileStation.List::getinfo` returns identical
> data and is confirmed working.
---
#### `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=<binary content decoded from content_base64>
```
---
## 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 400403) 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.
---
## Known Limitations
### `dir_size` / `get_md5`: occasional "DSM error code 599" on large directories
DSM's `DirSize` and `MD5` APIs are one-shot: once `finished=true` is returned
by the status endpoint, the task is removed and all subsequent polls return
error 599. The MCP server polls immediately after `start` (no initial delay)
and retries up to 5 consecutive 599 responses before giving up.
For small directories and files the result is reliably read on the first or
second poll. For large directories (e.g. `/docker`, `/music`) the task takes
longer; if DSM removes the completed result between two polls the tool returns
`Error: DSM error code 599`. Retrying the operation usually succeeds.
Root cause is not fully understood. The raw HTTP test script (`test_dirsize_md5.py`)
reliably catches `finished=true` for the same paths, suggesting the issue is
related to timing in the MCP stdio event loop.
**Workaround:** retry the `dir_size` call. It succeeds on the second or third attempt
for most paths.