13 Commits

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 06:46:45 +02:00
marcus 4430807b55 fix: get_thumbnail size limits, default=small, quality quirk docs (v0.3.5)
- Default size changed large → small (avoids MCP buffer overflows)
- Hard limit: return Error: when thumbnail exceeds ~2 MB base64 (1.5 MB raw)
- Soft limit: add "warning" field to JSON when thumbnail exceeds ~500 KB base64
  (375 KB raw), advising to use size='small'
- Constants _THUMB_ABORT_BYTES / _THUMB_WARN_BYTES moved to module level
- 6 new tests for size cap/warning/default/DSM-error paths (113 total)
- SPEC.md: document quality-ignored quirk, size ranges, soft+hard limits
- CLAUDE.md: DSM Quirks entry for Thumb quality/size behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 06:39:57 +02:00
marcus 4c6de3bfc7 feat: add background_tasks + list_snapshots tools (v0.3.4)
- background_tasks: SYNO.FileStation.BackgroundTask::list (v3) — paginated
  table of active/recent copy/move/delete/extract/compress tasks
- list_snapshots: SYNO.FileStation.Snapshot::list (v2) — Btrfs snapshots
  per share; maps error 400 to a clear Btrfs-required message
- 20 new tests (107 total)
- SPEC.md and CLAUDE.md updated (26 tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:54:03 +02:00
marcus b88677a20c docs: document Search::start folder_path array format + Extract::start file_path key
Hard-won DSM quirks confirmed by live testing:
- Search::start folder_path must be json.dumps([path]) — plain string or
  json.dumps(path) is silently ignored, causing empty results
- Extract::start source archive key is file_path (not path); both
  file_path and dest_folder_path require json.dumps()

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:18:05 +02:00
marcus 800b36a2b0 chore: release v0.2.10, bump to 0.3.0-dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:33:35 +02:00
10 changed files with 1517 additions and 98 deletions
+132 -37
View File
@@ -14,32 +14,57 @@ Sibling project to `mcp-synology-container`.
- **Credentials:** OS keyring, service name `mcp-synology-filestation`
- **Gitea:** `https://gitea.gecheckt.de/marcus/mcp-synology-filestation`
- **Local code:** `D:\Dev\Projects\mcp-synology-filestation`
- **Runtime:** Python 3.12+, `uv`, MCP SDK, `httpx`, `keyring`, `click`, `rich`
- **Runtime:** Python 3.12+, `uv`, MCP SDK (`mcp>=1.0.0`), `httpx`, `keyring`, `click`, `rich`
- **Test share on NAS:** `/test-mcp` — only for MCP tests, no production data
- **DSM test user:** `Testuser` / `Lasdas1234` (read-only, for browser tests)
## Deploy Workflow
1. Commit and push via Claude Code.
2. `uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git`
3. Restart Claude Desktop.
2. **Close Claude Desktop first** (Windows holds file locks — reinstall fails otherwise).
3. Install:
```
uv tool install --reinstall dist\mcp_synology_filestation-X.Y.Z-py3-none-any.whl
```
Or directly from Gitea:
```
uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git
```
4. Restart Claude Desktop.
5. Verify: `uv tool list` shows the expected version.
## Versioning
Bump the patch version on **every commit** in **both** files:
- `pyproject.toml` → `version = "X.Y.Z"`
- `src/mcp_synology_filestation/__init__.py` → `__version__ = "X.Y.Z"`
Current series: `0.3.x`
## Toolchain
| Task | Command |
|------|---------|
| Format | `ruff format src/ tests/` |
| Lint | `ruff check src/ tests/` |
| Tests | `pytest` |
| Install dev | `uv sync --dev` |
| Run server | `uv run mcp-synology-filestation serve` |
| Setup | `uv run mcp-synology-filestation setup` |
| Task | Command |
|-------------|-------------------------------------------|
| Format | `ruff format src/ tests/` |
| Lint | `ruff check src/ tests/` |
| Tests | `pytest` |
| Install dev | `uv sync --dev` |
| Run server | `uv run mcp-synology-filestation serve` |
| Setup | `uv run mcp-synology-filestation setup` |
## Code Standards
- **Type hints** on all public functions and methods.
- **Docstrings** (English) on all public modules, classes, and functions.
- **Docstrings** (English, 1 line max for `@mcp.tool()` functions) on all public modules,
classes, and functions.
- **Async-first:** use `httpx.AsyncClient` throughout; never `requests`.
- **Formatter:** `ruff format` (line length 100).
- **Linter:** `ruff check` — fix all warnings before committing.
- **No `-> str` on `@mcp.tool()` functions.** FastMCP generates `outputSchema` from return
annotations, which bloats the `tools/list` payload and causes Claude Desktop to truncate
the tool list. Omit return type annotations on all tool functions.
- **Short docstrings on tools.** Keep tool docstrings to 1 line. Long docstrings also bloat
the `tools/list` payload.
## Security Rules
@@ -53,8 +78,8 @@ Sibling project to `mcp-synology-container`.
`overwrite=True` are considered destructive.
- The `delete` tool MUST require `confirmed=True` to proceed. Without it, return a preview
message that describes exactly what would be deleted — never silently proceed.
- For overwrite scenarios in `move`/`copy`/`upload`, include a warning in the tool description
and default `overwrite` to `False`.
- For overwrite scenarios in `move`/`copy`/`upload`, include a warning in the tool
description and default `overwrite` to `False`.
## Error Handling Rules
@@ -72,36 +97,106 @@ Sibling project to `mcp-synology-container`.
- Include item counts and pagination hints where relevant.
- Error messages are prefixed with `Error:` for easy recognition by Claude.
## DSM Quirks (hard-won knowledge)
- **JSON-encode path parameters:** `path`, `dest_file_path`, `dest_folder_path`, `name` etc.
must be passed as `json.dumps("/path")` or `json.dumps(["/path1", "/path2"])`.
Plain strings or Python lists are silently ignored by DSM.
- **Share paths only:** always use `/docker`, `/homes/marcus` — never `/volume1/docker`.
Volume paths cause DSM error 408.
- **`additional` field:** must be `json.dumps(["size","time"])` — comma-separated string
does not work.
- **`List::list` additional:** only `["size","time"]` confirmed working on this firmware.
`real_path`, `perm`, `type` cause error 408.
- **`SYNO.FileStation.Stat`:** not in API registry — use `List::getinfo` instead.
- **`SYNO.FileStation.CheckExist`:** returns error 400 — use `List::getinfo` instead
(entries with `name=null` do not exist).
- **DirSize / MD5 one-shot:** `finished=true` is returned exactly once, then the task is
deleted. Implemented via `_start_and_poll_oneshot()` — never poll again after
`finished=true`.
- **DirSize cold start:** after inactivity, DSM's background service needs ~68 s to
initialise. During this window every `status` poll returns 599.
Fix: restart the task (new `start` call) — up to 6 restarts within a 60 s budget.
- **DirSize `status` version:** must use `version=1`. Version 2 always returns 599.
- **`Sharing::delete` id parameter:** must be `json.dumps(link_id)`.
- **`CheckPermission::write`:** `path` and `filename` are plain strings (no `json.dumps`).
Returns `{"blSkip": false}` on success.
- **`Search::start` folder_path:** must be a JSON array: `json.dumps([path])`.
A plain string or `json.dumps(path)` is silently ignored by DSM — it starts an empty
search and immediately returns `finished=true, files=[]`.
- **`Extract::start` parameter name:** the source archive key is `file_path` (not `path`).
Both path parameters need `json.dumps()`: `file_path=json.dumps(...)` and
`dest_folder_path=json.dumps(...)`.
- **Error 599:** means "background service not ready / task not found" for DirSize/MD5.
Handled by `_start_and_poll_oneshot()`.
## Module Structure
```
src/mcp_synology_filestation/
├── __init__.py # __version__
├── __main__.py # entry point
├── server.py # create_server(config, client) → FastMCP
├── client.py # FileStationClient (async httpx wrapper)
├── auth.py # AuthManager: keyring, env vars, 2FA, login/logout
├── config.py # AppConfig, ConnectionConfig, load_config, save_config
├── cli.py # click: setup / check / serve
├── __init__.py # __version__
├── __main__.py # entry point
├── server.py # create_server(config, client) → FastMCP
├── client.py # FileStationClient (async httpx wrapper)
├── auth.py # AuthManager: keyring, env vars, 2FA, login/logout
├── config.py # AppConfig, ConnectionConfig, load_config, save_config
├── cli.py # click: setup / check / serve
└── tools/
├── __init__.py
└── filestation.py # register_filestation(mcp, config, client)
└── filestation.py # register_filestation(mcp, config, client)
```
## Implemented Tools
## Implemented Tools (v0.3.5 — 26 tools)
| Tool | Description |
|------|-------------|
| `list_shares` | List all shared folders with volume usage |
| `list_dir` | List directory contents with pagination and sorting |
| `get_info` | Get detailed metadata for one or more paths |
| `search` | Search for files by glob pattern with async polling |
| `download` | Download a file as base64 (max 10 MB) |
| `create_folder` | Create a new folder (optionally with parent dirs) |
| `rename` | Rename a file or folder |
| `copy` | Copy a file or folder (async polling, overwrite=False default) |
| `move` | Move a file or folder (async polling, overwrite=False default) |
| `delete` | Delete a file or folder — requires confirmed=True |
| `upload` | Upload base64-encoded content to a path (max 50 MB) |
| Tool | Description |
|-----------------------|--------------------------------------------------------------|
| `list_shares` | List all shared folders with volume usage |
| `list_dir` | List directory contents with pagination and sorting |
| `get_info` | Get detailed metadata for one or more paths |
| `check_exist` | Check if one or more paths exist (Yes/No table) |
| `search` | Search for files by glob pattern with async polling |
| `download` | Download a file as base64 (max 10 MB) |
| `create_folder` | Create a new folder (optionally with parent dirs) |
| `rename` | Rename a file or folder |
| `copy` | Copy a file or folder (async polling, overwrite=False) |
| `move` | Move a file or folder (async polling, overwrite=False) |
| `delete` | Delete a file or folder — requires confirmed=True |
| `upload` | Upload base64-encoded content to a path (max 50 MB) |
| `compress` | Compress paths into a ZIP or 7z archive |
| `extract` | Extract a ZIP or 7z archive to a destination folder |
| `dir_size` | Total size, file count, folder count for directories |
| `get_md5` | Compute MD5 checksum of a file |
| `check_permission` | Check write permission for a filename in a directory |
| `create_sharing_link` | Create a public sharing link (optional password + expiry) |
| `list_sharing_links` | List all sharing links (paginated table) |
| `delete_sharing_link` | Delete a sharing link by ID |
| `get_thumbnail` | Fetch a thumbnail for an image/video file as base64 |
| `list_favorites` | List all FileStation user favorites |
| `add_favorite` | Pin a path as a FileStation favorite |
| `delete_favorite` | Remove a path from FileStation favorites |
| `background_tasks` | List FileStation background tasks (copy, move, delete, etc.) |
| `list_snapshots` | List Btrfs snapshots for a share (requires Btrfs volume) |
See [SPEC.md](SPEC.md) for the full planned tool set.
See [SPEC.md](SPEC.md) for full tool specifications and DSM call details.
## DSM Quirks (continued)
- **`SYNO.FileStation.Snapshot::list`:** Returns error 400 when the share is not on a
Btrfs-formatted volume. Confirmed: this NAS has no Btrfs volumes — `list_snapshots`
maps error 400 to a clear "requires Btrfs-formatted volume" message.
- **`SYNO.FileStation.BackgroundTask`:** Only the `list` method is available (v1v3).
No stop/cancel/clear methods exist on this firmware.
- **`SYNO.FileStation.Thumb` — `quality` parameter ignored:** DSM accepts the `quality`
field in the POST body but always returns the same JPEG regardless of the value.
No server-side quality control is available.
- **`SYNO.FileStation.Thumb` — size limits:** `small` thumbnails range from ~5 KB to
~548 KB raw depending on source image resolution. `medium`/`large` can exceed 380 KB
raw. `original` reflects the full stored image and may be several MB.
The `get_thumbnail` tool enforces: abort >1.5 MB raw (≈2 MB base64), warning >375 KB
raw (≈500 KB base64). Default changed from `large` to `small` to avoid MCP buffer
overflows.
## Roadmap
### v0.4 — candidates
No further tools currently planned.
+285 -21
View File
@@ -1,40 +1,304 @@
# mcp-synology-filestation
MCP server for Synology FileStation — browse, search, transfer, and manage files
on your NAS via Claude.
MCP server that exposes a **Synology NAS FileStation** as tools for Claude Desktop (and any
other MCP client). All 26 tools are production-tested against DSM 7.x.
## Status
---
Work in progress. See [SPEC.md](SPEC.md) for the planned tool set.
## Features
## Planned Tools
26 tools covering the full FileStation surface:
| Tool | Description |
|------|-------------|
| `list_shares` | List all shared folders |
| `list_dir` | Directory contents with pagination and sorting |
| `get_info` | File or folder metadata |
| `search` | Recursive pattern search |
| `download` | Download a file (base64 content) |
| `create_folder` | Create a new directory |
| `rename` | Rename a file or folder |
| `move` | Move to a new location |
| `copy` | Copy to a new location |
| `delete` | Delete a path (requires confirmation) |
| `upload` | Upload a file from base64 content |
| Group | Tools |
|-------|-------|
| **Browse** | `list_shares`, `list_dir`, `get_info`, `check_exist`, `search` |
| **Transfer** | `download`, `upload`, `get_thumbnail` |
| **Organise** | `create_folder`, `rename`, `copy`, `move`, `delete`, `compress`, `extract` |
| **Analyse** | `dir_size`, `get_md5`, `check_permission` |
| **Sharing** | `create_sharing_link`, `list_sharing_links`, `delete_sharing_link` |
| **Favorites** | `list_favorites`, `add_favorite`, `delete_favorite` |
| **System** | `background_tasks`, `list_snapshots` |
## Setup
---
## Requirements
- Python 3.12+
- [`uv`](https://docs.astral.sh/uv/) (recommended) or pip
- Synology NAS running DSM 7.x with FileStation enabled
- A DSM user account with FileStation access
---
## Installation
### From Gitea
```bash
uv tool install git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git
```
### Reinstall (upgrade)
```bash
uv tool install --reinstall git+https://gitea.gecheckt.de/marcus/mcp-synology-filestation.git
```
> **Windows / Claude Desktop:** close Claude Desktop before reinstalling — Windows holds
> file locks on running executables.
---
## Setup
Run the interactive wizard once to configure the NAS connection and store credentials
securely in the OS keyring:
```bash
mcp-synology-filestation setup
```
The wizard will:
1. Ask for your NAS host, port, and HTTPS settings
2. Ask for your DSM username and password (stored in OS keyring — never written to disk)
3. Handle 2FA / device token if required
4. Verify the FileStation API is reachable
5. Print a ready-to-paste Claude Desktop config snippet
### Verify the setup
```bash
mcp-synology-filestation check
```
---
## Claude Desktop integration
Add the server to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"synology-filestation": {
"command": "mcp-synology-filestation",
"args": ["serve"]
}
}
}
```
The server uses **stdio transport** and is fully compatible with Claude Desktop on
Windows, macOS, and Linux.
### Environment variable overrides
| Variable | Purpose |
|----------|---------|
| `SYNOLOGY_HOST` | Override NAS hostname |
| `SYNOLOGY_USERNAME` | Override DSM username |
| `SYNOLOGY_PASSWORD` | Override DSM password (not stored) |
---
## Tool reference
### Browse
#### `list_shares`
List all shared folders with volume usage (total / used / %).
#### `list_dir`
List a directory's contents with pagination and sorting.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Share-relative path (e.g. `/docker`, `/data/photos`) |
| `offset` | 0 | Items to skip |
| `limit` | 100 | Max items (hard cap: 500) |
| `sort_by` | `name` | `name` \| `size` \| `user` \| `group` \| `mtime` \| `atime` \| `crtime` \| `type` |
| `sort_direction` | `asc` | `asc` \| `desc` |
#### `get_info`
Detailed metadata (size, owner, permissions, timestamps) for one or more paths.
Accepts comma-separated paths.
#### `check_exist`
Yes/No existence check for one or more comma-separated paths.
#### `search`
Glob-pattern search across a directory tree with async DSM polling.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Root directory |
| `pattern` | — | Filename glob, e.g. `*.log` |
| `recursive` | `true` | Search subdirectories |
| `max_results` | 200 | Cap on returned matches |
---
### Transfer
#### `download`
Download a file as base64-encoded content. Files > 10 MB are rejected.
Returns JSON: `{filename, size, content_base64}`
#### `upload`
Upload base64-encoded content to the NAS. Files > 50 MB are rejected.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Destination directory |
| `filename` | — | Name for the new file |
| `content_base64` | — | Base64-encoded bytes |
| `overwrite` | `false` | ⚠ Overwrites existing file if `true` |
| `create_parents` | `true` | Create missing parent directories |
#### `get_thumbnail`
Fetch a JPEG thumbnail for an image or video file.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `path` | — | Share-relative file path |
| `size` | `small` | `small` \| `medium` \| `large` \| `original` |
Returns JSON: `{filename, size_bytes, content_base64}`.
A `"warning"` field is added when the thumbnail exceeds ~500 KB base64.
Thumbnails > ~2 MB base64 are rejected with an error — use `size=small` in that case.
> **Note:** The DSM `quality` parameter has no effect — DSM ignores it. `size=small` is
> the default and recommended value; larger sizes can exceed MCP buffer limits.
---
### Organise
#### `create_folder`
Create a folder. Set `create_parents=true` to create intermediate directories.
#### `rename`
Rename a file or folder. `new_name` is a bare name (not a full path).
#### `copy` / `move`
Async copy or move. Default `overwrite=false`.
#### `delete`
**Irreversible.** Requires `confirmed=true`.
Without it, returns a preview of what would be deleted — nothing is removed.
#### `compress`
Compress one or more paths into a ZIP or 7z archive (async DSM task).
| Parameter | Default | Description |
|-----------|---------|-------------|
| `paths` | — | List of source paths |
| `dest_file_path` | — | Output archive path including filename |
| `level` | `moderate` | `store` \| `fastest` \| `fast` \| `normal` \| `moderate` \| `maximum` |
| `format` | `zip` | `zip` \| `7z` |
| `password` | `""` | Archive password |
#### `extract`
Extract a ZIP or 7z archive to a destination folder (async DSM task).
| Parameter | Default | Description |
|-----------|---------|-------------|
| `file_path` | — | Archive path on NAS |
| `dest_folder_path` | — | Destination directory |
| `overwrite` | `false` | ⚠ Overwrite existing files if `true` |
| `keep_dir` | `true` | Preserve directory structure |
| `create_subfolder` | `false` | Extract into a new subfolder |
| `password` | `""` | Archive password |
---
### Analyse
#### `dir_size`
Total size, file count, and folder count for one or more directories (comma-separated).
#### `get_md5`
Compute the MD5 checksum of a file.
#### `check_permission`
Check whether the current DSM user can write a given filename into a directory.
---
### Sharing
#### `create_sharing_link`
Create a public sharing link with optional password and expiry date (`YYYY-MM-DD`).
#### `list_sharing_links`
Paginated table of all sharing links (ID, URL, path, owner, expiry, status).
#### `delete_sharing_link`
Delete a sharing link by its ID (from `list_sharing_links`).
---
### Favorites
#### `list_favorites`
List all FileStation user favorites (name, path, type, status, real path, modified).
#### `add_favorite`
Pin a path as a FileStation favorite with a display label.
#### `delete_favorite`
Remove a favorite by its path.
---
### System
#### `background_tasks`
Paginated list of active/recent FileStation background tasks
(copy, move, delete, extract, compress) with type, status, path, and file progress.
> Only the `list` method is available on DSM 7.x — tasks cannot be cancelled via this API.
#### `list_snapshots`
List Btrfs snapshots for a shared folder (snapshot ID, creation time, description, locked).
> Requires a Btrfs-formatted volume. Returns a clear error on ext4/non-Btrfs shares
> ("requires Btrfs-formatted volume").
---
## Development
```bash
# Install with dev dependencies
uv sync --dev
# Format
uv run ruff format src/ tests/
# Lint
uv run ruff check src/ tests/
# Tests (113 tests)
uv run pytest
uv run ruff check src/
uv run ruff format src/
# Run server locally (stdio)
uv run mcp-synology-filestation serve
```
See [CLAUDE.md](CLAUDE.md) for the full development context and
[SPEC.md](SPEC.md) for DSM API call details and quirks.
---
## Security
- Passwords are stored in the **OS keyring only** — never in the config file or logs.
- Session IDs and credentials are masked in all debug output.
- The `delete` tool requires explicit `confirmed=true` to prevent accidents.
---
## License
MIT
+123 -3
View File
@@ -48,7 +48,7 @@ CLI (click)
## DSM API Endpoints
All requests use `GET /webapi/entry.cgi` with query parameters unless noted.
All requests use `POST /webapi/entry.cgi` with `application/x-www-form-urlencoded` body unless noted.
| API | Version | Methods used |
|-----|---------|--------------|
@@ -97,7 +97,7 @@ regardless of service state.
---
## Tools (v0.2.10 — 20 tools)
## Tools (v0.3.4 — 26 tools)
### Read-only
@@ -134,6 +134,22 @@ regardless of service state.
| `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
@@ -439,6 +455,110 @@ pagination hint when more results are available.
---
#### `get_thumbnail`
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `path` | str | yes | — | Share-relative path to the image or video file |
| `size` | str | no | `small` | `small`, `medium`, `large`, or `original` |
**Returns:** JSON `{filename, size_bytes, content_base64}` (JPEG bytes encoded as base64).
If the thumbnail exceeds the soft limit (≈500 KB base64 / 375 KB raw), the JSON includes
an additional `"warning"` field advising to use `size='small'`.
If the thumbnail exceeds the hard limit (≈2 MB base64 / 1.5 MB raw), the tool returns
`"Error: Thumbnail too large (… KB base64) — use size='small' to get a smaller version."`
instead of the JSON payload.
**DSM call:** `SYNO.FileStation.Thumb::get` (POST, v2)
> **`quality` parameter is ignored:** DSM accepts `quality` in the request but always
> returns the same JPEG regardless of the value. No server-side quality control is available.
>
> **Size limits confirmed against this NAS:**
> - `small` → 5 KB548 KB raw depending on source image resolution.
> - `medium` / `large` → can exceed 380 KB raw even for modest photos.
> - `original` → reflects the actual stored image; may be several MB.
> Default was changed from `large` to `small` to avoid hitting MCP buffer limits.
>
> DSM returns image bytes directly when the file has a thumbnail. Non-image content-type
> indicates a DSM error envelope — the tool parses and surfaces the error code.
---
#### `list_favorites`
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `offset` | int | no | 0 | Pagination offset |
| `limit` | int | no | 200 | Max items to return |
**Returns:** Table with name, path, type, status, real path, modified time.
**DSM call:** `SYNO.FileStation.Favorite::list` (v2, `additional=["real_path","size","time"]`)
---
#### `add_favorite`
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `path` | str | yes | Share-relative path to pin |
| `name` | str | yes | Display label for the favorite |
**Returns:** `"Added favorite '{name}' → {path}"` or error.
**DSM call:** `SYNO.FileStation.Favorite::add` (v2, `index=-1`)
---
#### `delete_favorite`
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `path` | str | yes | Share-relative path of the favorite to remove |
**Returns:** `"Deleted favorite for {path}"` or error.
**DSM call:** `SYNO.FileStation.Favorite::delete` (v2)
---
#### `background_tasks`
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `offset` | int | no | 0 | Pagination offset |
| `limit` | int | no | 100 | Max tasks to return (capped at 200) |
**Returns:** Table with task ID, type, status, path, and file progress. Includes total count
and a pagination hint when more results are available.
**DSM call:** `SYNO.FileStation.BackgroundTask::list` (v3)
> Only the `list` method is available on this NAS — tasks cannot be stopped or cleared
> via this API.
---
#### `list_snapshots`
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `share_path` | str | yes | — | Share-relative root path (e.g. `/docker`) |
| `offset` | int | no | 0 | Pagination offset |
| `limit` | int | no | 100 | Max snapshots to return (capped at 500) |
**Returns:** Table with snapshot ID, creation time, description, and locked flag. Includes
total count and a pagination hint when more results are available.
**DSM call:** `SYNO.FileStation.Snapshot::list` (v2, `folder_path={share_path}`)
> **Btrfs required:** DSM returns error 400 when the share is not on a Btrfs volume.
> The tool maps this to a clear message: *"Snapshots not available — requires Btrfs-formatted
> volume."*
---
## Error Handling Strategy
### Principles
@@ -466,7 +586,7 @@ pagination hint when more results are available.
| 401 | Guest or disabled account | "DSM account is disabled." |
| 403 | 2FA required | "Two-factor authentication required — run setup." |
| 404 | 2FA failed | "OTP code incorrect." |
| 408 | Device token required | "Device token required — run setup again." |
| 408 | Non-supported additional field | "DSM rejected additional fields — check parameter format." |
| 599 | Background service not ready | (handled by `_start_and_poll_oneshot` — restart task) |
| 1800 | File not found | "File or folder not found: {path}" |
| 1801 | No write permission | "No write permission for: {path}" |
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mcp-synology-filestation"
version = "0.2.10"
version = "0.4.0"
description = "MCP server for Synology FileStation"
requires-python = ">=3.12"
dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
"keyring>=25.0.0",
"click>=8.1.0",
"rich>=13.0.0",
"pypdf>=4.0.0",
]
[project.optional-dependencies]
+1 -1
View File
@@ -1,3 +1,3 @@
"""MCP server for Synology FileStation."""
__version__ = "0.2.10"
__version__ = "0.4.0"
+58
View File
@@ -427,3 +427,61 @@ class FileStationClient:
code = body.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code)
async def get_thumbnail_bytes(self, path: str, size: str = "large") -> bytes:
"""Fetch a thumbnail for a file via SYNO.FileStation.Thumb.
Args:
path: Share-relative path to the image file.
size: Thumbnail size — "small", "medium", "large", or "original".
Returns:
Raw JPEG bytes.
Raises:
SynologyError: On HTTP 404 (file not found), non-200 status,
or DSM error in the response body.
"""
api = "SYNO.FileStation.Thumb"
await self._ensure_initialized()
http = self._get_http()
if api not in self._api_cache:
raise SynologyError(f"API '{api}' not found.", code=102)
info = self._api_cache[api]
url = f"{self._base_url}/webapi/{info['path']}"
req_params: dict[str, str] = {
"api": api,
"version": "2",
"method": "get",
"path": path,
"size": size,
"rotate": "0",
}
if self._sid:
req_params["_sid"] = self._sid
logger.debug("DSM POST: %s/get v2 — path=%s size=%s", api, path, size)
resp = await http.post(url, data=req_params)
if resp.status_code == 404:
raise SynologyError(f"File not found: {path}", code=404)
if resp.status_code != 200:
raise SynologyError(
f"Thumbnail request failed (HTTP {resp.status_code})", code=resp.status_code
)
content_type = resp.headers.get("content-type", "")
if not content_type.startswith("image/"):
# DSM returned an error envelope instead of image data
try:
body = resp.json()
code = body.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code)
except (ValueError, KeyError):
raise SynologyError(f"Unexpected response content-type: {content_type}") from None
return resp.content
+374 -33
View File
@@ -25,6 +25,11 @@ _VALID_SORT_DIR = frozenset({"asc", "desc"})
# Cap on items returned by list_dir — DSM hard limit is 10000, we enforce lower.
_MAX_LIMIT = 500
# get_thumbnail: abort if raw bytes exceed this (≈2 MB base64 after encoding).
_THUMB_ABORT_BYTES = 1_500_000
# get_thumbnail: include a warning field if raw bytes exceed this (≈500 KB base64).
_THUMB_WARN_BYTES = 375_000
def _fmt_size(size: int | None) -> str:
"""Format a byte count as a human-readable string."""
@@ -261,8 +266,7 @@ def register_filestation(
sort_by: str = "name",
sort_direction: str = "asc",
):
"""List directory contents. path: share-relative (e.g. /docker).
offset/limit for pagination, sort_by/sort_direction for ordering."""
"""List directory contents. path: share-relative; offset/limit/sort_by/sort_dir optional."""
from mcp_synology_filestation.client import SynologyError
# Validate inputs
@@ -348,8 +352,7 @@ def register_filestation(
recursive: bool = True,
max_results: int = 200,
):
"""Search files by glob pattern under path. pattern: e.g. "*.yaml".
recursive/max_results optional."""
"""Search files by glob pattern. pattern: e.g. *.yaml; recursive/max_results optional."""
from mcp_synology_filestation.client import SynologyError
limit = max(1, min(max_results, 1000))
@@ -360,7 +363,7 @@ def register_filestation(
"SYNO.FileStation.Search",
"start",
params={
"folder_path": path,
"folder_path": json.dumps([path]),
"recursive": "true" if recursive else "false",
"pattern": pattern,
},
@@ -469,8 +472,7 @@ def register_filestation(
@mcp.tool()
async def download(path: str):
"""Download a file as base64 (max 10 MB). path: share-relative.
Returns JSON {filename, size, content_base64}."""
"""Download a file as base64 (max 10 MB). Returns JSON {filename, size, content_base64}."""
import base64
from mcp_synology_filestation.client import SynologyError
@@ -499,8 +501,7 @@ def register_filestation(
@mcp.tool()
async def get_info(path: str):
"""Get metadata (type/size/owner/permissions/timestamps) for one or more paths.
path: comma-separated share-relative paths."""
"""Get file/folder metadata. path: one or more comma-separated share-relative paths."""
from mcp_synology_filestation.client import SynologyError
paths = [p.strip() for p in path.split(",") if p.strip()]
@@ -589,8 +590,7 @@ def register_filestation(
@mcp.tool()
async def check_exist(path: str):
"""Check if one or more paths exist. path: comma-separated share-relative paths.
Returns Yes/No table."""
"""Check if paths exist (comma-separated share-relative paths). Returns Yes/No table."""
from mcp_synology_filestation.client import SynologyError
paths = [p.strip() for p in path.split(",") if p.strip()]
@@ -638,8 +638,7 @@ def register_filestation(
name: str,
create_parents: bool = False,
):
"""Create a folder. path: parent dir, name: folder name (not full path).
create_parents: make missing parents."""
"""Create a folder. path: parent directory, name: folder name (not full path)."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -661,8 +660,7 @@ def register_filestation(
@mcp.tool()
async def rename(path: str, new_name: str):
"""Rename a file or folder. path: current share-relative path,
new_name: bare name only (not full path)."""
"""Rename a file or folder. new_name: bare name only (not a full path)."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -684,8 +682,7 @@ def register_filestation(
@mcp.tool()
async def copy(src: str, dst: str, overwrite: bool = False):
"""Copy src to dst directory.
WARNING: overwrite=True replaces existing items (default False)."""
"""Copy src to dst directory. WARNING: overwrite=True replaces existing items."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -718,8 +715,7 @@ def register_filestation(
@mcp.tool()
async def move(src: str, dst: str, overwrite: bool = False):
"""Move src to dst directory.
WARNING: overwrite=True replaces existing items (default False)."""
"""Move src to dst directory. WARNING: overwrite=True replaces existing items."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -752,8 +748,7 @@ def register_filestation(
@mcp.tool()
async def delete(path: str, confirmed: bool = False):
"""Delete a file or folder. IRREVERSIBLE.
confirmed=False (default) shows preview only; pass confirmed=True to actually delete."""
"""Delete a file or folder (IRREVERSIBLE). confirmed=False shows preview; True deletes."""
from mcp_synology_filestation.client import SynologyError
if not confirmed:
@@ -794,8 +789,7 @@ def register_filestation(
format: str = "zip",
password: str = "",
):
"""Compress paths into an archive. dest_file_path: full path incl. filename.
level: store/fastest/fast/normal/moderate/maximum. format: zip/7z."""
"""Compress paths into a ZIP or 7z archive. dest_file_path: full path incl. filename."""
from mcp_synology_filestation.client import SynologyError
_valid_levels = {"store", "fastest", "fast", "normal", "moderate", "maximum"}
@@ -847,8 +841,7 @@ def register_filestation(
create_subfolder: bool = False,
password: str = "",
):
"""Extract a ZIP or 7z archive to dest_folder_path.
overwrite/keep_dir/create_subfolder/password optional."""
"""Extract a ZIP or 7z archive to dest_folder_path."""
from mcp_synology_filestation.client import SynologyError
try:
@@ -883,8 +876,7 @@ def register_filestation(
@mcp.tool()
async def dir_size(path: str):
"""Get total size, file count and folder count for one or more directories.
path: comma-separated share-relative paths."""
"""Get total size, file count, folder count. path: comma-separated share-relative paths."""
paths = [p.strip() for p in path.split(",") if p.strip()]
if not paths:
return "Error: no path provided."
@@ -962,8 +954,7 @@ def register_filestation(
overwrite: bool = False,
create_parents: bool = True,
):
"""Upload base64-encoded content as filename into path (max 50 MB).
WARNING: overwrite=True replaces existing file (default False)."""
"""Upload base64 content to path/filename (max 50 MB). WARNING: overwrite=True replaces."""
import base64
from mcp_synology_filestation.client import SynologyError
@@ -1003,8 +994,7 @@ def register_filestation(
overwrite: bool = False,
create_only: bool = False,
):
"""Check write permission for filename in path.
Returns 'Permission granted' or an error message."""
"""Check write permission for filename in path."""
from mcp_synology_filestation.client import SynologyError
req_params: dict[str, Any] = {"path": path, "filename": filename}
@@ -1032,8 +1022,7 @@ def register_filestation(
date_expired: str = "",
date_available: str = "",
):
"""Create a public sharing link for a file or folder.
date_expired/date_available: YYYY-MM-DD, optional."""
"""Create a public sharing link. date_expired/date_available: YYYY-MM-DD (optional)."""
from mcp_synology_filestation.client import SynologyError
req_params: dict[str, Any] = {"path": json.dumps(path)}
@@ -1138,3 +1127,355 @@ def register_filestation(
return f"Error: {e}"
return f"Deleted sharing link: {link_id}"
# ── thumbnail + favorites tools ───────────────────────────────────────
@mcp.tool()
async def get_thumbnail(path: str, size: str = "small"):
"""Fetch a thumbnail for an image/video. Returns JSON with filename and base64 content."""
import base64
import json as _json
from mcp_synology_filestation.client import SynologyError
valid_sizes = {"small", "medium", "large", "original"}
if size not in valid_sizes:
return f"Error: size must be one of {sorted(valid_sizes)}"
try:
img_bytes = await client.get_thumbnail_bytes(path, size)
except SynologyError as e:
return f"Error: {e}"
raw_len = len(img_bytes)
# Hard limit: refuse to return a payload that would exceed ~2 MB base64.
if raw_len > _THUMB_ABORT_BYTES:
b64_kb = raw_len * 4 // 3 // 1024
return (
f"Error: Thumbnail too large ({b64_kb} KB base64) — "
f"use size='small' to get a smaller version."
)
filename = path.rsplit("/", 1)[-1]
payload: dict = {
"filename": filename,
"size_bytes": raw_len,
"content_base64": base64.b64encode(img_bytes).decode(),
}
# Soft warning: note large payload without refusing it.
if raw_len > _THUMB_WARN_BYTES:
b64_kb = raw_len * 4 // 3 // 1024
payload["warning"] = (
f"Thumbnail is large ({b64_kb} KB base64). "
"Consider using size='small' for faster responses."
)
return _json.dumps(payload)
@mcp.tool()
async def list_favorites():
"""List all FileStation favorites. Table: name, path, type, status, real path, modified."""
from mcp_synology_filestation.client import SynologyError
try:
data = await client.request(
"SYNO.FileStation.Favorite",
"list",
version=2,
params={
"offset": 0,
"limit": 200,
"additional": json.dumps(["real_path", "size", "time"]),
},
)
except SynologyError as e:
return f"Error: {e}"
favorites: list[dict] = data.get("favorites", [])
total: int = data.get("total", len(favorites))
if not favorites:
return "No favorites found."
rows: list[tuple[str, ...]] = []
for fav in favorites:
name = fav.get("name", "")
fpath = fav.get("path", "")
ftype = "folder" if fav.get("isdir", False) else "file"
status = fav.get("status", "")
add = fav.get("additional", {})
real_path = add.get("real_path", "")
mtime = add.get("time", {}).get("mtime")
rows.append((name, fpath, ftype, status, real_path, _fmt_time(mtime)))
headers = ("Name", "Path", "Type", "Status", "Real Path", "Modified")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{total} favorite(s)")
return "\n".join(lines)
@mcp.tool()
async def add_favorite(path: str, name: str):
"""Add a path to FileStation favorites. name: display label for the favorite."""
from mcp_synology_filestation.client import SynologyError
try:
await client.request(
"SYNO.FileStation.Favorite",
"add",
version=2,
params={"path": path, "name": name, "index": -1},
)
except SynologyError as e:
return f"Error: {e}"
return f"Added favorite '{name}'{path}"
@mcp.tool()
async def delete_favorite(path: str):
"""Delete a FileStation favorite by path."""
from mcp_synology_filestation.client import SynologyError
try:
await client.request(
"SYNO.FileStation.Favorite",
"delete",
version=2,
params={"path": path},
)
except SynologyError as e:
return f"Error: {e}"
return f"Deleted favorite for {path}"
# ── background task + snapshot tools ──────────────────────────────────
@mcp.tool()
async def background_tasks(offset: int = 0, limit: int = 100):
"""List FileStation background tasks (copy, move, delete, extract, compress, etc.)."""
from mcp_synology_filestation.client import SynologyError
limit = max(1, min(limit, 200))
offset = max(0, offset)
try:
data = await client.request(
"SYNO.FileStation.BackgroundTask",
"list",
version=3,
params={"offset": offset, "limit": limit},
)
except SynologyError as e:
return f"Error: {e}"
tasks: list[dict] = data.get("tasks", [])
total: int = data.get("total", len(tasks))
if not tasks:
return "No background tasks found."
rows: list[tuple[str, ...]] = []
for task in tasks:
taskid = str(task.get("taskid", ""))
ttype = str(task.get("type", ""))
status = str(task.get("status", ""))
path = str(task.get("path", task.get("dest_folder_path", "")))
processed = task.get("processed_num_file", 0)
total_files = task.get("total_num_file", 0)
progress = f"{processed}/{total_files}" if total_files else "-"
rows.append((taskid, ttype, status, path, progress))
headers = ("Task ID", "Type", "Status", "Path", "Progress")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{total} task(s) total.")
if total > offset + limit:
lines.append(
f"Showing {offset + 1}{offset + len(tasks)} of {total}. Use offset to page."
)
return "\n".join(lines)
@mcp.tool()
async def read_text(path: str, max_chars: int = 50_000, page: int = 0):
"""Extract readable text from a PDF, TXT, or MD file (max 10 MB)."""
import io
from mcp_synology_filestation.client import SynologyError
max_read_bytes = 10 * 1024 * 1024
plain_text_exts = {
".txt",
".md",
".markdown",
".csv",
".log",
".yaml",
".yml",
".json",
".xml",
".html",
".htm",
}
path_lower = path.lower()
if path_lower.endswith(".pdf"):
file_type = "pdf"
elif any(path_lower.endswith(ext) for ext in plain_text_exts):
file_type = "text"
else:
return (
"Error: Unsupported file type. Supported: PDF, TXT, MD, CSV, JSON, YAML, XML, HTML"
" and other plain text formats."
)
try:
filename, content = await client.download_bytes(path)
except SynologyError as e:
return f"Error: {e}"
size = len(content)
if size > max_read_bytes:
return f"Error: File too large ({size / 1024 / 1024:.1f} MB). Maximum is 10 MB."
total_pages = 0
if file_type == "pdf":
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
total_pages = len(reader.pages)
if page == 0:
page_texts = [pg.extract_text() or "" for pg in reader.pages]
if not any(t.strip() for t in page_texts):
return (
"Error: No extractable text found. The PDF may be image-only"
" (scanned without OCR layer)."
)
parts: list[str] = []
for i, pg_text in enumerate(page_texts):
if i == 0:
parts.append(pg_text)
else:
parts.append(f"\n\n--- Page {i + 1} ---\n\n{pg_text}")
text = "".join(parts)
else:
if page > total_pages:
return (
f"Error: Page {page} does not exist — "
f"this PDF has {total_pages} page(s)."
)
text = reader.pages[page - 1].extract_text() or ""
if not text.strip():
return (
"Error: No extractable text found. The PDF may be image-only"
" (scanned without OCR layer)."
)
except SynologyError:
raise
except Exception as exc:
return f"Error: Failed to parse PDF: {exc}"
else:
text = content.decode("utf-8", errors="replace")
full_len = len(text)
if max_chars > 0 and full_len > max_chars:
text = (
text[:max_chars]
+ f"\n\n[Truncated: {full_len} total chars, showing first {max_chars}."
+ " Use max_chars parameter to adjust.]"
)
display_name = path.rsplit("/", 1)[-1]
if file_type == "pdf" and page != 0:
header = f"[{display_name} — Page {page}/{total_pages}{full_len} chars]"
else:
header = f"[{display_name}{full_len} chars]"
return f"{header}\n{text}"
@mcp.tool()
async def list_snapshots(share_path: str, offset: int = 0, limit: int = 100):
"""List Btrfs snapshots for a shared folder (requires Btrfs volume)."""
from mcp_synology_filestation.client import SynologyError
limit = max(1, min(limit, 500))
offset = max(0, offset)
try:
data = await client.request(
"SYNO.FileStation.Snapshot",
"list",
version=2,
params={"folder_path": share_path, "offset": offset, "limit": limit},
)
except SynologyError as e:
if e.code == 400:
return (
f"Error: Snapshots not available for '{share_path}'"
"this feature requires a Btrfs-formatted volume."
)
return f"Error: {e}"
snapshots: list[dict] = data.get("snapshots", [])
total: int = data.get("total", len(snapshots))
if not snapshots:
return f"No snapshots found for '{share_path}'."
rows: list[tuple[str, ...]] = []
for snap in snapshots:
snap_id = str(snap.get("id", snap.get("snapshot_id", "")))
snap_time = _fmt_time(snap.get("time", snap.get("create_time")))
snap_desc = str(snap.get("description", snap.get("desc", "")))
snap_lock = "Yes" if snap.get("lock", False) else "No"
rows.append((snap_id, snap_time, snap_desc, snap_lock))
headers = ("Snapshot ID", "Created", "Description", "Locked")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{total} snapshot(s) for '{share_path}'.")
if total > offset + limit:
lines.append(
f"Showing {offset + 1}{offset + len(snapshots)} of {total}. Use offset to page."
)
return "\n".join(lines)
+227
View File
@@ -0,0 +1,227 @@
"""Tests for read_text tool."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mcp_synology_filestation.client import SynologyError
from mcp_synology_filestation.config import AppConfig, ConnectionConfig
@pytest.fixture()
def config() -> AppConfig:
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.example.com"),
)
def _make_tools(config: AppConfig, client: MagicMock) -> dict:
from mcp_synology_filestation.tools.filestation import register_filestation
registered: dict[str, object] = {}
mcp = MagicMock()
def tool_decorator():
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
mcp.tool = tool_decorator
register_filestation(mcp, config, client)
return registered
def _mock_pdf_reader(pages_text: list[str]) -> MagicMock:
"""Build a mock PdfReader whose .pages list returns the given text per page."""
mock_pages = []
for text in pages_text:
page = MagicMock()
page.extract_text.return_value = text
mock_pages.append(page)
reader = MagicMock()
reader.pages = mock_pages
return reader
# ── TXT / plain text ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_txt_success(config: AppConfig) -> None:
"""TXT file content is decoded and returned with header."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("readme.txt", b"Hello, world!"))
tools = _make_tools(config, client)
result = await tools["read_text"]("/docs/readme.txt")
assert "readme.txt" in result
assert "Hello, world!" in result
assert "13 chars" in result # len("Hello, world!") == 13
@pytest.mark.asyncio
async def test_read_text_md_success(config: AppConfig) -> None:
"""Markdown file is treated as plain text."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("notes.md", b"# Title\n\nBody text."))
tools = _make_tools(config, client)
result = await tools["read_text"]("/docs/notes.md")
assert "notes.md" in result
assert "# Title" in result
assert "Body text." in result
# ── PDF extraction ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_pdf_all_pages(config: AppConfig) -> None:
"""PDF all-pages mode joins pages with separator."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("report.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["First page text.", "Second page text."])
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/report.pdf")
assert "report.pdf" in result
assert "First page text." in result
assert "Second page text." in result
assert "--- Page 2 ---" in result
@pytest.mark.asyncio
async def test_read_text_pdf_single_page(config: AppConfig) -> None:
"""PDF single-page mode returns only the requested page."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("doc.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["Page one.", "Page two.", "Page three."])
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/doc.pdf", page=2)
assert "Page 2/3" in result
assert "Page two." in result
assert "Page one." not in result
assert "Page three." not in result
@pytest.mark.asyncio
async def test_read_text_pdf_page_out_of_range(config: AppConfig) -> None:
"""Requesting a page beyond the PDF page count returns an error."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("doc.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["Only page."])
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/doc.pdf", page=5)
assert result.startswith("Error:")
assert "5" in result
assert "1" in result # total pages
@pytest.mark.asyncio
async def test_read_text_pdf_image_only(config: AppConfig) -> None:
"""PDF with no extractable text returns image-only error."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("scan.pdf", b"%PDF-1.4 stub"))
mock_reader = _mock_pdf_reader(["", ""]) # no text on any page
tools = _make_tools(config, client)
with patch("pypdf.PdfReader", return_value=mock_reader):
result = await tools["read_text"]("/docs/scan.pdf")
assert result.startswith("Error:")
assert "image-only" in result.lower() or "No extractable text" in result
# ── max_chars truncation ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_max_chars_truncation(config: AppConfig) -> None:
"""Text exceeding max_chars is truncated with a hint."""
long_text = "A" * 200
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("big.txt", long_text.encode()))
tools = _make_tools(config, client)
result = await tools["read_text"]("/data/big.txt", max_chars=50)
assert "Truncated" in result
assert "200 total chars" in result
assert "showing first 50" in result
# The returned content before the truncation note must be exactly 50 'A's
assert "A" * 50 in result
@pytest.mark.asyncio
async def test_read_text_max_chars_zero_no_limit(config: AppConfig) -> None:
"""max_chars=0 disables truncation."""
long_text = "B" * 100_000
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("huge.txt", long_text.encode()))
tools = _make_tools(config, client)
result = await tools["read_text"]("/data/huge.txt", max_chars=0)
assert "Truncated" not in result
assert "B" * 100 in result # spot-check some content
# ── error cases ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_read_text_unsupported_type(config: AppConfig) -> None:
"""Unknown file extension returns an unsupported-type error."""
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("binary.exe", b"\x00\x01\x02"))
tools = _make_tools(config, client)
result = await tools["read_text"]("/bin/binary.exe")
assert result.startswith("Error:")
assert "Unsupported file type" in result
@pytest.mark.asyncio
async def test_read_text_file_too_large(config: AppConfig) -> None:
"""Files exceeding 10 MB return a size error without downloading the full content."""
oversized = b"x" * (10 * 1024 * 1024 + 1)
client = MagicMock()
client.download_bytes = AsyncMock(return_value=("big.txt", oversized))
tools = _make_tools(config, client)
result = await tools["read_text"]("/data/big.txt")
assert result.startswith("Error:")
assert "10 MB" in result
@pytest.mark.asyncio
async def test_read_text_dsm_error(config: AppConfig) -> None:
"""DSM errors from download are surfaced as Error: messages."""
client = MagicMock()
client.download_bytes = AsyncMock(side_effect=SynologyError(1800, "File not found"))
tools = _make_tools(config, client)
result = await tools["read_text"]("/missing/file.txt")
assert result.startswith("Error:")
+314 -1
View File
@@ -463,7 +463,7 @@ async def test_search_success(config: AppConfig) -> None:
start_call = client.request.call_args_list[0]
assert start_call[0][0] == "SYNO.FileStation.Search"
assert start_call[0][1] == "start"
assert start_call[1]["params"]["folder_path"] == "/docker"
assert json.loads(start_call[1]["params"]["folder_path"]) == ["/docker"]
assert start_call[1]["params"]["pattern"] == "*.yaml"
assert start_call[1]["params"]["recursive"] == "true"
# Verify clean was called last
@@ -1766,3 +1766,316 @@ async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None:
assert result.startswith("Error:")
assert "md5" in result.lower() or "hash" in result.lower()
# ──────────────────────────────────────────────────────────────────────────
# get_thumbnail
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_thumbnail_success(config: AppConfig) -> None:
"""get_thumbnail returns JSON with filename, size_bytes, content_base64 for small images."""
import base64
client = MagicMock()
img_bytes = b"\xff\xd8\xff" + b"\x00" * 1000 # fake JPEG, well under limits
client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/test.jpg", size="small")
payload = json.loads(result)
assert payload["filename"] == "test.jpg"
assert payload["size_bytes"] == len(img_bytes)
assert base64.b64decode(payload["content_base64"]) == img_bytes
assert "warning" not in payload
@pytest.mark.asyncio
async def test_get_thumbnail_invalid_size(config: AppConfig) -> None:
"""get_thumbnail returns Error: for an unrecognised size value."""
client = MagicMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/test.jpg", size="tiny")
assert result.startswith("Error:")
assert "size" in result.lower()
@pytest.mark.asyncio
async def test_get_thumbnail_dsm_error(config: AppConfig) -> None:
"""get_thumbnail returns Error: when the client raises SynologyError."""
from mcp_synology_filestation.client import SynologyError
client = MagicMock()
client.get_thumbnail_bytes = AsyncMock(
side_effect=SynologyError("File not found: /photo/missing.jpg", code=404)
)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/missing.jpg")
assert result.startswith("Error:")
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_get_thumbnail_large_warning(config: AppConfig) -> None:
"""get_thumbnail includes a warning field when image exceeds the soft limit (~375 KB raw)."""
client = MagicMock()
# 400 000 bytes raw > 375 000 threshold but < 1 500 000 hard limit
img_bytes = b"\x00" * 400_000
client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/big.jpg", size="medium")
payload = json.loads(result)
assert "content_base64" in payload
assert "warning" in payload
assert "small" in payload["warning"].lower()
@pytest.mark.asyncio
async def test_get_thumbnail_too_large_aborts(config: AppConfig) -> None:
"""get_thumbnail returns Error: (no base64) when image exceeds the hard limit (~1.5 MB raw)."""
client = MagicMock()
# 2 000 000 bytes raw > 1 500 000 hard limit
img_bytes = b"\x00" * 2_000_000
client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_thumbnail"](path="/photo/huge.jpg", size="original")
assert result.startswith("Error:")
assert "large" in result.lower() or "too large" in result.lower()
# Must NOT contain base64 payload
assert "content_base64" not in result
@pytest.mark.asyncio
async def test_get_thumbnail_default_size_is_small(config: AppConfig) -> None:
"""get_thumbnail defaults to size='small'."""
client = MagicMock()
client.get_thumbnail_bytes = AsyncMock(return_value=b"\xff\xd8" + b"\x00" * 100)
tools = _make_mcp_and_tools(config, client)
await tools["get_thumbnail"](path="/photo/test.jpg")
client.get_thumbnail_bytes.assert_called_once_with("/photo/test.jpg", "small")
# ──────────────────────────────────────────────────────────────────────────
# background_tasks
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_background_tasks_empty(config: AppConfig) -> None:
"""background_tasks returns a 'no tasks' message when the list is empty."""
client = MagicMock()
client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"]()
assert "No background tasks" in result
@pytest.mark.asyncio
async def test_background_tasks_with_tasks(config: AppConfig) -> None:
"""background_tasks returns a formatted table when tasks are present."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"offset": 0,
"total": 1,
"tasks": [
{
"taskid": "FileStation_CopyMove_1",
"type": "CopyMove",
"status": "running",
"path": "/docker/dest",
"processed_num_file": 3,
"total_num_file": 10,
}
],
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"]()
assert "FileStation_CopyMove_1" in result
assert "CopyMove" in result
assert "running" in result
assert "/docker/dest" in result
assert "3/10" in result
assert "1 task(s)" in result
@pytest.mark.asyncio
async def test_background_tasks_pagination_hint(config: AppConfig) -> None:
"""background_tasks shows a pagination hint when there are more results."""
client = MagicMock()
tasks = [
{
"taskid": f"task_{i}",
"type": "Delete",
"status": "running",
"path": f"/share/item{i}",
"total_num_file": 0,
}
for i in range(5)
]
client.request = AsyncMock(return_value={"offset": 0, "total": 20, "tasks": tasks})
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"](offset=0, limit=5)
assert "20 task(s)" in result
assert "offset" in result.lower()
@pytest.mark.asyncio
async def test_background_tasks_dsm_error(config: AppConfig) -> None:
"""background_tasks returns Error: when DSM raises an exception."""
client = MagicMock()
client.request = AsyncMock(
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
)
tools = _make_mcp_and_tools(config, client)
result = await tools["background_tasks"]()
assert result.startswith("Error:")
@pytest.mark.asyncio
async def test_background_tasks_dsm_api_call(config: AppConfig) -> None:
"""background_tasks calls the correct DSM API with offset and limit."""
client = MagicMock()
client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
await tools["background_tasks"](offset=10, limit=50)
call = client.request.call_args
assert call[0][0] == "SYNO.FileStation.BackgroundTask"
assert call[0][1] == "list"
assert call[1]["version"] == 3
assert call[1]["params"]["offset"] == 10
assert call[1]["params"]["limit"] == 50
# ──────────────────────────────────────────────────────────────────────────
# list_snapshots
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_snapshots_btrfs_not_available(config: AppConfig) -> None:
"""list_snapshots returns a Btrfs-specific error message on DSM error 400."""
client = MagicMock()
client.request = AsyncMock(side_effect=SynologyError("Invalid parameter", code=400))
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert result.startswith("Error:")
assert "Btrfs" in result
@pytest.mark.asyncio
async def test_list_snapshots_empty(config: AppConfig) -> None:
"""list_snapshots returns a 'no snapshots' message when the list is empty."""
client = MagicMock()
client.request = AsyncMock(return_value={"snapshots": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert "No snapshots" in result
assert "/docker" in result
@pytest.mark.asyncio
async def test_list_snapshots_with_data(config: AppConfig) -> None:
"""list_snapshots returns a formatted table when snapshots are present."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"total": 2,
"snapshots": [
{
"id": "snap_001",
"time": 1700000000,
"description": "Before upgrade",
"lock": True,
},
{"id": "snap_002", "time": 1700100000, "description": "", "lock": False},
],
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert "snap_001" in result
assert "snap_002" in result
assert "Before upgrade" in result
assert "Yes" in result # locked
assert "No" in result # not locked
assert "2 snapshot(s)" in result
@pytest.mark.asyncio
async def test_list_snapshots_dsm_error_other(config: AppConfig) -> None:
"""list_snapshots surfaces non-400 DSM errors as 'Error: …'."""
client = MagicMock()
client.request = AsyncMock(
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/docker")
assert result.startswith("Error:")
assert "Permission" in result
@pytest.mark.asyncio
async def test_list_snapshots_dsm_api_call(config: AppConfig) -> None:
"""list_snapshots calls SYNO.FileStation.Snapshot::list v2 with the correct params."""
client = MagicMock()
client.request = AsyncMock(return_value={"snapshots": [], "total": 0})
tools = _make_mcp_and_tools(config, client)
await tools["list_snapshots"](share_path="/data", offset=0, limit=50)
call = client.request.call_args
assert call[0][0] == "SYNO.FileStation.Snapshot"
assert call[0][1] == "list"
assert call[1]["version"] == 2
assert call[1]["params"]["folder_path"] == "/data"
assert call[1]["params"]["offset"] == 0
assert call[1]["params"]["limit"] == 50
@pytest.mark.asyncio
async def test_list_snapshots_pagination_hint(config: AppConfig) -> None:
"""list_snapshots shows a pagination hint when more results are available."""
client = MagicMock()
snaps = [
{"id": f"snap_{i}", "time": 1700000000 + i * 3600, "description": "", "lock": False}
for i in range(3)
]
client.request = AsyncMock(return_value={"snapshots": snaps, "total": 10})
tools = _make_mcp_and_tools(config, client)
result = await tools["list_snapshots"](share_path="/data", offset=0, limit=3)
assert "10 snapshot(s)" in result
assert "offset" in result.lower()
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]]
name = "mcp-synology-filestation"
version = "0.2.4"
version = "0.3.6"
source = { editable = "." }
dependencies = [
{ name = "click" },