Adds read_text tool with pypdf integration for PDF text extraction,
plain-text decode for TXT/MD/CSV/JSON/etc, page-filter support,
max_chars truncation, and 11 mock-based tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- client.py: collapse 3-line raise SynologyError(...) to one line
(fits within 100 chars; ruff format output)
- uv.lock: package version entry updated from 0.2.4 to 0.3.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hard-won DSM quirks confirmed by live testing:
- Search::start folder_path must be json.dumps([path]) — plain string or
json.dumps(path) is silently ignored, causing empty results
- Extract::start source archive key is file_path (not path); both
file_path and dest_folder_path require json.dumps()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 — search::start folder_path format (already fixed in 314fae9):
json.dumps([path]) is confirmed correct per official Synology API docs
and multiple independent implementations (N4S4/synology-api, kwent/syno).
Poll-loop last-non-empty guard (if current_files:) is also in place.
No further change needed for Bug 1.
Bug 2 — extract::start wrong parameter key:
The previous fix attempt renamed "file_path" → "path", which was wrong.
Official API docs and independent implementations confirm the key is
"file_path". The json.dumps() wrapping on file_path and dest_folder_path
was already correct. Reverted the key rename.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 — search always returned empty results:
Search::start was passing folder_path as a plain string.
DSM silently ignores a plain string for this parameter and returns
finished=True with files=[] immediately, as if nothing was found.
Fix: json.dumps([path]) — JSON array, matching the multi-path API
pattern used by DirSize::start and List::getinfo.
Bug 2 — extract returned DSM error 408:
Extract::start was using "file_path" as the parameter key for the
source archive. DSM expects "path". Without a valid path DSM returned
error 408. The json.dumps wrapping was already correct.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tools/list payload: 8943 B → 8399 B (−6.1%)
All 16 multi-line docstrings collapsed to single lines per CLAUDE.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DSM API uses POST with application/x-www-form-urlencoded, not GET
- Error 408 means "non-supported additional field", not "device token required"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
Add FileStationClient.start_and_poll_immediately: starts the async task and
immediately makes the first status poll within the same method, with no
intermediate awaits other than the two HTTP calls. This minimises scheduler
latency between start and first poll for one-shot tasks.
_poll_oneshot now accepts the first_status from start_and_poll_immediately:
- finished=True on first poll → return immediately
- finished=False → Phase 2 (exponential backoff, 60 s timeout)
- None (first poll was 599) → burst-retry 10× at 10 ms, then Phase 2
(Phase 2 keeps polling through 599 until seen_alive, then fails fast)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace _poll_task for one-shot tasks with _poll_oneshot, which uses two
phases: (1) a burst of up to 11 immediate polls at 50ms intervals to catch
tasks that complete in <500ms, and (2) exponential-backoff polling once
finished=False is observed. A 599 during burst → window missed (fail fast).
A 599 during Phase 2 (task was seen running) → same. _poll_task is
simplified back to a plain long-poll with no window_timeout logic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_poll_task now accepts window_timeout. For DirSize and MD5 (one-shot result
windows), if only 599 errors arrive for window_timeout seconds without ever
seeing the task alive (finished=False), return a fast "result window missed —
please retry" error instead of waiting the full 60 s. Tasks that return
finished=False at least once (large dirs, large files) are unaffected.
Also removes the stale [dsm] debug stderr.write left in client.request().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DirSize/MD5 return error 599 while the async task is still initialising on
the NAS, not only after the task is gone. Remove the 5-consecutive-599 abort
limit and the debug stderr logging; instead pass on 599 and keep polling
until the existing 60 s timeout fires. Rename the test that checked the old
limit to reflect the new timeout-based behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Logs [poll] lines to stderr so Claude Desktop's MCP log shows exactly
what DSM returns on each status call: attempt number, elapsed time,
finished flag, data keys (on success) or error code + message (on 599).
Version 0.2.3 — remove this logging once the 599 root cause is confirmed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DirSize for large directories (e.g. /docker, 8441 folders, 46832 files)
takes ~800ms to compute. While running, status returns intermediate
progress (finished=false). But on the very first poll the task can return
599 transiently (task just started, not yet available). Previously
_poll_task caught any SynologyError and returned immediately, making
dir_size always fail on the first 599.
Fix: treat 599 as a transient condition and continue polling. Give up
only after 5 consecutive 599 responses. All other error codes remain
immediately fatal.
Investigation confirmed with test_dirsize_md5.py:
- /test-mcp (2937 B): finished=true at 0ms
- /docker (3.9 GB, 46832 files): finished=false at 35ms, finished=true at 789ms
Tests: 2 new cases (retry-succeeds, 5x-599-gives-up) → 95 total
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Includes all DirSize/MD5 polling fixes:
- initial_delay=0.0 for DirSize and MD5 (poll immediately after start)
- MD5 status uses version=1 (v2 always returns 599 on this NAS)
- __version__ kept in sync with pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three path formats tested against NAS:
a) plain string -> 599 (wrong, task fails silently)
b) json.dumps([path]) -> works (JSON array is the correct format)
c) json.dumps(path) -> 599 (wrong, double-encoded string)
Confirms current implementation (json.dumps(paths)) is correct.
MD5 probe simplified to final confirmed settings (status v1, 0ms).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Live NAS investigation (test_dirsize_md5.py) revealed two bugs:
1. _poll_task() always slept 200ms before the first status call.
DirSize and MD5 complete near-instantly on small data, so the result
window closes before the first poll. Fix: add initial_delay parameter
(default 0.2s for CopyMove/Delete/Compress/Extract); DirSize and MD5
pass initial_delay=0.0 to poll immediately after start.
2. get_md5 used status version=2, but the NAS only serves the result on
status v1 (v2 always returns 599 regardless of timing). Fix: change
_poll_task version to 1 for SYNO.FileStation.MD5.
MD5 is one-shot: the result is consumed on the first successful status
read. Polling at 0ms ensures we catch it before it expires.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DirSize: _poll_task now uses status version=1 (v2 always returns 599)
- MD5: _poll_task keeps status version=2 (confirmed working via live NAS test)
Investigation notes documented in test_dirsize_md5.py:
both APIs use start v2; DirSize status needs v1, MD5 status needs v2;
tiny data causes one-shot race condition (no issue with real-world data).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dir_size: SYNO.FileStation.DirSize v2 — async scan of one or more
directories, returns folder/file count and total size as a table.
get_md5: SYNO.FileStation.MD5 v2 — async MD5 checksum of a file.
Both follow the existing _poll_task pattern. 9 new unit tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reduced tools/list JSON payload from ~45 KB to 5.7 KB by replacing
verbose multi-paragraph docstrings with 1-2 line summaries on all
14 @mcp.tool() functions. Fixes Claude Desktop truncation of tools/list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FastMCP generates an outputSchema from the return type annotation, which
inflates the tools/list payload and triggers truncation in Claude Desktop.
Dropping the annotations suppresses outputSchema generation entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements SYNO.FileStation.Compress (v3) and SYNO.FileStation.Extract (v2)
with async polling identical to copy/move. Includes input validation for
compress (level, mode, format, empty paths) and 11 new unit tests.
Bumps version to 0.2.0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SYNO.FileStation.CheckExist returns error 400 for every parameter format on
this DSM firmware, so the tool falls back to SYNO.FileStation.List::getinfo.
DSM returns an entry per requested path with name=None for non-existent paths,
which provides a reliable exists/not-exists signal.
Accepts a single path or a comma-separated list; returns a table of
Path | Exists (Yes/No) with a count footer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All path/name params are json.dumps-wrapped per confirmed DSM behaviour.
copy and move use async polling via a shared _poll_task helper
(exponential backoff 200ms→2s, 60s timeout). delete requires
confirmed=True; without it only a preview is returned and no DSM call
is made. upload decodes base64 and enforces a 50 MB cap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DSM sends files=[] on the final finished=True poll even when results
exist in earlier rounds. Overwriting files each iteration discarded
the real results, producing a false "No files found" response.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements SYNO.FileStation.Search with async polling (exponential
backoff 200ms→2s, 60s timeout) and SYNO.FileStation.Download with
base64 output and a 10 MB size cap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DSM accepts multiple paths as a JSON array string, not comma-separated.
Comma-separated is treated as a single literal path; repeated path[]
params return error 400. Confirmed via test_getinfo_multipath.py.
- get_info: path param changed from ",".join(paths) to json.dumps(paths)
- tests: update multi-path assertion to expect JSON array format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SYNO.FileStation.Stat is absent from this NAS's API registry.
SYNO.FileStation.List::getinfo returns identical data and is confirmed
working.
- tools/filestation.py: new get_info tool — accepts one or more
comma-separated paths, calls getinfo with real_path/size/time/perm/
owner/type additional fields, returns a 9-column table
- tests: 6 new tests covering single file, directory, multi-path,
empty input, DSM error, and correct API method assertion
- SPEC.md: remove SYNO.FileStation.Stat from API table, rewrite get_info
tool section to reference getinfo, update list_dir note
- CLAUDE.md: update Implemented Tools list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause of DSM 408 was wrong path format (volume path vs share path),
not the additional parameter. Confirmed via test_additional.py that
json.dumps(["size","time"]) is the correct format; comma-separated string
is silently ignored by DSM.
- list_dir: restore 4-column table (Name, Type, Size, Modified)
- list_dir: use additional=json.dumps(["size","time"]) (confirmed working)
- SPEC.md: document share path requirement, additional format rules,
note SYNO.FileStation.Stat unavailability, remove comma-sep gotcha
- tests: restore size/mtime mock data and assertions
- delete test_additional.py (throwaway diagnostic script)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test_additional.py tests SYNO.FileStation.List::list with three variants:
no additional, comma-separated string, and JSON array — bypasses client
error mapping to log the raw DSM response for each. Delete after use.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: DSM expects share paths (/dev) not volume paths (/volume1/dev).
The 408 errors were triggered by any additional field on the wrong path format.
- list_shares: use share["path"] directly (e.g. /dev), drop real_path from
additional — only volume_status remains
- list_dir: remove additional parameter entirely; table now shows name + type
(isdir is returned by default); update docstring to show share path examples
- client.py: remove diagnostic REQUEST and RAW ERROR stderr logging
- tests: update assertions to match share paths and two-column table output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diagnostic build to identify root cause of 408 on list_dir:
- client.py: log resolved version + NAS maxVersion before every request
- client.py: log full raw body on every error response
- tools/filestation.py: remove additional parameter entirely to test if
any additional field triggers 408, or if the issue is elsewhere
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SYNO.FileStation.List::list returns error 408 ("Non-supported additional
field") for real_path, perm, and type. Reduce additional to ["size","time"]
— the only fields reliably supported across DSM versions. isdir is already
present in the default response.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>