Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83bccbcb53 | |||
| ae90e5f09a | |||
| 451ee7116f | |||
| 8b2f07d9c3 |
@@ -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 ~6–8 s to initialise. During this window every `status` poll returns error 599. The
|
||||||
|
correct recovery is to wait a moment, then restart the task. Retrying the same `taskid` does
|
||||||
|
not help — the service must be cold-started via a new `start` call.
|
||||||
|
|
||||||
|
**DirSize `status` version:** Must use `version=1`. Using `version=2` always returns 599
|
||||||
|
regardless of service state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tools
|
## Tools (v0.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 400–403) trigger a clear message with a hint to run `setup`.
|
3. Auth errors (codes 400–403) trigger a clear message with a hint to run `setup`.
|
||||||
4. Session expiry errors (106, 107, 119) are retried once transparently; if the retry also
|
4. Session expiry errors (106, 107, 119) are retried once transparently.
|
||||||
fails, the user sees "Session expired — please restart the MCP server."
|
|
||||||
5. Network errors (timeouts, connection refused) are reported as
|
5. Network errors (timeouts, connection refused) are reported as
|
||||||
"Cannot reach NAS at {host} — check connectivity."
|
"Cannot reach NAS at {host} — check connectivity."
|
||||||
6. Unknown DSM error codes are reported as "DSM error {code}: {raw_message}".
|
6. Unknown DSM error codes are reported as "DSM error {code}: {raw_message}".
|
||||||
@@ -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 ~6–8 s to initialise.
|
||||||
|
During cold start, `status` returns 599 on every poll. The fix is to wait briefly and
|
||||||
|
**restart** the task (new `start` call) — polling the original `taskid` again does not help.
|
||||||
|
`_start_and_poll_oneshot()` retries up to 6 task restarts before giving up.
|
||||||
|
|
||||||
|
### DirSize `status` API version
|
||||||
|
|
||||||
|
`SYNO.FileStation.DirSize::status` must be called with `version=1`. Using `version=2`
|
||||||
|
always returns error 599 regardless of whether the background service is running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configuration Model
|
## Configuration Model
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -402,3 +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
@@ -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,3 +1,3 @@
|
|||||||
"""MCP server for Synology FileStation."""
|
"""MCP server for Synology FileStation."""
|
||||||
|
|
||||||
__version__ = "0.2.7"
|
__version__ = "0.2.10"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)},
|
start_version=2,
|
||||||
poll_version=1,
|
poll_version=1,
|
||||||
start_version=2,
|
)
|
||||||
)
|
|
||||||
except SynologyError as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
ok, result = await _poll_oneshot("SYNO.FileStation.DirSize", 1, taskid, first_status)
|
|
||||||
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(
|
||||||
|
"SYNO.FileStation.MD5",
|
||||||
try:
|
start_params={"file_path": json.dumps(path)},
|
||||||
taskid, first_status = await client.start_and_poll_immediately(
|
start_version=2,
|
||||||
"SYNO.FileStation.MD5",
|
poll_version=1,
|
||||||
start_params={"file_path": json.dumps(path)},
|
)
|
||||||
poll_version=1,
|
|
||||||
start_version=2,
|
|
||||||
)
|
|
||||||
except SynologyError as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
ok, result = await _poll_oneshot("SYNO.FileStation.MD5", 1, taskid, first_status)
|
|
||||||
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}"
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user