Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65cb5a44c7 | |||
| 1bb75c9f36 | |||
| f2abf2af1e | |||
| 4430807b55 | |||
| 4c6de3bfc7 | |||
| b88677a20c | |||
| 161121b140 | |||
| 314fae9167 | |||
| 3dd6197fb3 | |||
| 79b7384aeb | |||
| ff79d438b0 | |||
| 53d5db142f | |||
| 800b36a2b0 | |||
| 83bccbcb53 | |||
| ae90e5f09a | |||
| 451ee7116f | |||
| 8b2f07d9c3 | |||
| 62f8e41931 | |||
| 0e8ffaa6df | |||
| 6510493930 | |||
| 4bf655236d | |||
| c0d4c347c5 | |||
| e3fa71b458 | |||
| 4d8eae752d | |||
| c923da6f6a | |||
| d8d7c6fd47 | |||
| 4145d929a6 | |||
| 04caaef003 | |||
| 1d0cf940b4 | |||
| fc706fb809 | |||
| 500dc73324 | |||
| 473c771c20 | |||
| dbab842738 |
@@ -14,18 +14,37 @@ 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` |
|
||||
@@ -36,10 +55,16 @@ Sibling project to `mcp-synology-container`.
|
||||
## 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,6 +97,39 @@ 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 ~6–8 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
|
||||
|
||||
```
|
||||
@@ -88,20 +146,57 @@ src/mcp_synology_filestation/
|
||||
└── 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 |
|
||||
| `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 default) |
|
||||
| `move` | Move a file or folder (async polling, overwrite=False default) |
|
||||
| `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 (v1–v3).
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ~6–8 s to initialise. During this window every `status` poll returns error 599. The
|
||||
correct recovery is to wait a moment, then restart the task. Retrying the same `taskid` does
|
||||
not help — the service must be cold-started via a new `start` call.
|
||||
|
||||
**DirSize `status` version:** Must use `version=1`. Using `version=2` always returns 599
|
||||
regardless of service state.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
## 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 KB–548 KB raw depending on source image resolution.
|
||||
> - `medium` / `large` → can exceed 380 KB raw even for modest photos.
|
||||
> - `original` → reflects the actual stored image; may be several MB.
|
||||
> Default was changed 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 400–403) 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 ~6–8 s to initialise.
|
||||
During cold start, `status` returns 599 on every poll. The fix is to wait briefly and
|
||||
**restart** the task (new `start` call) — polling the original `taskid` again does not help.
|
||||
`_start_and_poll_oneshot()` retries up to 6 task restarts before giving up.
|
||||
|
||||
### DirSize `status` API version
|
||||
|
||||
`SYNO.FileStation.DirSize::status` must be called with `version=1`. Using `version=2`
|
||||
always returns error 599 regardless of whether the background service is running.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Model
|
||||
|
||||
```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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcp-synology-filestation"
|
||||
version = "0.1.0"
|
||||
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,3 +1,3 @@
|
||||
"""MCP server for Synology FileStation."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -247,8 +247,6 @@ class FileStationClient:
|
||||
Raises:
|
||||
SynologyError: On API errors.
|
||||
"""
|
||||
sys.stderr.write(f"[dsm] request: {api}/{method}\n")
|
||||
sys.stderr.flush()
|
||||
if not self._initializing:
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
@@ -429,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:")
|
||||
+1005
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user