- Default size changed large → small (avoids MCP buffer overflows) - Hard limit: return Error: when thumbnail exceeds ~2 MB base64 (1.5 MB raw) - Soft limit: add "warning" field to JSON when thumbnail exceeds ~500 KB base64 (375 KB raw), advising to use size='small' - Constants _THUMB_ABORT_BYTES / _THUMB_WARN_BYTES moved to module level - 6 new tests for size cap/warning/default/DSM-error paths (113 total) - SPEC.md: document quality-ignored quirk, size ranges, soft+hard limits - CLAUDE.md: DSM Quirks entry for Thumb quality/size behaviour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
27 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 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():
- Call
start. - Poll
statusrepeatedly untilfinished=trueor timeout. - 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).
- 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). Thepathfield 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_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.
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:
pathandfilenameare passed as plain strings (nojson.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)
pathmust bejson.dumps()-encoded. Thedate_expired/date_availablefields are not echoed back in the create response — confirm vialist_sharing_linksif 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_idmust be passed asjson.dumps(link_id)in theidparameter.
get_thumbnail
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path |
str | yes | — | Share-relative path to the image or video file |
size |
str | no | small |
small, medium, large, or original |
Returns: JSON {filename, size_bytes, content_base64} (JPEG bytes encoded as base64).
If the thumbnail exceeds the soft limit (≈500 KB base64 / 375 KB raw), the JSON includes
an additional "warning" field advising to use size='small'.
If the thumbnail exceeds the hard limit (≈2 MB base64 / 1.5 MB raw), the tool returns
"Error: Thumbnail too large (… KB base64) — use size='small' to get a smaller version."
instead of the JSON payload.
DSM call: SYNO.FileStation.Thumb::get (POST, v2)
qualityparameter is ignored: DSM acceptsqualityin the request but always returns the same JPEG regardless of the value. No server-side quality control is available.Size limits confirmed against this NAS:
small→ 5 KB–548 KB raw depending on source image resolution.medium/large→ can exceed 380 KB raw even for modest photos.original→ reflects the actual stored image; may be several MB. Default was changed fromlargetosmallto avoid hitting MCP buffer limits.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
listmethod 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
- 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.
- 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 | 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 withjson.dumps(). A plain Python list or comma-separated string is silently ignored by DSM — the field will be absent from the response. pathfor multi-item operations (e.g.List::getinfo,DirSize::start): always pass asjson.dumps(["/path1", "/path2"]), even for a single path.pathfor single-item operations (e.g.CopyMove::start,Rename::rename,Sharing::create): pass asjson.dumps("/path")(a JSON-encoded string).Sharing::deleteidparameter: must bejson.dumps(link_id)— plain string is not accepted.CheckPermission::writepathandfilename: pass as plain strings (nojson.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
# ~/.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.
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 |