feat: initial project structure

This commit is contained in:
2026-04-14 07:51:51 +02:00
commit 9fc5a3d68c
15 changed files with 597 additions and 0 deletions
+385
View File
@@ -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=<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.