diff --git a/pyproject.toml b/pyproject.toml index 846165c..63c9023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-filestation" -version = "0.2.4" +version = "0.2.5" description = "MCP server for Synology FileStation" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_filestation/__init__.py b/src/mcp_synology_filestation/__init__.py index 4646ba6..a334bba 100644 --- a/src/mcp_synology_filestation/__init__.py +++ b/src/mcp_synology_filestation/__init__.py @@ -1,3 +1,3 @@ """MCP server for Synology FileStation.""" -__version__ = "0.2.4" +__version__ = "0.2.5" diff --git a/src/mcp_synology_filestation/client.py b/src/mcp_synology_filestation/client.py index 10ea729..2c06f6c 100644 --- a/src/mcp_synology_filestation/client.py +++ b/src/mcp_synology_filestation/client.py @@ -247,8 +247,6 @@ class FileStationClient: Raises: SynologyError: On API errors. """ - sys.stderr.write(f"[dsm] request: {api}/{method}\n") - sys.stderr.flush() if not self._initializing: await self._ensure_initialized() http = self._get_http() diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index aab94b1..14b9666 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -66,6 +66,7 @@ def register_filestation( version: int, taskid: str, initial_delay: float = 0.2, + window_timeout: float | None = None, ) -> tuple[bool, dict[str, Any] | str]: """Poll a DSM async task until finished or timeout. @@ -76,6 +77,11 @@ def register_filestation( 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). + window_timeout: For one-shot tasks (DirSize, MD5) whose result is + available exactly once: if we receive nothing but 599 errors for + this many seconds without ever seeing the task running + (``finished=False``), the result window was missed — return an + error immediately instead of waiting for the full 60 s timeout. Returns: ``(True, status_dict)`` on success, or ``(False, "Error: …")`` on @@ -86,6 +92,7 @@ def register_filestation( delay = 0.2 elapsed = initial_delay timeout = 60.0 + seen_task_alive = False # True once we receive any non-599 status response if initial_delay > 0: await asyncio.sleep(initial_delay) @@ -100,13 +107,25 @@ def register_filestation( ) except _SynologyError as e: if e.code == 599: - # DSM returns 599 while the async task is still initialising - # or running (task-not-yet-available). Treat it the same as - # finished=False and keep polling until the 60 s timeout. - pass + # DSM 599 = task not found. For one-shot tasks (DirSize, MD5) + # this means either the task hasn't started yet or the result + # window has already closed. If we've never seen the task + # running and window_timeout has elapsed, the window is gone — + # fail fast so the caller can retry rather than wait 60 s. + if ( + window_timeout is not None + and not seen_task_alive + and elapsed >= window_timeout + ): + return ( + False, + "Error: Could not read task result — the operation finished" + " before the first successful poll. Please retry.", + ) else: return False, f"Error: {e}" else: + seen_task_alive = True if status_data.get("finished"): return True, status_data @@ -821,7 +840,9 @@ def register_filestation( if not taskid: return "Error: DSM did not return a task ID." - ok, result = await _poll_task("SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0) + ok, result = await _poll_task( + "SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0, window_timeout=3.0 + ) if not ok: return result # type: ignore[return-value] @@ -882,7 +903,9 @@ def register_filestation( if not taskid: return "Error: DSM did not return a task ID." - ok, result = await _poll_task("SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0) + ok, result = await _poll_task( + "SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0, window_timeout=3.0 + ) if not ok: return result # type: ignore[return-value] diff --git a/tests/test_tools_filestation.py b/tests/test_tools_filestation.py index c88e355..8cb8d55 100644 --- a/tests/test_tools_filestation.py +++ b/tests/test_tools_filestation.py @@ -1635,8 +1635,13 @@ async def test_dir_size_retries_on_transient_599(config: AppConfig) -> None: @pytest.mark.asyncio -async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None: - """dir_size returns Error: after 60 s timeout when DSM keeps returning 599.""" +async def test_dir_size_window_timeout_on_persistent_599(config: AppConfig) -> None: + """dir_size fails fast (window_timeout=3 s) when DSM returns only 599s. + + Once the window_timeout elapses without ever seeing the task running + (finished=False), _poll_task returns the "result window missed" error + rather than waiting the full 60 s. + """ client = MagicMock() async def _request(api, method, version=None, params=None, **kwargs): @@ -1651,7 +1656,7 @@ async def test_dir_size_times_out_on_persistent_599(config: AppConfig) -> None: result = await tools["dir_size"](path="/dead") assert result.startswith("Error:") - assert "timed out" in result.lower() or "60 seconds" in result + assert "retry" in result.lower() or "window" in result.lower() or "poll" in result.lower() # ──────────────────────────────────────────────────────────────────────────