4 Commits

Author SHA1 Message Date
marcus 83bccbcb53 chore: remove throwaway test scripts, update SPEC.md for v0.2.10
- Delete test_dirsize_md5.py, test_extract.py, test_sharing.py
- SPEC.md: add all 20 tools, DSM quirks (one-shot, cold-start, FastMCP
  outputSchema, DirSize status v1, Sharing.delete id encoding), APIs
  confirmed unavailable, mark v0.2 complete, list v0.3 candidates

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:49:32 +02:00
marcus 8b2f07d9c3 revert: restore _poll_task and dir_size/get_md5 to 0.2.2 state
All changes since 0.2.2 to _poll_task, dir_size, and get_md5 (window_timeout,
_poll_oneshot, start_and_poll_immediately) are reverted. The 0.2.2 behaviour
worked reliably for small directories and is the last known-good baseline.

The remaining known limitation (occasional 599 on large directories) is
documented in SPEC.md. Retry the operation as a workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:18:43 +02:00
7 changed files with 583 additions and 406 deletions
+303 -97
View File
@@ -62,21 +62,83 @@ 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 ~68 s to initialise. During this window every `status` poll returns error 599. The
correct recovery is to wait a moment, then restart the task. Retrying the same `taskid` does
not help — the service must be cold-started via a new `start` call.
**DirSize `status` version:** Must use `version=1`. Using `version=2` always returns 599
regardless of service state.
--- ---
## Tools ## Tools (v0.2.10 — 20 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 |
---
### 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 +154,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 +179,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 +194,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 +224,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 +273,169 @@ 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.
--- ---
@@ -317,8 +445,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 400403) trigger a clear message with a hint to run `setup`. 3. Auth errors (codes 400403) trigger a clear message with a hint to run `setup`.
4. Session expiry errors (106, 107, 119) are retried once transparently; if the retry also 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}".
@@ -340,6 +467,7 @@ file=<binary content decoded from content_base64>
| 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 | Device token required | "Device token required — run setup again." |
| 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 +479,59 @@ file=<binary content decoded from content_base64>
--- ---
## DSM API Quirks
### Parameter encoding
- **JSON array parameters** (`path`, `additional`, etc.) must be serialised with
`json.dumps()`. A plain Python list or comma-separated string is silently ignored
by DSM — the field will be absent from the response.
- **`path` for multi-item operations** (e.g. `List::getinfo`, `DirSize::start`):
always pass as `json.dumps(["/path1", "/path2"])`, even for a single path.
- **`path` for single-item operations** (e.g. `CopyMove::start`, `Rename::rename`,
`Sharing::create`): pass as `json.dumps("/path")` (a JSON-encoded string).
- **`Sharing::delete` `id` parameter:** must be `json.dumps(link_id)` — plain string
is not accepted.
- **`CheckPermission::write` `path` and `filename`:** pass as plain strings (no
`json.dumps`).
- **Share paths vs. volume paths:** always use share paths (`/docker`, `/homes/marcus`).
Volume paths (`/volume1/docker`) cause DSM error 408.
### APIs confirmed NOT available on this NAS
| API | Status | Workaround |
|-----|--------|------------|
| `SYNO.FileStation.Stat` | Not in API registry | Use `List::getinfo` |
| `SYNO.FileStation.CheckExist` | Returns error 400 | Use `List::getinfo` (entries with `name=null` don't exist) |
### FastMCP `outputSchema` / tools/list payload limit
Do **not** add `-> str` return type annotations to `@mcp.tool()` functions. FastMCP
infers `outputSchema` from the annotation, which doubles the tools/list payload size.
Claude Desktop truncates the tool list when the payload exceeds its limit, making some
tools invisible. Omitting the return annotation suppresses `outputSchema` generation.
### DirSize / MD5 one-shot behaviour
`DirSize` and `MD5` tasks are consumed on first read: `finished=true` is returned exactly
once, then the task is deleted and all subsequent `status` polls return error 599.
Implementation: `_start_and_poll_oneshot()` — never call `status` a second time after
receiving `finished=true`.
### DirSize cold-start
After a period of inactivity, DSM's DirSize background service needs ~68 s to initialise.
During cold start, `status` returns 599 on every poll. The fix is to wait briefly and
**restart** the task (new `start` call) — polling the original `taskid` again does not help.
`_start_and_poll_oneshot()` retries up to 6 task restarts before giving up.
### DirSize `status` API version
`SYNO.FileStation.DirSize::status` must be called with `version=1`. Using `version=2`
always returns error 599 regardless of whether the background service is running.
---
## Configuration Model ## Configuration Model
```yaml ```yaml
@@ -402,3 +583,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 |
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mcp-synology-filestation" name = "mcp-synology-filestation"
version = "0.2.7" version = "0.2.10"
description = "MCP server for Synology FileStation" description = "MCP server for Synology FileStation"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+1 -1
View File
@@ -1,3 +1,3 @@
"""MCP server for Synology FileStation.""" """MCP server for Synology FileStation."""
__version__ = "0.2.7" __version__ = "0.2.10"
-46
View File
@@ -307,52 +307,6 @@ class FileStationClient:
raise SynologyError(_error_message(code, api), code=code) raise SynologyError(_error_message(code, api), code=code)
async def start_and_poll_immediately(
self,
api: str,
start_params: dict[str, Any],
poll_version: int,
*,
start_version: int | None = None,
) -> tuple[str, dict[str, Any] | None]:
"""Start a DSM async task and immediately make the first status poll.
Designed for one-shot tasks (DirSize, MD5) where the result window
may close quickly. Both the ``start`` and the first ``status`` request
are issued inside this single method with no intermediate awaits other
than the HTTP calls themselves, minimising scheduler latency.
Args:
api: DSM API name (e.g. "SYNO.FileStation.DirSize").
start_params: Query parameters for the ``start`` call.
poll_version: API version to use for the ``status`` call.
start_version: API version for the ``start`` call (defaults to
``maxVersion`` from the API info cache).
Returns:
``(taskid, status_data)`` where ``status_data`` is ``None`` if
the first status poll returned 599 (task not yet visible).
Raises:
SynologyError: If the ``start`` call fails, the response contains
no task ID, or the ``status`` call fails with a non-599 error.
"""
start_data = await self.request(api, "start", version=start_version, params=start_params)
taskid: str = start_data.get("taskid", "")
if not taskid:
raise SynologyError("DSM did not return a task ID.", code=0)
try:
status_data = await self.request(
api, "status", version=poll_version, params={"taskid": taskid}
)
except SynologyError as e:
if e.code == 599:
return taskid, None
raise
return taskid, status_data
async def download_bytes(self, path: str) -> tuple[str, bytes]: async def download_bytes(self, path: str) -> tuple[str, bytes]:
"""Download a file from the NAS via SYNO.FileStation.Download. """Download a file from the NAS via SYNO.FileStation.Download.
+240 -107
View File
@@ -59,7 +59,88 @@ def register_filestation(
client: FileStationClient for DSM API calls. client: FileStationClient for DSM API calls.
""" """
# ── internal polling helper ────────────────────────────────────────── # ── internal polling helpers ──────────────────────────────────────────
async def _start_and_poll_oneshot(
api: str,
start_params: dict[str, Any],
start_version: int,
poll_version: int,
) -> tuple[bool, dict[str, Any] | str]:
"""Start a one-shot DSM task and poll until finished, restarting on cold-start 599s.
DirSize and MD5 are "one-shot" tasks: DSM delivers ``finished=True`` exactly
once, then discards the result. Additionally, the DSM background service for
these tasks occasionally needs a few seconds to initialise after a period of
inactivity ("cold start"). During cold start the service registers task IDs
but returns error 599 on every status poll. The correct recovery is to restart
the task once the service has had time to wake up.
Args:
api: DSM API name (e.g. "SYNO.FileStation.DirSize").
start_params: Parameters forwarded to the ``start`` method.
start_version: API version for the ``start`` call.
poll_version: API version for the ``status`` call.
Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
DSM error or timeout.
"""
from mcp_synology_filestation.client import SynologyError as _SynologyError
max_restarts = 6
timeout = 60.0
total_elapsed = 0.0
for _attempt in range(max_restarts):
try:
start_data = await client.request(
api, "start", version=start_version, params=start_params
)
except _SynologyError as e:
return False, f"Error: {e}"
taskid: str = start_data.get("taskid", "")
if not taskid:
return False, "Error: DSM did not return a task ID."
# Poll with exponential backoff; restart on 5 consecutive 599s
delay = 0.2
consecutive_599 = 0
while True:
try:
status_data = await client.request(
api, "status", version=poll_version, params={"taskid": taskid}
)
consecutive_599 = 0
if status_data.get("finished"):
return True, status_data
# Still running — keep polling
except _SynologyError as e:
if e.code != 599:
return False, f"Error: {e}"
consecutive_599 += 1
if consecutive_599 >= 5:
# 5× 599 in a row: either cold start or missed result window.
# Restart the task so DSM can re-queue it.
break
if total_elapsed >= timeout:
return (
False,
"Error: Operation timed out after 60 seconds — check NAS manually.",
)
await asyncio.sleep(delay)
total_elapsed += delay
delay = min(delay * 2, 2.0)
return (
False,
"Error: DSM did not return results after multiple retries"
" (service may be starting up — try again in a moment).",
)
async def _poll_task( async def _poll_task(
api: str, api: str,
@@ -69,15 +150,13 @@ def register_filestation(
) -> tuple[bool, dict[str, Any] | str]: ) -> tuple[bool, dict[str, Any] | str]:
"""Poll a DSM async task until finished or timeout. """Poll a DSM async task until finished or timeout.
For tasks that return intermediate ``finished=False`` status while
running (CopyMove, Delete, Compress, Extract, Search). Use
``_poll_oneshot`` for DirSize and MD5.
Args: Args:
api: DSM API name (e.g. "SYNO.FileStation.CopyMove"). api: DSM API name (e.g. "SYNO.FileStation.CopyMove").
version: API version to use for the status call. version: API version to use for the status call.
taskid: Task ID returned by the corresponding start method. taskid: Task ID returned by the corresponding start method.
initial_delay: Seconds to wait before the first status poll. initial_delay: Seconds to wait before the first status poll.
Set to 0.0 for tasks that may finish before the first poll
interval (e.g. DirSize on small directories, MD5 on small files).
Returns: Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on ``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
@@ -88,6 +167,7 @@ def register_filestation(
delay = 0.2 delay = 0.2
elapsed = initial_delay elapsed = initial_delay
timeout = 60.0 timeout = 60.0
consecutive_599 = 0
if initial_delay > 0: if initial_delay > 0:
await asyncio.sleep(initial_delay) await asyncio.sleep(initial_delay)
@@ -100,9 +180,14 @@ def register_filestation(
version=version, version=version,
params={"taskid": taskid}, params={"taskid": taskid},
) )
consecutive_599 = 0
except _SynologyError as e: except _SynologyError as e:
if e.code == 599: if e.code == 599:
pass # task not yet visible — keep polling # 599 can be transient (task just started, not yet available).
# Retry up to 5 times before giving up.
consecutive_599 += 1
if consecutive_599 >= 5:
return False, f"Error: {e}"
else: else:
return False, f"Error: {e}" return False, f"Error: {e}"
else: else:
@@ -119,89 +204,6 @@ def register_filestation(
elapsed += delay elapsed += delay
delay = min(delay * 2, 2.0) delay = min(delay * 2, 2.0)
async def _poll_oneshot(
api: str,
version: int,
taskid: str,
first_status: dict[str, Any] | None,
) -> tuple[bool, dict[str, Any] | str]:
"""Continue polling a one-shot DSM task after the first status poll.
Called after ``client.start_and_poll_immediately`` has already made
the first status request. Handles three outcomes for ``first_status``:
* ``finished=True`` — return immediately (task done on first poll).
* ``finished=False`` — task confirmed running; enter Phase 2
(exponential backoff until ``finished=True`` or 60 s timeout).
* ``None`` (first poll returned 599) — burst-retry 10× at 10 ms,
then enter Phase 2 regardless (large directories will eventually
return ``finished=False``; a 599 after the task was seen alive
means the window closed — fail fast with a retry message).
Returns:
``(True, status_dict)`` on success, or ``(False, "Error: …")``
on DSM error or timeout.
"""
from mcp_synology_filestation.client import SynologyError as _SynologyError
seen_alive = False
if first_status is not None:
if first_status.get("finished"):
return True, first_status
seen_alive = True # finished=False: task is running
else:
# 599 on the immediate poll: burst-retry (10×, 10 ms apart)
for _ in range(10):
await asyncio.sleep(0.01)
try:
s = await client.request(
api, "status", version=version, params={"taskid": taskid}
)
except _SynologyError as e:
if e.code == 599:
continue
return False, f"Error: {e}"
if s.get("finished"):
return True, s
seen_alive = True
break # finished=False: enter Phase 2
# ── Phase 2: exponential backoff until finished or 60 s timeout ──
delay = 0.2
elapsed = 0.0
timeout = 60.0
while True:
await asyncio.sleep(delay)
elapsed += delay
delay = min(delay * 2, 2.0)
try:
s = await client.request(api, "status", version=version, params={"taskid": taskid})
except _SynologyError as e:
if e.code == 599:
if seen_alive:
# Task was running but the one-shot window closed before we read it
return (
False,
"Error: Could not read task result — the operation finished"
" before the result was polled. Please retry.",
)
# Not yet seen alive: large dir still initialising, keep polling
else:
return False, f"Error: {e}"
else:
seen_alive = True
if s.get("finished"):
return True, s
if elapsed >= timeout:
return (
False,
"Error: Operation timed out after 60 seconds — check NAS manually.",
)
@mcp.tool() @mcp.tool()
async def list_shares(): async def list_shares():
"""List all shared folders. Returns name/path/volume-usage table.""" """List all shared folders. Returns name/path/volume-usage table."""
@@ -883,23 +885,16 @@ def register_filestation(
async def dir_size(path: str): async def dir_size(path: str):
"""Get total size, file count and folder count for one or more directories. """Get total size, file count and folder count for one or more directories.
path: comma-separated share-relative paths.""" path: comma-separated share-relative paths."""
from mcp_synology_filestation.client import SynologyError
paths = [p.strip() for p in path.split(",") if p.strip()] paths = [p.strip() for p in path.split(",") if p.strip()]
if not paths: if not paths:
return "Error: no path provided." return "Error: no path provided."
try: ok, result = await _start_and_poll_oneshot(
taskid, first_status = await client.start_and_poll_immediately(
"SYNO.FileStation.DirSize", "SYNO.FileStation.DirSize",
start_params={"path": json.dumps(paths)}, start_params={"path": json.dumps(paths)},
poll_version=1,
start_version=2, start_version=2,
poll_version=1,
) )
except SynologyError as e:
return f"Error: {e}"
ok, result = await _poll_oneshot("SYNO.FileStation.DirSize", 1, taskid, first_status)
if not ok: if not ok:
return result # type: ignore[return-value] return result # type: ignore[return-value]
@@ -944,19 +939,12 @@ def register_filestation(
@mcp.tool() @mcp.tool()
async def get_md5(path: str): async def get_md5(path: str):
"""Compute the MD5 checksum of a file on the NAS. path: share-relative file path.""" """Compute the MD5 checksum of a file on the NAS. path: share-relative file path."""
from mcp_synology_filestation.client import SynologyError ok, result = await _start_and_poll_oneshot(
try:
taskid, first_status = await client.start_and_poll_immediately(
"SYNO.FileStation.MD5", "SYNO.FileStation.MD5",
start_params={"file_path": json.dumps(path)}, start_params={"file_path": json.dumps(path)},
poll_version=1,
start_version=2, start_version=2,
poll_version=1,
) )
except SynologyError as e:
return f"Error: {e}"
ok, result = await _poll_oneshot("SYNO.FileStation.MD5", 1, taskid, first_status)
if not ok: if not ok:
return result # type: ignore[return-value] return result # type: ignore[return-value]
@@ -1005,3 +993,148 @@ def register_filestation(
return f"Error: {e}" return f"Error: {e}"
return f"Uploaded: {path}/{filename}" return f"Uploaded: {path}/{filename}"
# ── permission + sharing tools ────────────────────────────────────────
@mcp.tool()
async def check_permission(
path: str,
filename: str,
overwrite: bool = False,
create_only: bool = False,
):
"""Check write permission for filename in path.
Returns 'Permission granted' or an error message."""
from mcp_synology_filestation.client import SynologyError
req_params: dict[str, Any] = {"path": path, "filename": filename}
if overwrite:
req_params["overwrite"] = "true"
if create_only:
req_params["create_only"] = "true"
try:
await client.request(
"SYNO.FileStation.CheckPermission",
"write",
version=3,
params=req_params,
)
except SynologyError as e:
return f"Error: {e}"
return f"Permission granted: write {filename!r} into {path}"
@mcp.tool()
async def create_sharing_link(
path: str,
password: str = "",
date_expired: str = "",
date_available: str = "",
):
"""Create a public sharing link for a file or folder.
date_expired/date_available: YYYY-MM-DD, optional."""
from mcp_synology_filestation.client import SynologyError
req_params: dict[str, Any] = {"path": json.dumps(path)}
if password:
req_params["password"] = password
if date_expired:
req_params["date_expired"] = date_expired
if date_available:
req_params["date_available"] = date_available
try:
data = await client.request(
"SYNO.FileStation.Sharing",
"create",
version=3,
params=req_params,
)
except SynologyError as e:
return f"Error: {e}"
links = data.get("links", [])
if not links:
return "Error: DSM returned no sharing link."
link = links[0]
link_id = link.get("id", "?")
url = link.get("url", "?")
has_password = link.get("has_password", False)
lines = [f"Sharing link created: {url}", f"ID: {link_id}"]
if has_password:
lines.append("Password protected: yes")
return "\n".join(lines)
@mcp.tool()
async def list_sharing_links(offset: int = 0, limit: int = 100):
"""List sharing links (paginated). Table: ID, URL, path, owner, expiry, status."""
from mcp_synology_filestation.client import SynologyError
try:
data = await client.request(
"SYNO.FileStation.Sharing",
"list",
version=3,
params={"offset": str(offset), "limit": str(limit)},
)
except SynologyError as e:
return f"Error: {e}"
links = data.get("links", [])
total = data.get("total", 0)
if not links:
return f"No sharing links found. (total={total})"
rows = []
for lnk in links:
link_id = lnk.get("id", "?")
url = lnk.get("url", "?")
lpath = lnk.get("path", "?")
owner = lnk.get("link_owner", "?")
expiry = lnk.get("date_expired", "") or "never"
status = lnk.get("status", "?")
rows.append((link_id, url, lpath, owner, expiry, status))
headers = ("ID", "URL", "Path", "Owner", "Expires", "Status")
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
if offset + len(rows) < total:
lines.append(
f"\nShowing {offset + 1}{offset + len(rows)} of {total}. Pass offset to paginate."
)
else:
lines.append(f"\n{len(rows)} of {total} link(s).")
return "\n".join(lines)
@mcp.tool()
async def delete_sharing_link(link_id: str):
"""Delete a sharing link by its ID. IRREVERSIBLE."""
from mcp_synology_filestation.client import SynologyError
try:
await client.request(
"SYNO.FileStation.Sharing",
"delete",
version=3,
params={"id": json.dumps(link_id)},
)
except SynologyError as e:
return f"Error: {e}"
return f"Deleted sharing link: {link_id}"
-123
View File
@@ -1,123 +0,0 @@
"""Wegwerfskript: DirSize + MD5 direkt gegen die NAS testen.
Ausfuehren: uv run python test_dirsize_md5.py
"""
import asyncio
import json
import time
import httpx
from mcp_synology_filestation.auth import AuthManager
from mcp_synology_filestation.client import FileStationClient
from mcp_synology_filestation.config import load_config
DIRSIZE_PATHS = ["/test-mcp", "/docker"]
MD5_PATH = "/test-mcp/test.zip"
def pp(label: str, data: object, elapsed_ms: float | None = None) -> None:
print(f"\n{'='*60}")
suffix = f" [{elapsed_ms:.1f} ms]" if elapsed_ms is not None else ""
print(f" {label}{suffix}")
print("=" * 60)
print(json.dumps(data, indent=2, ensure_ascii=False))
async def raw(http: httpx.AsyncClient, url: str, sid: str, **params) -> dict:
r = await http.get(url, params={"_sid": sid, **params})
r.raise_for_status()
try:
return r.json()
except Exception:
return {"_raw": r.text[:300], "_http_status": r.status_code}
async def probe_dirsize_long(
http: httpx.AsyncClient, sid: str, api_url: str, path: str
) -> None:
"""Start DirSize and poll v1 every 200ms for up to 15s.
Goal: find out if 599 means 'task still running' (keep polling)
or 'task gone' (give up). If the task eventually returns data,
599 = 'not ready yet'. If it never returns data, 599 = 'task gone'.
"""
print(f"\n{'#'*60}")
print(f" DIRSIZE {path} — long poll (15s, every 200ms)")
print(f"{'#'*60}")
t0 = time.perf_counter()
start_body = await raw(
http, api_url, sid,
api="SYNO.FileStation.DirSize", version="2", method="start",
path=json.dumps([path]),
)
elapsed_start = (time.perf_counter() - t0) * 1000
pp(f"DirSize::start ({path})", start_body, elapsed_start)
taskid = (start_body.get("data") or {}).get("taskid")
if not taskid:
print("[!] No taskid.")
return
print(f"\n[*] Polling status v1 every 200ms for up to 15s (taskid={taskid[:12]}...)")
for attempt in range(75): # 75 * 200ms = 15s
if attempt > 0:
await asyncio.sleep(0.2)
t = time.perf_counter()
r = await raw(
http, api_url, sid,
api="SYNO.FileStation.DirSize", version="1", method="status",
taskid=taskid,
)
elapsed = (t - t0) * 1000
success = r.get("success")
data = (r.get("data") or {})
finished = data.get("finished")
error_code = (r.get("error") or {}).get("code")
if finished:
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: FERTIG! "
f"num_dir={data.get('num_dir')} "
f"num_file={data.get('num_file')} "
f"total_size={data.get('total_size')}")
pp(f"DirSize::status final ({path})", r, elapsed)
return
elif success and not finished:
# Still running — show current progress
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: running... "
f"num_dir={data.get('num_dir', '?')} "
f"num_file={data.get('num_file', '?')} "
f"total_size={data.get('total_size', '?')}")
else:
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: error code={error_code}")
# Continue polling — 599 might mean 'not ready yet'
print(f"\n[!] No result after 15s — task never returned data.")
async def main() -> None:
config = load_config()
auth = AuthManager(config)
async with FileStationClient(config.base_url, config.connection.verify_ssl) as client:
client.set_auth_manager(auth)
await client._ensure_initialized() # noqa: SLF001
sid = client.sid
base = config.base_url
info = client._api_cache.get("SYNO.FileStation.DirSize", {}) # noqa: SLF001
api_url = f"{base}/webapi/{info.get('path', 'entry.cgi')}"
print(f"[*] DirSize API: {api_url} v{info.get('minVersion')}-v{info.get('maxVersion')}")
async with httpx.AsyncClient(verify=config.connection.verify_ssl, timeout=30.0) as http:
for path in DIRSIZE_PATHS:
await probe_dirsize_long(http, sid, api_url, path)
await auth.logout(client)
print("\n[*] Logout OK.")
if __name__ == "__main__":
asyncio.run(main())
+30 -23
View File
@@ -22,24 +22,8 @@ def config() -> AppConfig:
def _make_mcp_and_tools(config: AppConfig, client: MagicMock) -> dict: def _make_mcp_and_tools(config: AppConfig, client: MagicMock) -> dict:
"""Register FileStation tools on a mock FastMCP and collect them by name.""" """Register FileStation tools on a mock FastMCP and collect them by name."""
from mcp_synology_filestation.client import FileStationClient
from mcp_synology_filestation.tools.filestation import register_filestation from mcp_synology_filestation.tools.filestation import register_filestation
# Bind the real start_and_poll_immediately so it delegates into the
# already-mocked client.request — no separate mock needed per test.
async def _start_and_poll_immediately(
api: str,
start_params: dict,
poll_version: int,
*,
start_version: int | None = None,
):
return await FileStationClient.start_and_poll_immediately(
client, api, start_params, poll_version, start_version=start_version
)
client.start_and_poll_immediately = _start_and_poll_immediately
registered: dict[str, object] = {} registered: dict[str, object] = {}
mcp = MagicMock() mcp = MagicMock()
@@ -1651,12 +1635,8 @@ async def test_dir_size_retries_on_transient_599(config: AppConfig) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None: async def test_dir_size_fails_after_5_consecutive_599(config: AppConfig) -> None:
"""dir_size times out after 60 s when DSM returns only 599s for every poll. """dir_size gives up and returns Error: after exhausting all restart attempts."""
The immediate poll + burst both return 599; Phase 2 keeps polling (large
directories eventually surface) until the 60 s timeout fires.
"""
client = MagicMock() client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs): async def _request(api, method, version=None, params=None, **kwargs):
@@ -1671,7 +1651,34 @@ async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None:
result = await tools["dir_size"](path="/dead") result = await tools["dir_size"](path="/dead")
assert result.startswith("Error:") assert result.startswith("Error:")
assert "timed out" in result.lower() or "60 seconds" in result
@pytest.mark.asyncio
async def test_dir_size_cold_start_restart(config: AppConfig) -> None:
"""dir_size restarts the task after 5 consecutive 599s and succeeds on second attempt."""
client = MagicMock()
start_count = {"n": 0}
status_count = {"n": 0}
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
start_count["n"] += 1
return {"taskid": f"task_{start_count['n']}"}
status_count["n"] += 1
# First 5 status calls → 599 (simulates cold start)
if status_count["n"] <= 5:
raise SynologyError("DSM error code 599", code=599)
# After restart: immediately done
return {"finished": True, "num_dir": 1, "num_file": 5, "total_size": 1024}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await tools["dir_size"](path="/coldstart")
assert "Total Size" in result
assert start_count["n"] == 2 # task was restarted once after cold-start 599s
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────