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>
15 KiB
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
mcp-synology-filestation setupcollects NAS host, port, HTTPS flag, username, password.- Credentials stored in OS keyring; connection config written to YAML.
- On
serve,AuthManager.login()callsSYNO.API.Auth::loginand holds the session ID in memory only — never written to disk or logged. - If login requires 2FA: user runs
setupinteractively; the OTP + device token are handled by the setup wizard; the device token is stored in keyring for subsequent logins. - 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). Thepathfield 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_pathmust be a share path as returned bylist_share(e.g./dev,/data). Volume paths (/volume1/dev) are not accepted and cause DSM error 408.
additionalformat: 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 — theadditionalfield will be absent from every file entry.
SYNO.FileStation.Stat: Not available on this NAS's API registry. UseSYNO.FileStation.List::getinfofor 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.Statis not available on all NAS firmware versions and is absent from this NAS's API registry.SYNO.FileStation.List::getinforeturns 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
- DSM error codes are mapped to human-readable messages before surfacing to Claude.
- Never expose raw stack traces or session IDs in tool responses.
- Auth errors (codes 400–403) trigger a clear message with a hint to run
setup. - 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."
- Network errors (timeouts, connection refused) are reported as "Cannot reach NAS at {host} — check connectivity."
- 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
# ~/.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:
- Prompt for NAS host, port, HTTPS, SSL verify.
- Prompt for username and password (masked).
- Attempt login; detect 2FA requirement.
- If 2FA: prompt OTP, request device token, store token in keyring.
- Verify FileStation API availability (
SYNO.FileStation.List). - Save config to
~/.config/mcp-synology-filestation/config.yaml. - 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.