Compare commits
32 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 |
@@ -14,32 +14,57 @@ Sibling project to `mcp-synology-container`.
|
|||||||
- **Credentials:** OS keyring, service name `mcp-synology-filestation`
|
- **Credentials:** OS keyring, service name `mcp-synology-filestation`
|
||||||
- **Gitea:** `https://gitea.gecheckt.de/marcus/mcp-synology-filestation`
|
- **Gitea:** `https://gitea.gecheckt.de/marcus/mcp-synology-filestation`
|
||||||
- **Local code:** `D:\Dev\Projects\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
|
## Deploy Workflow
|
||||||
|
|
||||||
1. Commit and push via Claude Code.
|
1. Commit and push via Claude Code.
|
||||||
2. `uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git`
|
2. **Close Claude Desktop first** (Windows holds file locks — reinstall fails otherwise).
|
||||||
3. Restart Claude Desktop.
|
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
|
## Toolchain
|
||||||
|
|
||||||
| Task | Command |
|
| Task | Command |
|
||||||
|------|---------|
|
|-------------|-------------------------------------------|
|
||||||
| Format | `ruff format src/ tests/` |
|
| Format | `ruff format src/ tests/` |
|
||||||
| Lint | `ruff check src/ tests/` |
|
| Lint | `ruff check src/ tests/` |
|
||||||
| Tests | `pytest` |
|
| Tests | `pytest` |
|
||||||
| Install dev | `uv sync --dev` |
|
| Install dev | `uv sync --dev` |
|
||||||
| Run server | `uv run mcp-synology-filestation serve` |
|
| Run server | `uv run mcp-synology-filestation serve` |
|
||||||
| Setup | `uv run mcp-synology-filestation setup` |
|
| Setup | `uv run mcp-synology-filestation setup` |
|
||||||
|
|
||||||
## Code Standards
|
## Code Standards
|
||||||
|
|
||||||
- **Type hints** on all public functions and methods.
|
- **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`.
|
- **Async-first:** use `httpx.AsyncClient` throughout; never `requests`.
|
||||||
- **Formatter:** `ruff format` (line length 100).
|
- **Formatter:** `ruff format` (line length 100).
|
||||||
- **Linter:** `ruff check` — fix all warnings before committing.
|
- **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
|
## Security Rules
|
||||||
|
|
||||||
@@ -53,8 +78,8 @@ Sibling project to `mcp-synology-container`.
|
|||||||
`overwrite=True` are considered destructive.
|
`overwrite=True` are considered destructive.
|
||||||
- The `delete` tool MUST require `confirmed=True` to proceed. Without it, return a preview
|
- 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.
|
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
|
- For overwrite scenarios in `move`/`copy`/`upload`, include a warning in the tool
|
||||||
and default `overwrite` to `False`.
|
description and default `overwrite` to `False`.
|
||||||
|
|
||||||
## Error Handling Rules
|
## Error Handling Rules
|
||||||
|
|
||||||
@@ -72,36 +97,106 @@ Sibling project to `mcp-synology-container`.
|
|||||||
- Include item counts and pagination hints where relevant.
|
- Include item counts and pagination hints where relevant.
|
||||||
- Error messages are prefixed with `Error:` for easy recognition by Claude.
|
- 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
|
## Module Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/mcp_synology_filestation/
|
src/mcp_synology_filestation/
|
||||||
├── __init__.py # __version__
|
├── __init__.py # __version__
|
||||||
├── __main__.py # entry point
|
├── __main__.py # entry point
|
||||||
├── server.py # create_server(config, client) → FastMCP
|
├── server.py # create_server(config, client) → FastMCP
|
||||||
├── client.py # FileStationClient (async httpx wrapper)
|
├── client.py # FileStationClient (async httpx wrapper)
|
||||||
├── auth.py # AuthManager: keyring, env vars, 2FA, login/logout
|
├── auth.py # AuthManager: keyring, env vars, 2FA, login/logout
|
||||||
├── config.py # AppConfig, ConnectionConfig, load_config, save_config
|
├── config.py # AppConfig, ConnectionConfig, load_config, save_config
|
||||||
├── cli.py # click: setup / check / serve
|
├── cli.py # click: setup / check / serve
|
||||||
└── tools/
|
└── tools/
|
||||||
├── __init__.py
|
├── __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 |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|-----------------------|--------------------------------------------------------------|
|
||||||
| `list_shares` | List all shared folders with volume usage |
|
| `list_shares` | List all shared folders with volume usage |
|
||||||
| `list_dir` | List directory contents with pagination and sorting |
|
| `list_dir` | List directory contents with pagination and sorting |
|
||||||
| `get_info` | Get detailed metadata for one or more paths |
|
| `get_info` | Get detailed metadata for one or more paths |
|
||||||
| `search` | Search for files by glob pattern with async polling |
|
| `check_exist` | Check if one or more paths exist (Yes/No table) |
|
||||||
| `download` | Download a file as base64 (max 10 MB) |
|
| `search` | Search for files by glob pattern with async polling |
|
||||||
| `create_folder` | Create a new folder (optionally with parent dirs) |
|
| `download` | Download a file as base64 (max 10 MB) |
|
||||||
| `rename` | Rename a file or folder |
|
| `create_folder` | Create a new folder (optionally with parent dirs) |
|
||||||
| `copy` | Copy a file or folder (async polling, overwrite=False default) |
|
| `rename` | Rename a file or folder |
|
||||||
| `move` | Move a file or folder (async polling, overwrite=False default) |
|
| `copy` | Copy a file or folder (async polling, overwrite=False) |
|
||||||
| `delete` | Delete a file or folder — requires confirmed=True |
|
| `move` | Move a file or folder (async polling, overwrite=False) |
|
||||||
| `upload` | Upload base64-encoded content to a path (max 50 MB) |
|
| `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-synology-filestation
|
||||||
|
|
||||||
MCP server for Synology FileStation — browse, search, transfer, and manage files
|
MCP server that exposes a **Synology NAS FileStation** as tools for Claude Desktop (and any
|
||||||
on your NAS via Claude.
|
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 |
|
| Group | Tools |
|
||||||
|------|-------------|
|
|-------|-------|
|
||||||
| `list_shares` | List all shared folders |
|
| **Browse** | `list_shares`, `list_dir`, `get_info`, `check_exist`, `search` |
|
||||||
| `list_dir` | Directory contents with pagination and sorting |
|
| **Transfer** | `download`, `upload`, `get_thumbnail` |
|
||||||
| `get_info` | File or folder metadata |
|
| **Organise** | `create_folder`, `rename`, `copy`, `move`, `delete`, `compress`, `extract` |
|
||||||
| `search` | Recursive pattern search |
|
| **Analyse** | `dir_size`, `get_md5`, `check_permission` |
|
||||||
| `download` | Download a file (base64 content) |
|
| **Sharing** | `create_sharing_link`, `list_sharing_links`, `delete_sharing_link` |
|
||||||
| `create_folder` | Create a new directory |
|
| **Favorites** | `list_favorites`, `add_favorite`, `delete_favorite` |
|
||||||
| `rename` | Rename a file or folder |
|
| **System** | `background_tasks`, `list_snapshots` |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
uv tool install git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git
|
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
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install with dev dependencies
|
||||||
uv sync --dev
|
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 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
|
## 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 |
|
| 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.Rename` | 2 | `rename` |
|
||||||
| `SYNO.FileStation.CopyMove` | 3 | `start`, `status`, `stop` |
|
| `SYNO.FileStation.CopyMove` | 3 | `start`, `status`, `stop` |
|
||||||
| `SYNO.FileStation.Delete` | 2 | `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
|
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`.
|
(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
|
### Read-only
|
||||||
|
|
||||||
#### `list_shares`
|
| Tool | Description |
|
||||||
List all shared folders visible to the authenticated user.
|
|------|-------------|
|
||||||
|
| `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
|
**Parameters:** none
|
||||||
|
|
||||||
**Returns:** Formatted table of share names, paths, and volume usage.
|
**Returns:** Formatted table of share names, paths, and volume usage.
|
||||||
@@ -92,8 +170,6 @@ additional=["volume_status"]
|
|||||||
---
|
---
|
||||||
|
|
||||||
#### `list_dir`
|
#### `list_dir`
|
||||||
List contents of a directory with optional pagination and sorting.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Default | Description |
|
| Name | Type | Required | Default | Description |
|
||||||
|------|------|----------|---------|-------------|
|
|------|------|----------|---------|-------------|
|
||||||
@@ -119,17 +195,11 @@ additional=["size","time"]
|
|||||||
>
|
>
|
||||||
> **`additional` format:** Must be a JSON array serialised as a string
|
> **`additional` format:** Must be a JSON array serialised as a string
|
||||||
> (`json.dumps(["size","time"])` → `'["size", "time"]'`). A comma-separated 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
|
> (`"size,time"`) is silently ignored by DSM.
|
||||||
> every file entry.
|
|
||||||
>
|
|
||||||
> **`SYNO.FileStation.Stat`:** Not available on this NAS's API registry. Use
|
|
||||||
> `SYNO.FileStation.List::getinfo` for per-path metadata instead.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `get_info`
|
#### `get_info`
|
||||||
Get detailed metadata for one or more files or folders.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Description |
|
| Name | Type | Required | Description |
|
||||||
|------|------|----------|-------------|
|
|------|------|----------|-------------|
|
||||||
@@ -140,19 +210,25 @@ creation time, and real volume path for each requested item.
|
|||||||
|
|
||||||
**DSM call:** `SYNO.FileStation.List::getinfo`
|
**DSM call:** `SYNO.FileStation.List::getinfo`
|
||||||
```
|
```
|
||||||
path={comma-joined paths},
|
path=json.dumps([paths...]),
|
||||||
additional=["real_path","size","time","perm","owner","type"]
|
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`
|
||||||
Search for files matching a pattern within a directory.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Default | Description |
|
| 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.
|
**Returns:** List of matching paths with type, size, and modification time.
|
||||||
|
|
||||||
**DSM calls:** `SYNO.FileStation.Search::start` → poll `::list` → `::stop` + `::clean`
|
**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`
|
||||||
Download a single file and return its content.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Description |
|
| 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).
|
**Returns:** JSON `{filename, size, content_base64}`. Files > 10 MB return an error.
|
||||||
Files larger than 10 MB return an error suggesting `sftp` instead.
|
|
||||||
|
|
||||||
**DSM call:** `SYNO.FileStation.Download::download` (streaming GET)
|
**DSM call:** `SYNO.FileStation.Download::download` (streaming GET, `mode=download`)
|
||||||
```
|
|
||||||
path={path}, 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_folder`
|
||||||
Create a new directory (and optionally all intermediate parents).
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Default | Description |
|
| Name | Type | Required | Default | Description |
|
||||||
|------|------|----------|---------|-------------|
|
|------|------|----------|---------|-------------|
|
||||||
@@ -201,113 +289,273 @@ Create a new directory (and optionally all intermediate parents).
|
|||||||
| `name` | str | yes | — | New folder name |
|
| `name` | str | yes | — | New folder name |
|
||||||
| `create_parents` | bool | no | false | Create missing parent directories |
|
| `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`
|
**DSM call:** `SYNO.FileStation.CreateFolder::create`
|
||||||
```
|
|
||||||
folder_path={path}, name={name}, force_parent={create_parents}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `rename`
|
#### `rename`
|
||||||
Rename a file or directory.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Description |
|
| Name | Type | Required | Description |
|
||||||
|------|------|----------|-------------|
|
|------|------|----------|-------------|
|
||||||
| `path` | str | yes | Absolute path to the item |
|
| `path` | str | yes | Current share-relative path |
|
||||||
| `new_name` | str | yes | New filename (not a full path) |
|
| `new_name` | str | yes | New filename (bare name, not full path) |
|
||||||
|
|
||||||
**Returns:** New absolute path after rename.
|
|
||||||
|
|
||||||
**DSM call:** `SYNO.FileStation.Rename::rename`
|
**DSM call:** `SYNO.FileStation.Rename::rename`
|
||||||
```
|
|
||||||
path={path}, name={new_name}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `move`
|
|
||||||
Move a file or directory to a new location.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
| Name | Type | Required | Default | Description |
|
|
||||||
|------|------|----------|---------|-------------|
|
|
||||||
| `src` | str | yes | — | Source absolute path |
|
|
||||||
| `dst` | str | yes | — | Destination directory path |
|
|
||||||
| `overwrite` | bool | no | false | Overwrite if destination exists |
|
|
||||||
|
|
||||||
**Returns:** Destination path on success, or a descriptive error.
|
|
||||||
|
|
||||||
**DSM call:** `SYNO.FileStation.CopyMove::start` (async task)
|
|
||||||
```
|
|
||||||
path={src}, dest_folder_path={dst}, overwrite={overwrite}, remove_src=true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `copy`
|
#### `copy`
|
||||||
Copy a file or directory to a new location.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Default | Description |
|
| Name | Type | Required | Default | Description |
|
||||||
|------|------|----------|---------|-------------|
|
|------|------|----------|---------|-------------|
|
||||||
| `src` | str | yes | — | Source absolute path |
|
| `src` | str | yes | — | Source path |
|
||||||
| `dst` | str | yes | — | Destination directory path |
|
| `dst` | str | yes | — | Destination directory path |
|
||||||
| `overwrite` | bool | no | false | Overwrite if destination exists |
|
| `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`
|
#### `delete`
|
||||||
**Destructive — requires `confirmed=True`.**
|
**Destructive — requires `confirmed=True`.**
|
||||||
|
|
||||||
Delete a file or directory. Without confirmation, returns a preview of what would be deleted.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Default | Description |
|
| 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 |
|
| `confirmed` | bool | yes | false | Must be `true` to proceed |
|
||||||
|
|
||||||
**Returns:**
|
**Returns:**
|
||||||
- `confirmed=false`: Preview message listing the path and item type.
|
- `confirmed=false`: Preview of what would be deleted.
|
||||||
- `confirmed=true`: Success message or error detail.
|
- `confirmed=true`: Success message or error.
|
||||||
|
|
||||||
**DSM call:** `SYNO.FileStation.Delete::start` (async task)
|
**DSM call:** `SYNO.FileStation.Delete::start` (async, `recursive=true`)
|
||||||
```
|
|
||||||
path={path}, recursive=true, accurate_progress=false
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `upload`
|
#### `upload`
|
||||||
Upload a file to the NAS from base64-encoded content.
|
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Name | Type | Required | Default | Description |
|
| 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 |
|
| `filename` | str | yes | — | Filename to create |
|
||||||
| `content_base64` | str | yes | — | Base64-encoded file content |
|
| `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 |
|
| `create_parents` | bool | no | true | Create missing parent directories |
|
||||||
|
|
||||||
**Returns:** Full path of the uploaded file or an error message.
|
**Returns:** Full path of uploaded file. Files > 50 MB are rejected.
|
||||||
Files exceeding 50 MB should not be uploaded via MCP; return a clear error.
|
|
||||||
|
|
||||||
**DSM call:** `SYNO.FileStation.Upload::upload` (POST multipart/form-data)
|
**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.
|
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.
|
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`.
|
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
|
4. Session expiry errors (106, 107, 119) are retried once transparently.
|
||||||
fails, the user sees "Session expired — please restart the MCP server."
|
|
||||||
5. Network errors (timeouts, connection refused) are reported as
|
5. Network errors (timeouts, connection refused) are reported as
|
||||||
"Cannot reach NAS at {host} — check connectivity."
|
"Cannot reach NAS at {host} — check connectivity."
|
||||||
6. Unknown DSM error codes are reported as "DSM error {code}: {raw_message}".
|
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." |
|
| 401 | Guest or disabled account | "DSM account is disabled." |
|
||||||
| 403 | 2FA required | "Two-factor authentication required — run setup." |
|
| 403 | 2FA required | "Two-factor authentication required — run setup." |
|
||||||
| 404 | 2FA failed | "OTP code incorrect." |
|
| 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}" |
|
| 1800 | File not found | "File or folder not found: {path}" |
|
||||||
| 1801 | No write permission | "No write permission for: {path}" |
|
| 1801 | No write permission | "No write permission for: {path}" |
|
||||||
| 1802 | File exists | "A file already exists at this 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
|
## Configuration Model
|
||||||
|
|
||||||
```yaml
|
```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),
|
Load config and credentials, create `FileStationClient` (lazy — no immediate connection),
|
||||||
create and run `FastMCP("mcp-synology-filestation")` over stdio. Uses `anyio.run()` for
|
create and run `FastMCP("mcp-synology-filestation")` over stdio. Uses `anyio.run()` for
|
||||||
Windows compatibility.
|
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]
|
[project]
|
||||||
name = "mcp-synology-filestation"
|
name = "mcp-synology-filestation"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
description = "MCP server for Synology FileStation"
|
description = "MCP server for Synology FileStation"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"keyring>=25.0.0",
|
"keyring>=25.0.0",
|
||||||
"click>=8.1.0",
|
"click>=8.1.0",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
|
"pypdf>=4.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""MCP server for Synology FileStation."""
|
"""MCP server for Synology FileStation."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.4.0"
|
||||||
|
|||||||
@@ -247,8 +247,6 @@ class FileStationClient:
|
|||||||
Raises:
|
Raises:
|
||||||
SynologyError: On API errors.
|
SynologyError: On API errors.
|
||||||
"""
|
"""
|
||||||
sys.stderr.write(f"[dsm] request: {api}/{method}\n")
|
|
||||||
sys.stderr.flush()
|
|
||||||
if not self._initializing:
|
if not self._initializing:
|
||||||
await self._ensure_initialized()
|
await self._ensure_initialized()
|
||||||
http = self._get_http()
|
http = self._get_http()
|
||||||
@@ -429,3 +427,61 @@ class FileStationClient:
|
|||||||
|
|
||||||
code = body.get("error", {}).get("code", 0)
|
code = body.get("error", {}).get("code", 0)
|
||||||
raise SynologyError(_error_message(code, api), code=code)
|
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:")
|
||||||
@@ -463,7 +463,7 @@ async def test_search_success(config: AppConfig) -> None:
|
|||||||
start_call = client.request.call_args_list[0]
|
start_call = client.request.call_args_list[0]
|
||||||
assert start_call[0][0] == "SYNO.FileStation.Search"
|
assert start_call[0][0] == "SYNO.FileStation.Search"
|
||||||
assert start_call[0][1] == "start"
|
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"]["pattern"] == "*.yaml"
|
||||||
assert start_call[1]["params"]["recursive"] == "true"
|
assert start_call[1]["params"]["recursive"] == "true"
|
||||||
# Verify clean was called last
|
# Verify clean was called last
|
||||||
@@ -1152,6 +1152,305 @@ async def test_check_exist_multi_path(config: AppConfig) -> None:
|
|||||||
assert "/ghost" in requested_paths
|
assert "/ghost" in requested_paths
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# compress
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compress_success(config: AppConfig) -> None:
|
||||||
|
"""compress polls until finished and returns the archive path."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_compress1"}
|
||||||
|
if method == "status":
|
||||||
|
return {"finished": True}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["compress"](
|
||||||
|
paths=["/data/report.pdf", "/data/photos"],
|
||||||
|
dest_file_path="/backup/archive.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "Compressed to: /backup/archive.zip"
|
||||||
|
|
||||||
|
# Verify DSM call parameters
|
||||||
|
start_call = client.request.call_args_list[0]
|
||||||
|
assert start_call[0][0] == "SYNO.FileStation.Compress"
|
||||||
|
assert start_call[0][1] == "start"
|
||||||
|
assert start_call[1]["version"] == 3
|
||||||
|
p = start_call[1]["params"]
|
||||||
|
assert json.loads(p["path"]) == ["/data/report.pdf", "/data/photos"]
|
||||||
|
assert json.loads(p["dest_file_path"]) == "/backup/archive.zip"
|
||||||
|
assert p["level"] == "moderate"
|
||||||
|
assert p["mode"] == "add"
|
||||||
|
assert p["format"] == "zip"
|
||||||
|
assert p["compress_password"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compress_polling_multiple_rounds(config: AppConfig) -> None:
|
||||||
|
"""compress returns success after multiple polling rounds."""
|
||||||
|
client = MagicMock()
|
||||||
|
poll_calls = 0
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
nonlocal poll_calls
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_compress2"}
|
||||||
|
if method == "status":
|
||||||
|
poll_calls += 1
|
||||||
|
return {"finished": poll_calls >= 3}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["compress"](
|
||||||
|
paths=["/data/big-folder"],
|
||||||
|
dest_file_path="/backup/big.7z",
|
||||||
|
format="7z",
|
||||||
|
level="maximum",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "Compressed to: /backup/big.7z"
|
||||||
|
assert poll_calls == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compress_dsm_error_on_start(config: AppConfig) -> None:
|
||||||
|
"""compress returns Error: when the start call fails."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock(side_effect=SynologyError("No write permission", code=1801))
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["compress"](
|
||||||
|
paths=["/data/file.txt"],
|
||||||
|
dest_file_path="/backup/out.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "permission" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compress_invalid_level(config: AppConfig) -> None:
|
||||||
|
"""compress rejects unknown level values before making any DSM call."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock()
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
result = await tools["compress"](
|
||||||
|
paths=["/data/file.txt"],
|
||||||
|
dest_file_path="/backup/out.zip",
|
||||||
|
level="ultra",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "level" in result
|
||||||
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compress_invalid_format(config: AppConfig) -> None:
|
||||||
|
"""compress rejects unknown format values before making any DSM call."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock()
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
result = await tools["compress"](
|
||||||
|
paths=["/data/file.txt"],
|
||||||
|
dest_file_path="/backup/out.zip",
|
||||||
|
format="tar.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "format" in result
|
||||||
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compress_empty_paths(config: AppConfig) -> None:
|
||||||
|
"""compress rejects an empty paths list before making any DSM call."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock()
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
result = await tools["compress"](paths=[], dest_file_path="/backup/out.zip")
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "paths" in result.lower() or "empty" in result.lower()
|
||||||
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compress_timeout(config: AppConfig) -> None:
|
||||||
|
"""compress returns an error after polling times out."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_compress_timeout"}
|
||||||
|
return {"finished": False}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["compress"](
|
||||||
|
paths=["/data/huge"],
|
||||||
|
dest_file_path="/backup/huge.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "timed out" in result.lower() or "60 seconds" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# extract
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_success(config: AppConfig) -> None:
|
||||||
|
"""extract polls until finished and returns the dest_folder_path from status."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_extract1"}
|
||||||
|
if method == "status":
|
||||||
|
return {
|
||||||
|
"finished": True,
|
||||||
|
"dest_folder_path": "/data/extracted",
|
||||||
|
"path": "/backup/archive.zip",
|
||||||
|
"progress": 1,
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["extract"](
|
||||||
|
file_path="/backup/archive.zip",
|
||||||
|
dest_folder_path="/data/extracted",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "Extracted to: /data/extracted"
|
||||||
|
|
||||||
|
# Verify DSM call parameters
|
||||||
|
start_call = client.request.call_args_list[0]
|
||||||
|
assert start_call[0][0] == "SYNO.FileStation.Extract"
|
||||||
|
assert start_call[0][1] == "start"
|
||||||
|
assert start_call[1]["version"] == 2
|
||||||
|
p = start_call[1]["params"]
|
||||||
|
assert json.loads(p["file_path"]) == "/backup/archive.zip"
|
||||||
|
assert json.loads(p["dest_folder_path"]) == "/data/extracted"
|
||||||
|
assert p["overwrite"] == "false"
|
||||||
|
assert p["keep_dir"] == "true"
|
||||||
|
assert p["create_subfolder"] == "false"
|
||||||
|
assert p["codepage"] == "enu"
|
||||||
|
assert p["password"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_overwrite_and_subfolder(config: AppConfig) -> None:
|
||||||
|
"""extract passes overwrite=true and create_subfolder=true when requested."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_extract2"}
|
||||||
|
return {"finished": True, "dest_folder_path": "/data/out"}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
await tools["extract"](
|
||||||
|
file_path="/backup/archive.zip",
|
||||||
|
dest_folder_path="/data/out",
|
||||||
|
overwrite=True,
|
||||||
|
create_subfolder=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
p = client.request.call_args_list[0][1]["params"]
|
||||||
|
assert p["overwrite"] == "true"
|
||||||
|
assert p["create_subfolder"] == "true"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_dest_folder_from_status(config: AppConfig) -> None:
|
||||||
|
"""extract uses dest_folder_path from status response when available."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_extract3"}
|
||||||
|
return {"finished": True, "dest_folder_path": "/data/real-dest"}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["extract"](
|
||||||
|
file_path="/backup/archive.zip",
|
||||||
|
dest_folder_path="/data/requested",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should report what DSM confirmed, not what we requested
|
||||||
|
assert result == "Extracted to: /data/real-dest"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_dsm_error_on_start(config: AppConfig) -> None:
|
||||||
|
"""extract returns Error: when the start call fails (e.g. bad path)."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["extract"](
|
||||||
|
file_path="/backup/missing.zip",
|
||||||
|
dest_folder_path="/data/out",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "not found" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_timeout(config: AppConfig) -> None:
|
||||||
|
"""extract returns an error after polling times out."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_extract_timeout"}
|
||||||
|
return {"finished": False, "progress": 0.1}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["extract"](
|
||||||
|
file_path="/backup/huge.zip",
|
||||||
|
dest_folder_path="/data/out",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "timed out" in result.lower() or "60 seconds" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_exist_empty_path(config: AppConfig) -> None:
|
async def test_check_exist_empty_path(config: AppConfig) -> None:
|
||||||
"""check_exist returns Error: when no path is given."""
|
"""check_exist returns Error: when no path is given."""
|
||||||
@@ -1197,3 +1496,586 @@ async def test_check_exist_uses_getinfo(config: AppConfig) -> None:
|
|||||||
call_args = client.request.call_args
|
call_args = client.request.call_args
|
||||||
assert call_args[0][0] == "SYNO.FileStation.List"
|
assert call_args[0][0] == "SYNO.FileStation.List"
|
||||||
assert call_args[0][1] == "getinfo"
|
assert call_args[0][1] == "getinfo"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# dir_size
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dir_size_success(config: AppConfig) -> None:
|
||||||
|
"""dir_size polls until finished and returns a formatted table."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_dirsize1"}
|
||||||
|
if method == "status":
|
||||||
|
return {
|
||||||
|
"finished": True,
|
||||||
|
"num_dir": 4,
|
||||||
|
"num_file": 23,
|
||||||
|
"total_size": 5_242_880,
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
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="/data")
|
||||||
|
|
||||||
|
assert "Folders" in result
|
||||||
|
assert "Files" in result
|
||||||
|
assert "Total Size" in result
|
||||||
|
assert "4" in result
|
||||||
|
assert "23" in result
|
||||||
|
assert "5 MB" in result or "MB" in result
|
||||||
|
|
||||||
|
# Verify DSM call params
|
||||||
|
start_call = client.request.call_args_list[0]
|
||||||
|
assert start_call[0][0] == "SYNO.FileStation.DirSize"
|
||||||
|
assert start_call[0][1] == "start"
|
||||||
|
assert start_call[1]["version"] == 2
|
||||||
|
assert json.loads(start_call[1]["params"]["path"]) == ["/data"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dir_size_multi_path(config: AppConfig) -> None:
|
||||||
|
"""dir_size passes all comma-separated paths as a JSON array."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_dirsize2"}
|
||||||
|
return {"finished": True, "num_dir": 1, "num_file": 2, "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="/data, /backup")
|
||||||
|
|
||||||
|
start_params = client.request.call_args_list[0][1]["params"]
|
||||||
|
assert json.loads(start_params["path"]) == ["/data", "/backup"]
|
||||||
|
assert "/data" in result
|
||||||
|
assert "/backup" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dir_size_dsm_error_on_start(config: AppConfig) -> None:
|
||||||
|
"""dir_size returns Error: when start fails."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["dir_size"](path="/missing")
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "not found" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dir_size_timeout(config: AppConfig) -> None:
|
||||||
|
"""dir_size returns Error: after polling times out."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_dirsize_timeout"}
|
||||||
|
return {"finished": False, "num_dir": 0, "num_file": 0, "total_size": 0}
|
||||||
|
|
||||||
|
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="/huge")
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "timed out" in result.lower() or "60 seconds" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dir_size_empty_path(config: AppConfig) -> None:
|
||||||
|
"""dir_size returns Error: for blank path without making a DSM call."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock()
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
|
||||||
|
result = await tools["dir_size"](path=" ")
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dir_size_retries_on_transient_599(config: AppConfig) -> None:
|
||||||
|
"""dir_size retries up to 4 times on code-599 then succeeds on 5th status call."""
|
||||||
|
client = MagicMock()
|
||||||
|
call_count = {"status": 0}
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_dirsize_599"}
|
||||||
|
call_count["status"] += 1
|
||||||
|
if call_count["status"] < 4:
|
||||||
|
raise SynologyError("DSM error code 599", code=599)
|
||||||
|
return {"finished": True, "num_dir": 2, "num_file": 10, "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="/data")
|
||||||
|
|
||||||
|
assert "Total Size" in result
|
||||||
|
assert call_count["status"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_dirsize_dead"}
|
||||||
|
raise SynologyError("DSM error code 599", code=599)
|
||||||
|
|
||||||
|
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="/dead")
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# get_md5
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_md5_success(config: AppConfig) -> None:
|
||||||
|
"""get_md5 polls until finished and returns the MD5 string."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_md5_1"}
|
||||||
|
if method == "status":
|
||||||
|
return {"finished": True, "md5": "d41d8cd98f00b204e9800998ecf8427e"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["get_md5"](path="/data/file.zip")
|
||||||
|
|
||||||
|
assert result == "MD5 of /data/file.zip: d41d8cd98f00b204e9800998ecf8427e"
|
||||||
|
|
||||||
|
# Verify DSM call params
|
||||||
|
start_call = client.request.call_args_list[0]
|
||||||
|
assert start_call[0][0] == "SYNO.FileStation.MD5"
|
||||||
|
assert start_call[0][1] == "start"
|
||||||
|
assert start_call[1]["version"] == 2
|
||||||
|
assert json.loads(start_call[1]["params"]["file_path"]) == "/data/file.zip"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_md5_dsm_error_on_start(config: AppConfig) -> None:
|
||||||
|
"""get_md5 returns Error: when start fails (e.g. file not found)."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["get_md5"](path="/data/missing.zip")
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "not found" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_md5_timeout(config: AppConfig) -> None:
|
||||||
|
"""get_md5 returns Error: after polling times out."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_md5_timeout"}
|
||||||
|
return {"finished": False}
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["get_md5"](path="/data/huge.iso")
|
||||||
|
|
||||||
|
assert result.startswith("Error:")
|
||||||
|
assert "timed out" in result.lower() or "60 seconds" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None:
|
||||||
|
"""get_md5 returns Error: when finished status contains no md5 field."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
async def _request(api, method, version=None, params=None, **kwargs):
|
||||||
|
if method == "start":
|
||||||
|
return {"taskid": "FileStation_md5_nohash"}
|
||||||
|
return {"finished": True} # md5 field absent
|
||||||
|
|
||||||
|
client.request = AsyncMock(side_effect=_request)
|
||||||
|
tools = _make_mcp_and_tools(config, client)
|
||||||
|
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await tools["get_md5"](path="/data/file.zip")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-filestation"
|
name = "mcp-synology-filestation"
|
||||||
version = "0.1.0"
|
version = "0.3.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user