17 Commits

Author SHA1 Message Date
marcus 65cb5a44c7 feat: add read_text tool — extract text from PDF/TXT/MD (v0.4.0)
Adds read_text tool with pypdf integration for PDF text extraction,
plain-text decode for TXT/MD/CSV/JSON/etc, page-filter support,
max_chars truncation, and 11 mock-based tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:09:32 +02:00
marcus 1bb75c9f36 style: ruff line-fold in client.py + update uv.lock to v0.3.6 (v0.3.7)
- client.py: collapse 3-line raise SynologyError(...) to one line
  (fits within 100 chars; ruff format output)
- uv.lock: package version entry updated from 0.2.4 to 0.3.6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 06:50:47 +02:00
marcus f2abf2af1e docs: rewrite README, mark v0.3 complete in CLAUDE.md (v0.3.6)
- README.md: replace v0.1 placeholder with full v0.3.5 reference docs
  (all 26 tools documented with parameters, return values, caveats)
- CLAUDE.md: bump implemented-tools header to v0.3.5; roadmap updated
  (v0.3 complete, v0.4 candidates: none planned)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 06:46:45 +02:00
marcus 4430807b55 fix: get_thumbnail size limits, default=small, quality quirk docs (v0.3.5)
- 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>
2026-04-15 06:39:57 +02:00
marcus 4c6de3bfc7 feat: add background_tasks + list_snapshots tools (v0.3.4)
- 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>
2026-04-14 21:54:03 +02:00
marcus b88677a20c docs: document Search::start folder_path array format + Extract::start file_path key
Hard-won DSM quirks confirmed by live testing:
- Search::start folder_path must be json.dumps([path]) — plain string or
  json.dumps(path) is silently ignored, causing empty results
- Extract::start source archive key is file_path (not path); both
  file_path and dest_folder_path require json.dumps()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:40:21 +02:00
marcus 161121b140 fix: search empty results + extract error 408 (v0.3.3)
Bug 1 — search::start folder_path format (already fixed in 314fae9):
  json.dumps([path]) is confirmed correct per official Synology API docs
  and multiple independent implementations (N4S4/synology-api, kwent/syno).
  Poll-loop last-non-empty guard (if current_files:) is also in place.
  No further change needed for Bug 1.

Bug 2 — extract::start wrong parameter key:
  The previous fix attempt renamed "file_path" → "path", which was wrong.
  Official API docs and independent implementations confirm the key is
  "file_path". The json.dumps() wrapping on file_path and dest_folder_path
  was already correct. Reverted the key rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:18:03 +02:00
marcus 314fae9167 fix: search empty results + extract error 408 (v0.3.2)
Bug 1 — search always returned empty results:
  Search::start was passing folder_path as a plain string.
  DSM silently ignores a plain string for this parameter and returns
  finished=True with files=[] immediately, as if nothing was found.
  Fix: json.dumps([path]) — JSON array, matching the multi-path API
  pattern used by DirSize::start and List::getinfo.

Bug 2 — extract returned DSM error 408:
  Extract::start was using "file_path" as the parameter key for the
  source archive. DSM expects "path". Without a valid path DSM returned
  error 408. The json.dumps wrapping was already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:55:22 +02:00
marcus 3dd6197fb3 refactor: shorten all tool docstrings to 1 line, trim payload by 544 B
tools/list payload: 8943 B → 8399 B (−6.1%)
All 16 multi-line docstrings collapsed to single lines per CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:23:44 +02:00
marcus 79b7384aeb feat: add get_thumbnail, list_favorites, add_favorite, delete_favorite (v0.3.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:11:05 +02:00
marcus ff79d438b0 docs: update CLAUDE.md for v0.3 2026-04-14 16:51:29 +02:00
marcus 53d5db142f docs: fix two SPEC.md errors (HTTP method, error code 408)
- DSM API uses POST with application/x-www-form-urlencoded, not GET
- Error 408 means "non-supported additional field", not "device token required"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:18:05 +02:00
marcus 800b36a2b0 chore: release v0.2.10, bump to 0.3.0-dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:33:35 +02:00
marcus 83bccbcb53 chore: remove throwaway test scripts, update SPEC.md for v0.2.10
- Delete test_dirsize_md5.py, test_extract.py, test_sharing.py
- SPEC.md: add all 20 tools, DSM quirks (one-shot, cold-start, FastMCP
  outputSchema, DirSize status v1, Sharing.delete id encoding), APIs
  confirmed unavailable, mark v0.2 complete, list v0.3 candidates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:33:22 +02:00
marcus ae90e5f09a feat: add check_permission + 3 sharing tools (v0.2.10)
Implements Group 3 of the planned tool set:
- check_permission: SYNO.FileStation.CheckPermission/write
- create_sharing_link: SYNO.FileStation.Sharing/create (password + expiry optional)
- list_sharing_links: SYNO.FileStation.Sharing/list (paginated table)
- delete_sharing_link: SYNO.FileStation.Sharing/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:04:00 +02:00
marcus 451ee7116f fix: cold-start 599 on DirSize/MD5 — restart task instead of giving up
DSM's DirSize and MD5 background service needs ~6-8 s to initialise
after a period of inactivity. During this cold-start window tasks are
registered but every status poll returns error 599 ("no such task").

Replace the ad-hoc start+_poll_task call in dir_size and get_md5 with
a new _start_and_poll_oneshot helper that:
- polls with exponential backoff (0.2 s → cap 2 s)
- on 5 consecutive 599s: restarts the task (up to 6 attempts total)
- honours a shared 60 s wall-clock budget across all restarts
- returns a clear error if all restart attempts are exhausted

Root cause confirmed by test_dirsize_md5.py: after ~6 s / 2 restarts
the service warms up and the very first poll on the new task succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:49:32 +02:00
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
11 changed files with 2093 additions and 497 deletions
+132 -37
View File
@@ -14,32 +14,57 @@ Sibling project to `mcp-synology-container`.
- **Credentials:** OS keyring, service name `mcp-synology-filestation`
- **Gitea:** `https://gitea.gecheckt.de/marcus/mcp-synology-filestation`
- **Local code:** `D:\Dev\Projects\mcp-synology-filestation`
- **Runtime:** Python 3.12+, `uv`, MCP SDK, `httpx`, `keyring`, `click`, `rich`
- **Runtime:** Python 3.12+, `uv`, MCP SDK (`mcp>=1.0.0`), `httpx`, `keyring`, `click`, `rich`
- **Test share on NAS:** `/test-mcp` — only for MCP tests, no production data
- **DSM test user:** `Testuser` / `Lasdas1234` (read-only, for browser tests)
## Deploy Workflow
1. Commit and push via Claude Code.
2. `uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git`
3. Restart Claude Desktop.
2. **Close Claude Desktop first** (Windows holds file locks — reinstall fails otherwise).
3. Install:
```
uv tool install --reinstall dist\mcp_synology_filestation-X.Y.Z-py3-none-any.whl
```
Or directly from Gitea:
```
uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git
```
4. Restart Claude Desktop.
5. Verify: `uv tool list` shows the expected version.
## Versioning
Bump the patch version on **every commit** in **both** files:
- `pyproject.toml` → `version = "X.Y.Z"`
- `src/mcp_synology_filestation/__init__.py` → `__version__ = "X.Y.Z"`
Current series: `0.3.x`
## Toolchain
| Task | Command |
|------|---------|
| Format | `ruff format src/ tests/` |
| Lint | `ruff check src/ tests/` |
| Tests | `pytest` |
| Install dev | `uv sync --dev` |
| Run server | `uv run mcp-synology-filestation serve` |
| Setup | `uv run mcp-synology-filestation setup` |
| Task | Command |
|-------------|-------------------------------------------|
| Format | `ruff format src/ tests/` |
| Lint | `ruff check src/ tests/` |
| Tests | `pytest` |
| Install dev | `uv sync --dev` |
| Run server | `uv run mcp-synology-filestation serve` |
| Setup | `uv run mcp-synology-filestation setup` |
## Code Standards
- **Type hints** on all public functions and methods.
- **Docstrings** (English) on all public modules, classes, and functions.
- **Docstrings** (English, 1 line max for `@mcp.tool()` functions) on all public modules,
classes, and functions.
- **Async-first:** use `httpx.AsyncClient` throughout; never `requests`.
- **Formatter:** `ruff format` (line length 100).
- **Linter:** `ruff check` — fix all warnings before committing.
- **No `-> str` on `@mcp.tool()` functions.** FastMCP generates `outputSchema` from return
annotations, which bloats the `tools/list` payload and causes Claude Desktop to truncate
the tool list. Omit return type annotations on all tool functions.
- **Short docstrings on tools.** Keep tool docstrings to 1 line. Long docstrings also bloat
the `tools/list` payload.
## Security Rules
@@ -53,8 +78,8 @@ Sibling project to `mcp-synology-container`.
`overwrite=True` are considered destructive.
- The `delete` tool MUST require `confirmed=True` to proceed. Without it, return a preview
message that describes exactly what would be deleted — never silently proceed.
- For overwrite scenarios in `move`/`copy`/`upload`, include a warning in the tool description
and default `overwrite` to `False`.
- For overwrite scenarios in `move`/`copy`/`upload`, include a warning in the tool
description and default `overwrite` to `False`.
## Error Handling Rules
@@ -72,36 +97,106 @@ Sibling project to `mcp-synology-container`.
- Include item counts and pagination hints where relevant.
- Error messages are prefixed with `Error:` for easy recognition by Claude.
## DSM Quirks (hard-won knowledge)
- **JSON-encode path parameters:** `path`, `dest_file_path`, `dest_folder_path`, `name` etc.
must be passed as `json.dumps("/path")` or `json.dumps(["/path1", "/path2"])`.
Plain strings or Python lists are silently ignored by DSM.
- **Share paths only:** always use `/docker`, `/homes/marcus` — never `/volume1/docker`.
Volume paths cause DSM error 408.
- **`additional` field:** must be `json.dumps(["size","time"])` — comma-separated string
does not work.
- **`List::list` additional:** only `["size","time"]` confirmed working on this firmware.
`real_path`, `perm`, `type` cause error 408.
- **`SYNO.FileStation.Stat`:** not in API registry — use `List::getinfo` instead.
- **`SYNO.FileStation.CheckExist`:** returns error 400 — use `List::getinfo` instead
(entries with `name=null` do not exist).
- **DirSize / MD5 one-shot:** `finished=true` is returned exactly once, then the task is
deleted. Implemented via `_start_and_poll_oneshot()` — never poll again after
`finished=true`.
- **DirSize cold start:** after inactivity, DSM's background service needs ~68 s to
initialise. During this window every `status` poll returns 599.
Fix: restart the task (new `start` call) — up to 6 restarts within a 60 s budget.
- **DirSize `status` version:** must use `version=1`. Version 2 always returns 599.
- **`Sharing::delete` id parameter:** must be `json.dumps(link_id)`.
- **`CheckPermission::write`:** `path` and `filename` are plain strings (no `json.dumps`).
Returns `{"blSkip": false}` on success.
- **`Search::start` folder_path:** must be a JSON array: `json.dumps([path])`.
A plain string or `json.dumps(path)` is silently ignored by DSM — it starts an empty
search and immediately returns `finished=true, files=[]`.
- **`Extract::start` parameter name:** the source archive key is `file_path` (not `path`).
Both path parameters need `json.dumps()`: `file_path=json.dumps(...)` and
`dest_folder_path=json.dumps(...)`.
- **Error 599:** means "background service not ready / task not found" for DirSize/MD5.
Handled by `_start_and_poll_oneshot()`.
## Module Structure
```
src/mcp_synology_filestation/
├── __init__.py # __version__
├── __main__.py # entry point
├── server.py # create_server(config, client) → FastMCP
├── client.py # FileStationClient (async httpx wrapper)
├── auth.py # AuthManager: keyring, env vars, 2FA, login/logout
├── config.py # AppConfig, ConnectionConfig, load_config, save_config
├── cli.py # click: setup / check / serve
├── __init__.py # __version__
├── __main__.py # entry point
├── server.py # create_server(config, client) → FastMCP
├── client.py # FileStationClient (async httpx wrapper)
├── auth.py # AuthManager: keyring, env vars, 2FA, login/logout
├── config.py # AppConfig, ConnectionConfig, load_config, save_config
├── cli.py # click: setup / check / serve
└── tools/
├── __init__.py
└── filestation.py # register_filestation(mcp, config, client)
└── filestation.py # register_filestation(mcp, config, client)
```
## Implemented Tools
## Implemented Tools (v0.3.5 — 26 tools)
| 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 |
| `search` | Search for files by glob pattern with async polling |
| `download` | Download a file as base64 (max 10 MB) |
| `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) |
| 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) |
| `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) |
| `move` | Move a file or folder (async polling, overwrite=False) |
| `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 |
| `dir_size` | Total size, file count, folder count for directories |
| `get_md5` | Compute MD5 checksum of a file |
| `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 |
| `get_thumbnail` | Fetch a thumbnail for an image/video file as base64 |
| `list_favorites` | List all FileStation user favorites |
| `add_favorite` | Pin a path as a FileStation favorite |
| `delete_favorite` | Remove a path from FileStation favorites |
| `background_tasks` | List FileStation background tasks (copy, move, delete, etc.) |
| `list_snapshots` | List Btrfs snapshots for a share (requires Btrfs volume) |
See [SPEC.md](SPEC.md) for the full planned tool set.
See [SPEC.md](SPEC.md) for full tool specifications and DSM call details.
## DSM Quirks (continued)
- **`SYNO.FileStation.Snapshot::list`:** Returns error 400 when the share is not on a
Btrfs-formatted volume. Confirmed: this NAS has no Btrfs volumes — `list_snapshots`
maps error 400 to a clear "requires Btrfs-formatted volume" message.
- **`SYNO.FileStation.BackgroundTask`:** Only the `list` method is available (v1v3).
No stop/cancel/clear methods exist on this firmware.
- **`SYNO.FileStation.Thumb` — `quality` parameter ignored:** DSM accepts the `quality`
field in the POST body but always returns the same JPEG regardless of the value.
No server-side quality control is available.
- **`SYNO.FileStation.Thumb` — size limits:** `small` thumbnails range from ~5 KB to
~548 KB raw depending on source image resolution. `medium`/`large` can exceed 380 KB
raw. `original` reflects the full stored image and may be several MB.
The `get_thumbnail` tool enforces: abort >1.5 MB raw (≈2 MB base64), warning >375 KB
raw (≈500 KB base64). Default changed from `large` to `small` to avoid MCP buffer
overflows.
## Roadmap
### v0.4 — candidates
No further tools currently planned.
+285 -21
View File
@@ -1,40 +1,304 @@
# mcp-synology-filestation
MCP server for Synology FileStation — browse, search, transfer, and manage files
on your NAS via Claude.
MCP server that exposes a **Synology NAS FileStation** as tools for Claude Desktop (and any
other MCP client). All 26 tools are production-tested against DSM 7.x.
## Status
---
Work in progress. See [SPEC.md](SPEC.md) for the planned tool set.
## Features
## Planned Tools
26 tools covering the full FileStation surface:
| Tool | Description |
|------|-------------|
| `list_shares` | List all shared folders |
| `list_dir` | Directory contents with pagination and sorting |
| `get_info` | File or folder metadata |
| `search` | Recursive pattern search |
| `download` | Download a file (base64 content) |
| `create_folder` | Create a new directory |
| `rename` | Rename a file or folder |
| `move` | Move to a new location |
| `copy` | Copy to a new location |
| `delete` | Delete a path (requires confirmation) |
| `upload` | Upload a file from base64 content |
| Group | Tools |
|-------|-------|
| **Browse** | `list_shares`, `list_dir`, `get_info`, `check_exist`, `search` |
| **Transfer** | `download`, `upload`, `get_thumbnail` |
| **Organise** | `create_folder`, `rename`, `copy`, `move`, `delete`, `compress`, `extract` |
| **Analyse** | `dir_size`, `get_md5`, `check_permission` |
| **Sharing** | `create_sharing_link`, `list_sharing_links`, `delete_sharing_link` |
| **Favorites** | `list_favorites`, `add_favorite`, `delete_favorite` |
| **System** | `background_tasks`, `list_snapshots` |
## Setup
---
## Requirements
- Python 3.12+
- [`uv`](https://docs.astral.sh/uv/) (recommended) or pip
- Synology NAS running DSM 7.x with FileStation enabled
- A DSM user account with FileStation access
---
## Installation
### From Gitea
```bash
uv tool install git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git
```
### Reinstall (upgrade)
```bash
uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git
```
> **Windows / Claude Desktop:** close Claude Desktop before reinstalling — Windows holds
> file locks on running executables.
---
## Setup
Run the interactive wizard once to configure the NAS connection and store credentials
securely in the OS keyring:
```bash
mcp-synology-filestation setup
```
The wizard will:
1. Ask for your NAS host, port, and HTTPS settings
2. Ask for your DSM username and password (stored in OS keyring — never written to disk)
3. Handle 2FA / device token if required
4. Verify the FileStation API is reachable
5. Print a ready-to-paste Claude Desktop config snippet
### Verify the setup
```bash
mcp-synology-filestation check
```
---
## Claude Desktop integration
Add the server to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"synology-filestation": {
"command": "mcp-synology-filestation",
"args": ["serve"]
}
}
}
```
The server uses **stdio transport** and is fully compatible with Claude Desktop on
Windows, macOS, and Linux.
### Environment variable overrides
| Variable | Purpose |
|----------|---------|
| `SYNOLOGY_HOST` | Override NAS hostname |
| `SYNOLOGY_USERNAME` | Override DSM username |
| `SYNOLOGY_PASSWORD` | Override DSM password (not stored) |
---
## Tool reference
### Browse
#### `list_shares`
List all shared folders with volume usage (total / used / %).
#### `list_dir`
List a directory's contents with pagination and sorting.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Share-relative path (e.g. `/docker`, `/data/photos`) |
| `offset` | 0 | Items to skip |
| `limit` | 100 | Max items (hard cap: 500) |
| `sort_by` | `name` | `name` \| `size` \| `user` \| `group` \| `mtime` \| `atime` \| `crtime` \| `type` |
| `sort_direction` | `asc` | `asc` \| `desc` |
#### `get_info`
Detailed metadata (size, owner, permissions, timestamps) for one or more paths.
Accepts comma-separated paths.
#### `check_exist`
Yes/No existence check for one or more comma-separated paths.
#### `search`
Glob-pattern search across a directory tree with async DSM polling.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Root directory |
| `pattern` | — | Filename glob, e.g. `*.log` |
| `recursive` | `true` | Search subdirectories |
| `max_results` | 200 | Cap on returned matches |
---
### Transfer
#### `download`
Download a file as base64-encoded content. Files > 10 MB are rejected.
Returns JSON: `{filename, size, content_base64}`
#### `upload`
Upload base64-encoded content to the NAS. Files > 50 MB are rejected.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Destination directory |
| `filename` | — | Name for the new file |
| `content_base64` | — | Base64-encoded bytes |
| `overwrite` | `false` | ⚠ Overwrites existing file if `true` |
| `create_parents` | `true` | Create missing parent directories |
#### `get_thumbnail`
Fetch a JPEG thumbnail for an image or video file.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Share-relative file path |
| `size` | `small` | `small` \| `medium` \| `large` \| `original` |
Returns JSON: `{filename, size_bytes, content_base64}`.
A `"warning"` field is added when the thumbnail exceeds ~500 KB base64.
Thumbnails > ~2 MB base64 are rejected with an error — use `size=small` in that case.
> **Note:** The DSM `quality` parameter has no effect — DSM ignores it. `size=small` is
> the default and recommended value; larger sizes can exceed MCP buffer limits.
---
### Organise
#### `create_folder`
Create a folder. Set `create_parents=true` to create intermediate directories.
#### `rename`
Rename a file or folder. `new_name` is a bare name (not a full path).
#### `copy` / `move`
Async copy or move. Default `overwrite=false`.
#### `delete`
**Irreversible.** Requires `confirmed=true`.
Without it, returns a preview of what would be deleted — nothing is removed.
#### `compress`
Compress one or more paths into a ZIP or 7z archive (async DSM task).
| Parameter | Default | Description |
|-----------|---------|-------------|
| `paths` | — | List of source paths |
| `dest_file_path` | — | Output archive path including filename |
| `level` | `moderate` | `store` \| `fastest` \| `fast` \| `normal` \| `moderate` \| `maximum` |
| `format` | `zip` | `zip` \| `7z` |
| `password` | `""` | Archive password |
#### `extract`
Extract a ZIP or 7z archive to a destination folder (async DSM task).
| Parameter | Default | Description |
|-----------|---------|-------------|
| `file_path` | — | Archive path on NAS |
| `dest_folder_path` | — | Destination directory |
| `overwrite` | `false` | ⚠ Overwrite existing files if `true` |
| `keep_dir` | `true` | Preserve directory structure |
| `create_subfolder` | `false` | Extract into a new subfolder |
| `password` | `""` | Archive password |
---
### Analyse
#### `dir_size`
Total size, file count, and folder count for one or more directories (comma-separated).
#### `get_md5`
Compute the MD5 checksum of a file.
#### `check_permission`
Check whether the current DSM user can write a given filename into a directory.
---
### Sharing
#### `create_sharing_link`
Create a public sharing link with optional password and expiry date (`YYYY-MM-DD`).
#### `list_sharing_links`
Paginated table of all sharing links (ID, URL, path, owner, expiry, status).
#### `delete_sharing_link`
Delete a sharing link by its ID (from `list_sharing_links`).
---
### Favorites
#### `list_favorites`
List all FileStation user favorites (name, path, type, status, real path, modified).
#### `add_favorite`
Pin a path as a FileStation favorite with a display label.
#### `delete_favorite`
Remove a favorite by its path.
---
### System
#### `background_tasks`
Paginated list of active/recent FileStation background tasks
(copy, move, delete, extract, compress) with type, status, path, and file progress.
> Only the `list` method is available on DSM 7.x — tasks cannot be cancelled via this API.
#### `list_snapshots`
List Btrfs snapshots for a shared folder (snapshot ID, creation time, description, locked).
> Requires a Btrfs-formatted volume. Returns a clear error on ext4/non-Btrfs shares
> ("requires Btrfs-formatted volume").
---
## Development
```bash
# Install with dev dependencies
uv sync --dev
# Format
uv run ruff format src/ tests/
# Lint
uv run ruff check src/ tests/
# Tests (113 tests)
uv run pytest
uv run ruff check src/
uv run ruff format src/
# Run server locally (stdio)
uv run mcp-synology-filestation serve
```
See [CLAUDE.md](CLAUDE.md) for the full development context and
[SPEC.md](SPEC.md) for DSM API call details and quirks.
---
## Security
- Passwords are stored in the **OS keyring only** — never in the config file or logs.
- Session IDs and credentials are masked in all debug output.
- The `delete` tool requires explicit `confirmed=true` to prevent accidents.
---
## License
MIT
+425 -99
View File
@@ -48,7 +48,7 @@ CLI (click)
## DSM API Endpoints
All requests use `GET /webapi/entry.cgi` with query parameters unless noted.
All requests use `POST /webapi/entry.cgi` with `application/x-www-form-urlencoded` body unless noted.
| API | Version | Methods used |
|-----|---------|--------------|
@@ -62,21 +62,99 @@ All requests use `GET /webapi/entry.cgi` with query parameters unless noted.
| `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)
### 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 ~68 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
## Tools (v0.3.4 — 26 tools)
### Read-only
#### `list_shares`
List all shared folders visible to the authenticated user.
| 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.
@@ -92,8 +170,6 @@ additional=["volume_status"]
---
#### `list_dir`
List contents of a directory with optional pagination and sorting.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
@@ -119,17 +195,11 @@ additional=["size","time"]
>
> **`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.
> (`"size,time"`) is silently ignored by DSM.
---
#### `get_info`
Get detailed metadata for one or more files or folders.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
@@ -140,19 +210,25 @@ creation time, and real volume path for each requested item.
**DSM call:** `SYNO.FileStation.List::getinfo`
```
path={comma-joined paths},
path=json.dumps([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.
---
#### `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`
Search for files matching a pattern within a directory.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
@@ -164,36 +240,48 @@ Search for files matching a pattern within a directory.
**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 |
| `path` | str | yes | Share-relative file path |
**Returns:** Object with `filename`, `size`, `content_base64` (base64-encoded file bytes).
Files larger than 10 MB return an error suggesting `sftp` instead.
**Returns:** JSON `{filename, size, content_base64}`. Files > 10 MB return an error.
**DSM call:** `SYNO.FileStation.Download::download` (streaming GET)
```
path={path}, mode=download
```
**DSM call:** `SYNO.FileStation.Download::download` (streaming GET, `mode=download`)
---
### Write (require explicit confirmation where noted)
#### `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`
Create a new directory (and optionally all intermediate parents).
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
@@ -201,113 +289,273 @@ Create a new directory (and optionally all intermediate parents).
| `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.
| `path` | str | yes | Current share-relative path |
| `new_name` | str | yes | New filename (bare name, not full path) |
**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 |
| `src` | str | yes | — | Source 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, `remove_src=false`)
**DSM call:** `SYNO.FileStation.CopyMove::start` (async task)
```
path={src}, dest_folder_path={dst}, overwrite={overwrite}, 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`.**
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 |
| `path` | str | yes | — | 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.
- `confirmed=false`: Preview of what would be deleted.
- `confirmed=true`: Success message or error.
**DSM call:** `SYNO.FileStation.Delete::start` (async task)
```
path={path}, recursive=true, accurate_progress=false
```
**DSM call:** `SYNO.FileStation.Delete::start` (async, `recursive=true`)
---
#### `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 |
| `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 a file with this name already exists |
| `overwrite` | bool | no | false | Overwrite if file 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.
**Returns:** Full path of uploaded file. Files > 50 MB are rejected.
**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>
```
---
#### `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 | `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)
> **`quality` parameter is ignored:** DSM accepts `quality` in 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 KB548 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 from `large` to `small` to 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 `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."*
---
@@ -317,8 +565,7 @@ file=<binary content decoded from content_base64>
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."
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}".
@@ -339,7 +586,8 @@ file=<binary content decoded from content_base64>
| 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." |
| 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." |
@@ -351,6 +599,59 @@ file=<binary content decoded from content_base64>
---
## 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 ~68 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
@@ -402,3 +703,28 @@ Validate config, resolve credentials, test login, list available FileStation API
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 |
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mcp-synology-filestation"
version = "0.2.7"
version = "0.4.0"
description = "MCP server for Synology FileStation"
requires-python = ">=3.12"
dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
"keyring>=25.0.0",
"click>=8.1.0",
"rich>=13.0.0",
"pypdf>=4.0.0",
]
[project.optional-dependencies]
+1 -1
View File
@@ -1,3 +1,3 @@
"""MCP server for Synology FileStation."""
__version__ = "0.2.7"
__version__ = "0.4.0"
+58 -46
View File
@@ -307,52 +307,6 @@ class FileStationClient:
raise SynologyError(_error_message(code, api), code=code)
async def start_and_poll_immediately(
self,
api: str,
start_params: dict[str, Any],
poll_version: int,
*,
start_version: int | None = None,
) -> tuple[str, dict[str, Any] | None]:
"""Start a DSM async task and immediately make the first status poll.
Designed for one-shot tasks (DirSize, MD5) where the result window
may close quickly. Both the ``start`` and the first ``status`` request
are issued inside this single method with no intermediate awaits other
than the HTTP calls themselves, minimising scheduler latency.
Args:
api: DSM API name (e.g. "SYNO.FileStation.DirSize").
start_params: Query parameters for the ``start`` call.
poll_version: API version to use for the ``status`` call.
start_version: API version for the ``start`` call (defaults to
``maxVersion`` from the API info cache).
Returns:
``(taskid, status_data)`` where ``status_data`` is ``None`` if
the first status poll returned 599 (task not yet visible).
Raises:
SynologyError: If the ``start`` call fails, the response contains
no task ID, or the ``status`` call fails with a non-599 error.
"""
start_data = await self.request(api, "start", version=start_version, params=start_params)
taskid: str = start_data.get("taskid", "")
if not taskid:
raise SynologyError("DSM did not return a task ID.", code=0)
try:
status_data = await self.request(
api, "status", version=poll_version, params={"taskid": taskid}
)
except SynologyError as e:
if e.code == 599:
return taskid, None
raise
return taskid, status_data
async def download_bytes(self, path: str) -> tuple[str, bytes]:
"""Download a file from the NAS via SYNO.FileStation.Download.
@@ -473,3 +427,61 @@ class FileStationClient:
code = body.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code)
async def get_thumbnail_bytes(self, path: str, size: str = "large") -> bytes:
"""Fetch a thumbnail for a file via SYNO.FileStation.Thumb.
Args:
path: Share-relative path to the image file.
size: Thumbnail size — "small", "medium", "large", or "original".
Returns:
Raw JPEG bytes.
Raises:
SynologyError: On HTTP 404 (file not found), non-200 status,
or DSM error in the response body.
"""
api = "SYNO.FileStation.Thumb"
await self._ensure_initialized()
http = self._get_http()
if api not in self._api_cache:
raise SynologyError(f"API '{api}' not found.", code=102)
info = self._api_cache[api]
url = f"{self._base_url}/webapi/{info['path']}"
req_params: dict[str, str] = {
"api": api,
"version": "2",
"method": "get",
"path": path,
"size": size,
"rotate": "0",
}
if self._sid:
req_params["_sid"] = self._sid
logger.debug("DSM POST: %s/get v2 — path=%s size=%s", api, path, size)
resp = await http.post(url, data=req_params)
if resp.status_code == 404:
raise SynologyError(f"File not found: {path}", code=404)
if resp.status_code != 200:
raise SynologyError(
f"Thumbnail request failed (HTTP {resp.status_code})", code=resp.status_code
)
content_type = resp.headers.get("content-type", "")
if not content_type.startswith("image/"):
# DSM returned an error envelope instead of image data
try:
body = resp.json()
code = body.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code)
except (ValueError, KeyError):
raise SynologyError(f"Unexpected response content-type: {content_type}") from None
return resp.content
+618 -144
View File
@@ -25,6 +25,11 @@ _VALID_SORT_DIR = frozenset({"asc", "desc"})
# Cap on items returned by list_dir — DSM hard limit is 10000, we enforce lower.
_MAX_LIMIT = 500
# get_thumbnail: abort if raw bytes exceed this (≈2 MB base64 after encoding).
_THUMB_ABORT_BYTES = 1_500_000
# get_thumbnail: include a warning field if raw bytes exceed this (≈500 KB base64).
_THUMB_WARN_BYTES = 375_000
def _fmt_size(size: int | None) -> str:
"""Format a byte count as a human-readable string."""
@@ -59,7 +64,88 @@ def register_filestation(
client: FileStationClient for DSM API calls.
"""
# ── internal polling helper ──────────────────────────────────────────
# ── internal polling helpers ──────────────────────────────────────────
async def _start_and_poll_oneshot(
api: str,
start_params: dict[str, Any],
start_version: int,
poll_version: int,
) -> tuple[bool, dict[str, Any] | str]:
"""Start a one-shot DSM task and poll until finished, restarting on cold-start 599s.
DirSize and MD5 are "one-shot" tasks: DSM delivers ``finished=True`` exactly
once, then discards the result. Additionally, the DSM background service for
these tasks occasionally needs a few seconds to initialise after a period of
inactivity ("cold start"). During cold start the service registers task IDs
but returns error 599 on every status poll. The correct recovery is to restart
the task once the service has had time to wake up.
Args:
api: DSM API name (e.g. "SYNO.FileStation.DirSize").
start_params: Parameters forwarded to the ``start`` method.
start_version: API version for the ``start`` call.
poll_version: API version for the ``status`` call.
Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
DSM error or timeout.
"""
from mcp_synology_filestation.client import SynologyError as _SynologyError
max_restarts = 6
timeout = 60.0
total_elapsed = 0.0
for _attempt in range(max_restarts):
try:
start_data = await client.request(
api, "start", version=start_version, params=start_params
)
except _SynologyError as e:
return False, f"Error: {e}"
taskid: str = start_data.get("taskid", "")
if not taskid:
return False, "Error: DSM did not return a task ID."
# Poll with exponential backoff; restart on 5 consecutive 599s
delay = 0.2
consecutive_599 = 0
while True:
try:
status_data = await client.request(
api, "status", version=poll_version, params={"taskid": taskid}
)
consecutive_599 = 0
if status_data.get("finished"):
return True, status_data
# Still running — keep polling
except _SynologyError as e:
if e.code != 599:
return False, f"Error: {e}"
consecutive_599 += 1
if consecutive_599 >= 5:
# 5× 599 in a row: either cold start or missed result window.
# Restart the task so DSM can re-queue it.
break
if total_elapsed >= timeout:
return (
False,
"Error: Operation timed out after 60 seconds — check NAS manually.",
)
await asyncio.sleep(delay)
total_elapsed += delay
delay = min(delay * 2, 2.0)
return (
False,
"Error: DSM did not return results after multiple retries"
" (service may be starting up — try again in a moment).",
)
async def _poll_task(
api: str,
@@ -69,15 +155,13 @@ def register_filestation(
) -> tuple[bool, dict[str, Any] | str]:
"""Poll a DSM async task until finished or timeout.
For tasks that return intermediate ``finished=False`` status while
running (CopyMove, Delete, Compress, Extract, Search). Use
``_poll_oneshot`` for DirSize and MD5.
Args:
api: DSM API name (e.g. "SYNO.FileStation.CopyMove").
version: API version to use for the status call.
taskid: Task ID returned by the corresponding start method.
initial_delay: Seconds to wait before the first status poll.
Set to 0.0 for tasks that may finish before the first poll
interval (e.g. DirSize on small directories, MD5 on small files).
Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
@@ -88,6 +172,7 @@ def register_filestation(
delay = 0.2
elapsed = initial_delay
timeout = 60.0
consecutive_599 = 0
if initial_delay > 0:
await asyncio.sleep(initial_delay)
@@ -100,9 +185,14 @@ def register_filestation(
version=version,
params={"taskid": taskid},
)
consecutive_599 = 0
except _SynologyError as e:
if e.code == 599:
pass # task not yet visible — keep polling
# 599 can be transient (task just started, not yet available).
# Retry up to 5 times before giving up.
consecutive_599 += 1
if consecutive_599 >= 5:
return False, f"Error: {e}"
else:
return False, f"Error: {e}"
else:
@@ -119,89 +209,6 @@ def register_filestation(
elapsed += delay
delay = min(delay * 2, 2.0)
async def _poll_oneshot(
api: str,
version: int,
taskid: str,
first_status: dict[str, Any] | None,
) -> tuple[bool, dict[str, Any] | str]:
"""Continue polling a one-shot DSM task after the first status poll.
Called after ``client.start_and_poll_immediately`` has already made
the first status request. Handles three outcomes for ``first_status``:
* ``finished=True`` — return immediately (task done on first poll).
* ``finished=False`` — task confirmed running; enter Phase 2
(exponential backoff until ``finished=True`` or 60 s timeout).
* ``None`` (first poll returned 599) — burst-retry 10× at 10 ms,
then enter Phase 2 regardless (large directories will eventually
return ``finished=False``; a 599 after the task was seen alive
means the window closed — fail fast with a retry message).
Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")``
on DSM error or timeout.
"""
from mcp_synology_filestation.client import SynologyError as _SynologyError
seen_alive = False
if first_status is not None:
if first_status.get("finished"):
return True, first_status
seen_alive = True # finished=False: task is running
else:
# 599 on the immediate poll: burst-retry (10×, 10 ms apart)
for _ in range(10):
await asyncio.sleep(0.01)
try:
s = await client.request(
api, "status", version=version, params={"taskid": taskid}
)
except _SynologyError as e:
if e.code == 599:
continue
return False, f"Error: {e}"
if s.get("finished"):
return True, s
seen_alive = True
break # finished=False: enter Phase 2
# ── Phase 2: exponential backoff until finished or 60 s timeout ──
delay = 0.2
elapsed = 0.0
timeout = 60.0
while True:
await asyncio.sleep(delay)
elapsed += delay
delay = min(delay * 2, 2.0)
try:
s = await client.request(api, "status", version=version, params={"taskid": taskid})
except _SynologyError as e:
if e.code == 599:
if seen_alive:
# Task was running but the one-shot window closed before we read it
return (
False,
"Error: Could not read task result — the operation finished"
" before the result was polled. Please retry.",
)
# Not yet seen alive: large dir still initialising, keep polling
else:
return False, f"Error: {e}"
else:
seen_alive = True
if s.get("finished"):
return True, s
if elapsed >= timeout:
return (
False,
"Error: Operation timed out after 60 seconds — check NAS manually.",
)
@mcp.tool()
async def list_shares():
"""List all shared folders. Returns name/path/volume-usage table."""
@@ -259,8 +266,7 @@ def register_filestation(
sort_by: str = "name",
sort_direction: str = "asc",
):
"""List directory contents. path: share-relative (e.g. /docker).
offset/limit for pagination, sort_by/sort_direction for ordering."""
"""List directory contents. path: share-relative; offset/limit/sort_by/sort_dir optional."""
from mcp_synology_filestation.client import SynologyError
# Validate inputs
@@ -346,8 +352,7 @@ def register_filestation(
recursive: bool = True,
max_results: int = 200,
):
"""Search files by glob pattern under path. pattern: e.g. "*.yaml".
recursive/max_results optional."""
"""Search files by glob pattern. pattern: e.g. *.yaml; recursive/max_results optional."""
from mcp_synology_filestation.client import SynologyError
limit = max(1, min(max_results, 1000))
@@ -358,7 +363,7 @@ def register_filestation(
"SYNO.FileStation.Search",
"start",
params={
"folder_path": path,
"folder_path": json.dumps([path]),
"recursive": "true" if recursive else "false",
"pattern": pattern,
},
@@ -467,8 +472,7 @@ def register_filestation(
@mcp.tool()
async def download(path: str):
"""Download a file as base64 (max 10 MB). path: share-relative.
Returns JSON {filename, size, content_base64}."""
"""Download a file as base64 (max 10 MB). Returns JSON {filename, size, content_base64}."""
import base64
from mcp_synology_filestation.client import SynologyError
@@ -497,8 +501,7 @@ def register_filestation(
@mcp.tool()
async def get_info(path: str):
"""Get metadata (type/size/owner/permissions/timestamps) for one or more paths.
path: comma-separated share-relative paths."""
"""Get file/folder metadata. path: one or more comma-separated share-relative paths."""
from mcp_synology_filestation.client import SynologyError
paths = [p.strip() for p in path.split(",") if p.strip()]
@@ -587,8 +590,7 @@ def register_filestation(
@mcp.tool()
async def check_exist(path: str):
"""Check if one or more paths exist. path: comma-separated share-relative paths.
Returns Yes/No table."""
"""Check if paths exist (comma-separated share-relative paths). Returns Yes/No table."""
from mcp_synology_filestation.client import SynologyError
paths = [p.strip() for p in path.split(",") if p.strip()]
@@ -636,8 +638,7 @@ def register_filestation(
name: str,
create_parents: bool = False,
):
"""Create a folder. path: parent dir, name: folder name (not full path).
create_parents: make missing parents."""
"""Create a folder. path: parent directory, name: folder name (not full path)."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -659,8 +660,7 @@ def register_filestation(
@mcp.tool()
async def rename(path: str, new_name: str):
"""Rename a file or folder. path: current share-relative path,
new_name: bare name only (not full path)."""
"""Rename a file or folder. new_name: bare name only (not a full path)."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -682,8 +682,7 @@ def register_filestation(
@mcp.tool()
async def copy(src: str, dst: str, overwrite: bool = False):
"""Copy src to dst directory.
WARNING: overwrite=True replaces existing items (default False)."""
"""Copy src to dst directory. WARNING: overwrite=True replaces existing items."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -716,8 +715,7 @@ def register_filestation(
@mcp.tool()
async def move(src: str, dst: str, overwrite: bool = False):
"""Move src to dst directory.
WARNING: overwrite=True replaces existing items (default False)."""
"""Move src to dst directory. WARNING: overwrite=True replaces existing items."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -750,8 +748,7 @@ def register_filestation(
@mcp.tool()
async def delete(path: str, confirmed: bool = False):
"""Delete a file or folder. IRREVERSIBLE.
confirmed=False (default) shows preview only; pass confirmed=True to actually delete."""
"""Delete a file or folder (IRREVERSIBLE). confirmed=False shows preview; True deletes."""
from mcp_synology_filestation.client import SynologyError
if not confirmed:
@@ -792,8 +789,7 @@ def register_filestation(
format: str = "zip",
password: str = "",
):
"""Compress paths into an archive. dest_file_path: full path incl. filename.
level: store/fastest/fast/normal/moderate/maximum. format: zip/7z."""
"""Compress paths into a ZIP or 7z archive. dest_file_path: full path incl. filename."""
from mcp_synology_filestation.client import SynologyError
_valid_levels = {"store", "fastest", "fast", "normal", "moderate", "maximum"}
@@ -845,8 +841,7 @@ def register_filestation(
create_subfolder: bool = False,
password: str = "",
):
"""Extract a ZIP or 7z archive to dest_folder_path.
overwrite/keep_dir/create_subfolder/password optional."""
"""Extract a ZIP or 7z archive to dest_folder_path."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -881,25 +876,17 @@ def register_filestation(
@mcp.tool()
async def dir_size(path: str):
"""Get total size, file count and folder count for one or more directories.
path: comma-separated share-relative paths."""
from mcp_synology_filestation.client import SynologyError
"""Get total size, file count, folder count. path: comma-separated share-relative paths."""
paths = [p.strip() for p in path.split(",") if p.strip()]
if not paths:
return "Error: no path provided."
try:
taskid, first_status = await client.start_and_poll_immediately(
"SYNO.FileStation.DirSize",
start_params={"path": json.dumps(paths)},
poll_version=1,
start_version=2,
)
except SynologyError as e:
return f"Error: {e}"
ok, result = await _poll_oneshot("SYNO.FileStation.DirSize", 1, taskid, first_status)
ok, result = await _start_and_poll_oneshot(
"SYNO.FileStation.DirSize",
start_params={"path": json.dumps(paths)},
start_version=2,
poll_version=1,
)
if not ok:
return result # type: ignore[return-value]
@@ -944,19 +931,12 @@ def register_filestation(
@mcp.tool()
async def get_md5(path: str):
"""Compute the MD5 checksum of a file on the NAS. path: share-relative file path."""
from mcp_synology_filestation.client import SynologyError
try:
taskid, first_status = await client.start_and_poll_immediately(
"SYNO.FileStation.MD5",
start_params={"file_path": json.dumps(path)},
poll_version=1,
start_version=2,
)
except SynologyError as e:
return f"Error: {e}"
ok, result = await _poll_oneshot("SYNO.FileStation.MD5", 1, taskid, first_status)
ok, result = await _start_and_poll_oneshot(
"SYNO.FileStation.MD5",
start_params={"file_path": json.dumps(path)},
start_version=2,
poll_version=1,
)
if not ok:
return result # type: ignore[return-value]
@@ -974,8 +954,7 @@ def register_filestation(
overwrite: bool = False,
create_parents: bool = True,
):
"""Upload base64-encoded content as filename into path (max 50 MB).
WARNING: overwrite=True replaces existing file (default False)."""
"""Upload base64 content to path/filename (max 50 MB). WARNING: overwrite=True replaces."""
import base64
from mcp_synology_filestation.client import SynologyError
@@ -1005,3 +984,498 @@ def register_filestation(
return f"Error: {e}"
return f"Uploaded: {path}/{filename}"
# ── permission + sharing tools ────────────────────────────────────────
@mcp.tool()
async def check_permission(
path: str,
filename: str,
overwrite: bool = False,
create_only: bool = False,
):
"""Check write permission for filename in path."""
from mcp_synology_filestation.client import SynologyError
req_params: dict[str, Any] = {"path": path, "filename": filename}
if overwrite:
req_params["overwrite"] = "true"
if create_only:
req_params["create_only"] = "true"
try:
await client.request(
"SYNO.FileStation.CheckPermission",
"write",
version=3,
params=req_params,
)
except SynologyError as e:
return f"Error: {e}"
return f"Permission granted: write {filename!r} into {path}"
@mcp.tool()
async def create_sharing_link(
path: str,
password: str = "",
date_expired: str = "",
date_available: str = "",
):
"""Create a public sharing link. date_expired/date_available: YYYY-MM-DD (optional)."""
from mcp_synology_filestation.client import SynologyError
req_params: dict[str, Any] = {"path": json.dumps(path)}
if password:
req_params["password"] = password
if date_expired:
req_params["date_expired"] = date_expired
if date_available:
req_params["date_available"] = date_available
try:
data = await client.request(
"SYNO.FileStation.Sharing",
"create",
version=3,
params=req_params,
)
except SynologyError as e:
return f"Error: {e}"
links = data.get("links", [])
if not links:
return "Error: DSM returned no sharing link."
link = links[0]
link_id = link.get("id", "?")
url = link.get("url", "?")
has_password = link.get("has_password", False)
lines = [f"Sharing link created: {url}", f"ID: {link_id}"]
if has_password:
lines.append("Password protected: yes")
return "\n".join(lines)
@mcp.tool()
async def list_sharing_links(offset: int = 0, limit: int = 100):
"""List sharing links (paginated). Table: ID, URL, path, owner, expiry, status."""
from mcp_synology_filestation.client import SynologyError
try:
data = await client.request(
"SYNO.FileStation.Sharing",
"list",
version=3,
params={"offset": str(offset), "limit": str(limit)},
)
except SynologyError as e:
return f"Error: {e}"
links = data.get("links", [])
total = data.get("total", 0)
if not links:
return f"No sharing links found. (total={total})"
rows = []
for lnk in links:
link_id = lnk.get("id", "?")
url = lnk.get("url", "?")
lpath = lnk.get("path", "?")
owner = lnk.get("link_owner", "?")
expiry = lnk.get("date_expired", "") or "never"
status = lnk.get("status", "?")
rows.append((link_id, url, lpath, owner, expiry, status))
headers = ("ID", "URL", "Path", "Owner", "Expires", "Status")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
if offset + len(rows) < total:
lines.append(
f"\nShowing {offset + 1}{offset + len(rows)} of {total}. Pass offset to paginate."
)
else:
lines.append(f"\n{len(rows)} of {total} link(s).")
return "\n".join(lines)
@mcp.tool()
async def delete_sharing_link(link_id: str):
"""Delete a sharing link by its ID. IRREVERSIBLE."""
from mcp_synology_filestation.client import SynologyError
try:
await client.request(
"SYNO.FileStation.Sharing",
"delete",
version=3,
params={"id": json.dumps(link_id)},
)
except SynologyError as e:
return f"Error: {e}"
return f"Deleted sharing link: {link_id}"
# ── thumbnail + favorites tools ───────────────────────────────────────
@mcp.tool()
async def get_thumbnail(path: str, size: str = "small"):
"""Fetch a thumbnail for an image/video. Returns JSON with filename and base64 content."""
import base64
import json as _json
from mcp_synology_filestation.client import SynologyError
valid_sizes = {"small", "medium", "large", "original"}
if size not in valid_sizes:
return f"Error: size must be one of {sorted(valid_sizes)}"
try:
img_bytes = await client.get_thumbnail_bytes(path, size)
except SynologyError as e:
return f"Error: {e}"
raw_len = len(img_bytes)
# Hard limit: refuse to return a payload that would exceed ~2 MB base64.
if raw_len > _THUMB_ABORT_BYTES:
b64_kb = raw_len * 4 // 3 // 1024
return (
f"Error: Thumbnail too large ({b64_kb} KB base64) — "
f"use size='small' to get a smaller version."
)
filename = path.rsplit("/", 1)[-1]
payload: dict = {
"filename": filename,
"size_bytes": raw_len,
"content_base64": base64.b64encode(img_bytes).decode(),
}
# Soft warning: note large payload without refusing it.
if raw_len > _THUMB_WARN_BYTES:
b64_kb = raw_len * 4 // 3 // 1024
payload["warning"] = (
f"Thumbnail is large ({b64_kb} KB base64). "
"Consider using size='small' for faster responses."
)
return _json.dumps(payload)
@mcp.tool()
async def list_favorites():
"""List all FileStation favorites. Table: name, path, type, status, real path, modified."""
from mcp_synology_filestation.client import SynologyError
try:
data = await client.request(
"SYNO.FileStation.Favorite",
"list",
version=2,
params={
"offset": 0,
"limit": 200,
"additional": json.dumps(["real_path", "size", "time"]),
},
)
except SynologyError as e:
return f"Error: {e}"
favorites: list[dict] = data.get("favorites", [])
total: int = data.get("total", len(favorites))
if not favorites:
return "No favorites found."
rows: list[tuple[str, ...]] = []
for fav in favorites:
name = fav.get("name", "")
fpath = fav.get("path", "")
ftype = "folder" if fav.get("isdir", False) else "file"
status = fav.get("status", "")
add = fav.get("additional", {})
real_path = add.get("real_path", "")
mtime = add.get("time", {}).get("mtime")
rows.append((name, fpath, ftype, status, real_path, _fmt_time(mtime)))
headers = ("Name", "Path", "Type", "Status", "Real Path", "Modified")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{total} favorite(s)")
return "\n".join(lines)
@mcp.tool()
async def add_favorite(path: str, name: str):
"""Add a path to FileStation favorites. name: display label for the favorite."""
from mcp_synology_filestation.client import SynologyError
try:
await client.request(
"SYNO.FileStation.Favorite",
"add",
version=2,
params={"path": path, "name": name, "index": -1},
)
except SynologyError as e:
return f"Error: {e}"
return f"Added favorite '{name}'{path}"
@mcp.tool()
async def delete_favorite(path: str):
"""Delete a FileStation favorite by path."""
from mcp_synology_filestation.client import SynologyError
try:
await client.request(
"SYNO.FileStation.Favorite",
"delete",
version=2,
params={"path": path},
)
except SynologyError as e:
return f"Error: {e}"
return f"Deleted favorite for {path}"
# ── background task + snapshot tools ──────────────────────────────────
@mcp.tool()
async def background_tasks(offset: int = 0, limit: int = 100):
"""List FileStation background tasks (copy, move, delete, extract, compress, etc.)."""
from mcp_synology_filestation.client import SynologyError
limit = max(1, min(limit, 200))
offset = max(0, offset)
try:
data = await client.request(
"SYNO.FileStation.BackgroundTask",
"list",
version=3,
params={"offset": offset, "limit": limit},
)
except SynologyError as e:
return f"Error: {e}"
tasks: list[dict] = data.get("tasks", [])
total: int = data.get("total", len(tasks))
if not tasks:
return "No background tasks found."
rows: list[tuple[str, ...]] = []
for task in tasks:
taskid = str(task.get("taskid", ""))
ttype = str(task.get("type", ""))
status = str(task.get("status", ""))
path = str(task.get("path", task.get("dest_folder_path", "")))
processed = task.get("processed_num_file", 0)
total_files = task.get("total_num_file", 0)
progress = f"{processed}/{total_files}" if total_files else "-"
rows.append((taskid, ttype, status, path, progress))
headers = ("Task ID", "Type", "Status", "Path", "Progress")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{total} task(s) total.")
if total > offset + limit:
lines.append(
f"Showing {offset + 1}{offset + len(tasks)} of {total}. Use offset to page."
)
return "\n".join(lines)
@mcp.tool()
async def read_text(path: str, max_chars: int = 50_000, page: int = 0):
"""Extract readable text from a PDF, TXT, or MD file (max 10 MB)."""
import io
from mcp_synology_filestation.client import SynologyError
max_read_bytes = 10 * 1024 * 1024
plain_text_exts = {
".txt",
".md",
".markdown",
".csv",
".log",
".yaml",
".yml",
".json",
".xml",
".html",
".htm",
}
path_lower = path.lower()
if path_lower.endswith(".pdf"):
file_type = "pdf"
elif any(path_lower.endswith(ext) for ext in plain_text_exts):
file_type = "text"
else:
return (
"Error: Unsupported file type. Supported: PDF, TXT, MD, CSV, JSON, YAML, XML, HTML"
" and other plain text formats."
)
try:
filename, content = await client.download_bytes(path)
except SynologyError as e:
return f"Error: {e}"
size = len(content)
if size > max_read_bytes:
return f"Error: File too large ({size / 1024 / 1024:.1f} MB). Maximum is 10 MB."
total_pages = 0
if file_type == "pdf":
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
total_pages = len(reader.pages)
if page == 0:
page_texts = [pg.extract_text() or "" for pg in reader.pages]
if not any(t.strip() for t in page_texts):
return (
"Error: No extractable text found. The PDF may be image-only"
" (scanned without OCR layer)."
)
parts: list[str] = []
for i, pg_text in enumerate(page_texts):
if i == 0:
parts.append(pg_text)
else:
parts.append(f"\n\n--- Page {i + 1} ---\n\n{pg_text}")
text = "".join(parts)
else:
if page > total_pages:
return (
f"Error: Page {page} does not exist — "
f"this PDF has {total_pages} page(s)."
)
text = reader.pages[page - 1].extract_text() or ""
if not text.strip():
return (
"Error: No extractable text found. The PDF may be image-only"
" (scanned without OCR layer)."
)
except SynologyError:
raise
except Exception as exc:
return f"Error: Failed to parse PDF: {exc}"
else:
text = content.decode("utf-8", errors="replace")
full_len = len(text)
if max_chars > 0 and full_len > max_chars:
text = (
text[:max_chars]
+ f"\n\n[Truncated: {full_len} total chars, showing first {max_chars}."
+ " Use max_chars parameter to adjust.]"
)
display_name = path.rsplit("/", 1)[-1]
if file_type == "pdf" and page != 0:
header = f"[{display_name} — Page {page}/{total_pages}{full_len} chars]"
else:
header = f"[{display_name}{full_len} chars]"
return f"{header}\n{text}"
@mcp.tool()
async def list_snapshots(share_path: str, offset: int = 0, limit: int = 100):
"""List Btrfs snapshots for a shared folder (requires Btrfs volume)."""
from mcp_synology_filestation.client import SynologyError
limit = max(1, min(limit, 500))
offset = max(0, offset)
try:
data = await client.request(
"SYNO.FileStation.Snapshot",
"list",
version=2,
params={"folder_path": share_path, "offset": offset, "limit": limit},
)
except SynologyError as e:
if e.code == 400:
return (
f"Error: Snapshots not available for '{share_path}'"
"this feature requires a Btrfs-formatted volume."
)
return f"Error: {e}"
snapshots: list[dict] = data.get("snapshots", [])
total: int = data.get("total", len(snapshots))
if not snapshots:
return f"No snapshots found for '{share_path}'."
rows: list[tuple[str, ...]] = []
for snap in snapshots:
snap_id = str(snap.get("id", snap.get("snapshot_id", "")))
snap_time = _fmt_time(snap.get("time", snap.get("create_time")))
snap_desc = str(snap.get("description", snap.get("desc", "")))
snap_lock = "Yes" if snap.get("lock", False) else "No"
rows.append((snap_id, snap_time, snap_desc, snap_lock))
headers = ("Snapshot ID", "Created", "Description", "Locked")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{total} snapshot(s) for '{share_path}'.")
if total > offset + limit:
lines.append(
f"Showing {offset + 1}{offset + len(snapshots)} of {total}. Use offset to page."
)
return "\n".join(lines)
-123
View File
@@ -1,123 +0,0 @@
"""Wegwerfskript: DirSize + MD5 direkt gegen die NAS testen.
Ausfuehren: uv run python test_dirsize_md5.py
"""
import asyncio
import json
import time
import httpx
from mcp_synology_filestation.auth import AuthManager
from mcp_synology_filestation.client import FileStationClient
from mcp_synology_filestation.config import load_config
DIRSIZE_PATHS = ["/test-mcp", "/docker"]
MD5_PATH = "/test-mcp/test.zip"
def pp(label: str, data: object, elapsed_ms: float | None = None) -> None:
print(f"\n{'='*60}")
suffix = f" [{elapsed_ms:.1f} ms]" if elapsed_ms is not None else ""
print(f" {label}{suffix}")
print("=" * 60)
print(json.dumps(data, indent=2, ensure_ascii=False))
async def raw(http: httpx.AsyncClient, url: str, sid: str, **params) -> dict:
r = await http.get(url, params={"_sid": sid, **params})
r.raise_for_status()
try:
return r.json()
except Exception:
return {"_raw": r.text[:300], "_http_status": r.status_code}
async def probe_dirsize_long(
http: httpx.AsyncClient, sid: str, api_url: str, path: str
) -> None:
"""Start DirSize and poll v1 every 200ms for up to 15s.
Goal: find out if 599 means 'task still running' (keep polling)
or 'task gone' (give up). If the task eventually returns data,
599 = 'not ready yet'. If it never returns data, 599 = 'task gone'.
"""
print(f"\n{'#'*60}")
print(f" DIRSIZE {path} — long poll (15s, every 200ms)")
print(f"{'#'*60}")
t0 = time.perf_counter()
start_body = await raw(
http, api_url, sid,
api="SYNO.FileStation.DirSize", version="2", method="start",
path=json.dumps([path]),
)
elapsed_start = (time.perf_counter() - t0) * 1000
pp(f"DirSize::start ({path})", start_body, elapsed_start)
taskid = (start_body.get("data") or {}).get("taskid")
if not taskid:
print("[!] No taskid.")
return
print(f"\n[*] Polling status v1 every 200ms for up to 15s (taskid={taskid[:12]}...)")
for attempt in range(75): # 75 * 200ms = 15s
if attempt > 0:
await asyncio.sleep(0.2)
t = time.perf_counter()
r = await raw(
http, api_url, sid,
api="SYNO.FileStation.DirSize", version="1", method="status",
taskid=taskid,
)
elapsed = (t - t0) * 1000
success = r.get("success")
data = (r.get("data") or {})
finished = data.get("finished")
error_code = (r.get("error") or {}).get("code")
if finished:
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: FERTIG! "
f"num_dir={data.get('num_dir')} "
f"num_file={data.get('num_file')} "
f"total_size={data.get('total_size')}")
pp(f"DirSize::status final ({path})", r, elapsed)
return
elif success and not finished:
# Still running — show current progress
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: running... "
f"num_dir={data.get('num_dir', '?')} "
f"num_file={data.get('num_file', '?')} "
f"total_size={data.get('total_size', '?')}")
else:
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: error code={error_code}")
# Continue polling — 599 might mean 'not ready yet'
print(f"\n[!] No result after 15s — task never returned data.")
async def main() -> None:
config = load_config()
auth = AuthManager(config)
async with FileStationClient(config.base_url, config.connection.verify_ssl) as client:
client.set_auth_manager(auth)
await client._ensure_initialized() # noqa: SLF001
sid = client.sid
base = config.base_url
info = client._api_cache.get("SYNO.FileStation.DirSize", {}) # noqa: SLF001
api_url = f"{base}/webapi/{info.get('path', 'entry.cgi')}"
print(f"[*] DirSize API: {api_url} v{info.get('minVersion')}-v{info.get('maxVersion')}")
async with httpx.AsyncClient(verify=config.connection.verify_ssl, timeout=30.0) as http:
for path in DIRSIZE_PATHS:
await probe_dirsize_long(http, sid, api_url, path)
await auth.logout(client)
print("\n[*] Logout OK.")
if __name__ == "__main__":
asyncio.run(main())
+227
View File
@@ -0,0 +1,227 @@
"""Tests for read_text tool."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mcp_synology_filestation.client import SynologyError
from mcp_synology_filestation.config import AppConfig, ConnectionConfig
@pytest.fixture()
def config() -> AppConfig:
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.example.com"),
)
def _make_tools(config: AppConfig, client: MagicMock) -> dict:
from mcp_synology_filestation.tools.filestation import register_filestation
registered: dict[str, object] = {}
mcp = MagicMock()
def tool_decorator():
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
mcp.tool = tool_decorator
register_filestation(mcp, config, client)
return registered
def _mock_pdf_reader(pages_text: list[str]) -> MagicMock:
"""Build a mock PdfReader whose .pages list returns the given text per page."""
mock_pages = []
for text in pages_text:
page = MagicMock()
page.extract_text.return_value = text
mock_pages.append(page)
reader = MagicMock()
reader.pages = mock_pages
return reader
# ── TXT / plain text ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_txt_success(config: AppConfig) -> None:
"""TXT file content is decoded and returned with header."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("readme.txt", b"Hello, world!"))
tools = _make_tools(config, client)
result = await tools["read_text"]("/docs/readme.txt")
assert "readme.txt" in result
assert "Hello, world!" in result
assert "13 chars" in result # len("Hello, world!") == 13
@pytest.mark.asyncio
async def test_read_text_md_success(config: AppConfig) -> None:
"""Markdown file is treated as plain text."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("notes.md", b"# Title\n\nBody text."))
tools = _make_tools(config, client)
result = await tools["read_text"]("/docs/notes.md")
assert "notes.md" in result
assert "# Title" in result
assert "Body text." in result
# ── PDF extraction ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_pdf_all_pages(config: AppConfig) -> None:
"""PDF all-pages mode joins pages with separator."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("report.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["First page text.", "Second page text."])
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/report.pdf")
assert "report.pdf" in result
assert "First page text." in result
assert "Second page text." in result
assert "--- Page 2 ---" in result
@pytest.mark.asyncio
async def test_read_text_pdf_single_page(config: AppConfig) -> None:
"""PDF single-page mode returns only the requested page."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("doc.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["Page one.", "Page two.", "Page three."])
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/doc.pdf", page=2)
assert "Page 2/3" in result
assert "Page two." in result
assert "Page one." not in result
assert "Page three." not in result
@pytest.mark.asyncio
async def test_read_text_pdf_page_out_of_range(config: AppConfig) -> None:
"""Requesting a page beyond the PDF page count returns an error."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("doc.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["Only page."])
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/doc.pdf", page=5)
assert result.startswith("Error:")
assert "5" in result
assert "1" in result # total pages
@pytest.mark.asyncio
async def test_read_text_pdf_image_only(config: AppConfig) -> None:
"""PDF with no extractable text returns image-only error."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("scan.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["", ""]) # no text on any page
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/scan.pdf")
assert result.startswith("Error:")
assert "image-only" in result.lower() or "No extractable text" in result
# ── max_chars truncation ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_max_chars_truncation(config: AppConfig) -> None:
"""Text exceeding max_chars is truncated with a hint."""
long_text = "A" * 200
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("big.txt", long_text.encode()))
tools = _make_tools(config, client)
result = await tools["read_text"]("/data/big.txt", max_chars=50)
assert "Truncated" in result
assert "200 total chars" in result
assert "showing first 50" in result
# The returned content before the truncation note must be exactly 50 'A's
assert "A" * 50 in result
@pytest.mark.asyncio
async def test_read_text_max_chars_zero_no_limit(config: AppConfig) -> None:
"""max_chars=0 disables truncation."""
long_text = "B" * 100_000
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("huge.txt", long_text.encode()))
tools = _make_tools(config, client)
result = await tools["read_text"]("/data/huge.txt", max_chars=0)
assert "Truncated" not in result
assert "B" * 100 in result # spot-check some content
# ── error cases ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_unsupported_type(config: AppConfig) -> None:
"""Unknown file extension returns an unsupported-type error."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("binary.exe", b"\x00\x01\x02"))
tools = _make_tools(config, client)
result = await tools["read_text"]("/bin/binary.exe")
assert result.startswith("Error:")
assert "Unsupported file type" in result
@pytest.mark.asyncio
async def test_read_text_file_too_large(config: AppConfig) -> None:
"""Files exceeding 10 MB return a size error without downloading the full content."""
oversized = b"x" * (10 * 1024 * 1024 + 1)
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("big.txt", oversized))
tools = _make_tools(config, client)
result = await tools["read_text"]("/data/big.txt")
assert result.startswith("Error:")
assert "10 MB" in result
@pytest.mark.asyncio
async def test_read_text_dsm_error(config: AppConfig) -> None:
"""DSM errors from download are surfaced as Error: messages."""
client = MagicMock()
client.download_bytes = AsyncMock(side_effect=SynologyError(1800, "File not found"))
tools = _make_tools(config, client)
result = await tools["read_text"]("/missing/file.txt")
assert result.startswith("Error:")
+344 -24
View File
@@ -22,24 +22,8 @@ def config() -> AppConfig:
def _make_mcp_and_tools(config: AppConfig, client: MagicMock) -> dict:
"""Register FileStation tools on a mock FastMCP and collect them by name."""
from mcp_synology_filestation.client import FileStationClient
from mcp_synology_filestation.tools.filestation import register_filestation
# Bind the real start_and_poll_immediately so it delegates into the
# already-mocked client.request — no separate mock needed per test.
async def _start_and_poll_immediately(
api: str,
start_params: dict,
poll_version: int,
*,
start_version: int | None = None,
):
return await FileStationClient.start_and_poll_immediately(
client, api, start_params, poll_version, start_version=start_version
)
client.start_and_poll_immediately = _start_and_poll_immediately
registered: dict[str, object] = {}
mcp = MagicMock()
@@ -479,7 +463,7 @@ async def test_search_success(config: AppConfig) -> None:
start_call = client.request.call_args_list[0]
assert start_call[0][0] == "SYNO.FileStation.Search"
assert start_call[0][1] == "start"
assert start_call[1]["params"]["folder_path"] == "/docker"
assert json.loads(start_call[1]["params"]["folder_path"]) == ["/docker"]
assert start_call[1]["params"]["pattern"] == "*.yaml"
assert start_call[1]["params"]["recursive"] == "true"
# Verify clean was called last
@@ -1651,12 +1635,8 @@ async def test_dir_size_retries_on_transient_599(config: AppConfig) -> None:
@pytest.mark.asyncio
async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None:
"""dir_size times out after 60 s when DSM returns only 599s for every poll.
The immediate poll + burst both return 599; Phase 2 keeps polling (large
directories eventually surface) until the 60 s timeout fires.
"""
async def test_dir_size_fails_after_5_consecutive_599(config: AppConfig) -> None:
"""dir_size gives up and returns Error: after exhausting all restart attempts."""
client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs):
@@ -1671,7 +1651,34 @@ async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None:
result = await tools["dir_size"](path="/dead")
assert result.startswith("Error:")
assert "timed out" in result.lower() or "60 seconds" in result
@pytest.mark.asyncio
async def test_dir_size_cold_start_restart(config: AppConfig) -> None:
"""dir_size restarts the task after 5 consecutive 599s and succeeds on second attempt."""
client = MagicMock()
start_count = {"n": 0}
status_count = {"n": 0}
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
start_count["n"] += 1
return {"taskid": f"task_{start_count['n']}"}
status_count["n"] += 1
# First 5 status calls → 599 (simulates cold start)
if status_count["n"] <= 5:
raise SynologyError("DSM error code 599", code=599)
# After restart: immediately done
return {"finished": True, "num_dir": 1, "num_file": 5, "total_size": 1024}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await tools["dir_size"](path="/coldstart")
assert "Total Size" in result
assert start_count["n"] == 2 # task was restarted once after cold-start 599s
# ──────────────────────────────────────────────────────────────────────────
@@ -1759,3 +1766,316 @@ async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None:
assert result.startswith("Error:")
assert "md5" in result.lower() or "hash" in result.lower()
# ──────────────────────────────────────────────────────────────────────────
# get_thumbnail
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_thumbnail_success(config: AppConfig) -> None:
"""get_thumbnail returns JSON with filename, size_bytes, content_base64 for small images."""
import base64
client = MagicMock()
img_bytes = b"\xff\xd8\xff" + b"\x00" * 1000 # fake JPEG, well under limits
client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/test.jpg", size="small")
payload = json.loads(result)
assert payload["filename"] == "test.jpg"
assert payload["size_bytes"] == len(img_bytes)
assert base64.b64decode(payload["content_base64"]) == img_bytes
assert "warning" not in payload
@pytest.mark.asyncio
async def test_get_thumbnail_invalid_size(config: AppConfig) -> None:
"""get_thumbnail returns Error: for an unrecognised size value."""
client = MagicMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/test.jpg", size="tiny")
assert result.startswith("Error:")
assert "size" in result.lower()
@pytest.mark.asyncio
async def test_get_thumbnail_dsm_error(config: AppConfig) -> None:
"""get_thumbnail returns Error: when the client raises SynologyError."""
from mcp_synology_filestation.client import SynologyError
client = MagicMock()
client.get_thumbnail_bytes = AsyncMock(
side_effect=SynologyError("File not found: /photo/missing.jpg", code=404)
)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/missing.jpg")
assert result.startswith("Error:")
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_get_thumbnail_large_warning(config: AppConfig) -> None:
"""get_thumbnail includes a warning field when image exceeds the soft limit (~375 KB raw)."""
client = MagicMock()
# 400 000 bytes raw > 375 000 threshold but < 1 500 000 hard limit
img_bytes = b"\x00" * 400_000
client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/big.jpg", size="medium")
payload = json.loads(result)
assert "content_base64" in payload
assert "warning" in payload
assert "small" in payload["warning"].lower()
@pytest.mark.asyncio
async def test_get_thumbnail_too_large_aborts(config: AppConfig) -> None:
"""get_thumbnail returns Error: (no base64) when image exceeds the hard limit (~1.5 MB raw)."""
client = MagicMock()
# 2 000 000 bytes raw > 1 500 000 hard limit
img_bytes = b"\x00" * 2_000_000
client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/huge.jpg", size="original")
assert result.startswith("Error:")
assert "large" in result.lower() or "too large" in result.lower()
# Must NOT contain base64 payload
assert "content_base64" not in result
@pytest.mark.asyncio
async def test_get_thumbnail_default_size_is_small(config: AppConfig) -> None:
"""get_thumbnail defaults to size='small'."""
client = MagicMock()
client.get_thumbnail_bytes = AsyncMock(return_value=b"\xff\xd8" + b"\x00" * 100)
tools = _make_mcp_and_tools(config, client)
await tools["get_thumbnail"](path="/photo/test.jpg")
client.get_thumbnail_bytes.assert_called_once_with("/photo/test.jpg", "small")
# ──────────────────────────────────────────────────────────────────────────
# background_tasks
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_background_tasks_empty(config: AppConfig) -> None:
"""background_tasks returns a 'no tasks' message when the list is empty."""
client = MagicMock()
client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"]()
assert "No background tasks" in result
@pytest.mark.asyncio
async def test_background_tasks_with_tasks(config: AppConfig) -> None:
"""background_tasks returns a formatted table when tasks are present."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"offset": 0,
"total": 1,
"tasks": [
{
"taskid": "FileStation_CopyMove_1",
"type": "CopyMove",
"status": "running",
"path": "/docker/dest",
"processed_num_file": 3,
"total_num_file": 10,
}
],
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"]()
assert "FileStation_CopyMove_1" in result
assert "CopyMove" in result
assert "running" in result
assert "/docker/dest" in result
assert "3/10" in result
assert "1 task(s)" in result
@pytest.mark.asyncio
async def test_background_tasks_pagination_hint(config: AppConfig) -> None:
"""background_tasks shows a pagination hint when there are more results."""
client = MagicMock()
tasks = [
{
"taskid": f"task_{i}",
"type": "Delete",
"status": "running",
"path": f"/share/item{i}",
"total_num_file": 0,
}
for i in range(5)
]
client.request = AsyncMock(return_value={"offset": 0, "total": 20, "tasks": tasks})
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"](offset=0, limit=5)
assert "20 task(s)" in result
assert "offset" in result.lower()
@pytest.mark.asyncio
async def test_background_tasks_dsm_error(config: AppConfig) -> None:
"""background_tasks returns Error: when DSM raises an exception."""
client = MagicMock()
client.request = AsyncMock(
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
)
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"]()
assert result.startswith("Error:")
@pytest.mark.asyncio
async def test_background_tasks_dsm_api_call(config: AppConfig) -> None:
"""background_tasks calls the correct DSM API with offset and limit."""
client = MagicMock()
client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
await tools["background_tasks"](offset=10, limit=50)
call = client.request.call_args
assert call[0][0] == "SYNO.FileStation.BackgroundTask"
assert call[0][1] == "list"
assert call[1]["version"] == 3
assert call[1]["params"]["offset"] == 10
assert call[1]["params"]["limit"] == 50
# ──────────────────────────────────────────────────────────────────────────
# list_snapshots
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_snapshots_btrfs_not_available(config: AppConfig) -> None:
"""list_snapshots returns a Btrfs-specific error message on DSM error 400."""
client = MagicMock()
client.request = AsyncMock(side_effect=SynologyError("Invalid parameter", code=400))
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert result.startswith("Error:")
assert "Btrfs" in result
@pytest.mark.asyncio
async def test_list_snapshots_empty(config: AppConfig) -> None:
"""list_snapshots returns a 'no snapshots' message when the list is empty."""
client = MagicMock()
client.request = AsyncMock(return_value={"snapshots": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert "No snapshots" in result
assert "/docker" in result
@pytest.mark.asyncio
async def test_list_snapshots_with_data(config: AppConfig) -> None:
"""list_snapshots returns a formatted table when snapshots are present."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"total": 2,
"snapshots": [
{
"id": "snap_001",
"time": 1700000000,
"description": "Before upgrade",
"lock": True,
},
{"id": "snap_002", "time": 1700100000, "description": "", "lock": False},
],
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert "snap_001" in result
assert "snap_002" in result
assert "Before upgrade" in result
assert "Yes" in result # locked
assert "No" in result # not locked
assert "2 snapshot(s)" in result
@pytest.mark.asyncio
async def test_list_snapshots_dsm_error_other(config: AppConfig) -> None:
"""list_snapshots surfaces non-400 DSM errors as 'Error: …'."""
client = MagicMock()
client.request = AsyncMock(
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert result.startswith("Error:")
assert "Permission" in result
@pytest.mark.asyncio
async def test_list_snapshots_dsm_api_call(config: AppConfig) -> None:
"""list_snapshots calls SYNO.FileStation.Snapshot::list v2 with the correct params."""
client = MagicMock()
client.request = AsyncMock(return_value={"snapshots": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
await tools["list_snapshots"](share_path="/data", offset=0, limit=50)
call = client.request.call_args
assert call[0][0] == "SYNO.FileStation.Snapshot"
assert call[0][1] == "list"
assert call[1]["version"] == 2
assert call[1]["params"]["folder_path"] == "/data"
assert call[1]["params"]["offset"] == 0
assert call[1]["params"]["limit"] == 50
@pytest.mark.asyncio
async def test_list_snapshots_pagination_hint(config: AppConfig) -> None:
"""list_snapshots shows a pagination hint when more results are available."""
client = MagicMock()
snaps = [
{"id": f"snap_{i}", "time": 1700000000 + i * 3600, "description": "", "lock": False}
for i in range(3)
]
client.request = AsyncMock(return_value={"snapshots": snaps, "total": 10})
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/data", offset=0, limit=3)
assert "10 snapshot(s)" in result
assert "offset" in result.lower()
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]]
name = "mcp-synology-filestation"
version = "0.2.4"
version = "0.3.6"
source = { editable = "." }
dependencies = [
{ name = "click" },