diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index f6060cf..d5f4f56 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -61,13 +61,21 @@ def register_filestation( # ── internal polling helper ─────────────────────────────────────────── - async def _poll_task(api: str, version: int, taskid: str) -> tuple[bool, dict[str, Any] | str]: - """Poll a DSM async task (CopyMove / Delete) until finished or timeout. + async def _poll_task( + api: str, + version: int, + taskid: str, + initial_delay: float = 0.2, + ) -> tuple[bool, dict[str, Any] | str]: + """Poll a DSM async task until finished or timeout. Args: api: DSM API name (e.g. "SYNO.FileStation.CopyMove"). version: API version to use for the status call. taskid: Task ID returned by the corresponding start method. + 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: ``(True, status_dict)`` on success, or ``(False, "Error: …")`` on @@ -76,13 +84,13 @@ def register_filestation( from mcp_synology_filestation.client import SynologyError as _SynologyError delay = 0.2 - elapsed = 0.0 + elapsed = initial_delay timeout = 60.0 - while True: - await asyncio.sleep(delay) - elapsed += delay + if initial_delay > 0: + await asyncio.sleep(initial_delay) + while True: try: status_data = await client.request( api, @@ -102,6 +110,8 @@ def register_filestation( "Error: Operation timed out after 60 seconds — check NAS manually.", ) + await asyncio.sleep(delay) + elapsed += delay delay = min(delay * 2, 2.0) @mcp.tool() @@ -805,7 +815,7 @@ 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) + ok, result = await _poll_task("SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0) if not ok: return result # type: ignore[return-value] @@ -866,7 +876,7 @@ def register_filestation( if not taskid: return "Error: DSM did not return a task ID." - ok, result = await _poll_task("SYNO.FileStation.MD5", 2, taskid) + ok, result = await _poll_task("SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0) if not ok: return result # type: ignore[return-value] diff --git a/test_dirsize_md5.py b/test_dirsize_md5.py index 1db1166..9de6d5e 100644 --- a/test_dirsize_md5.py +++ b/test_dirsize_md5.py @@ -2,26 +2,35 @@ Ergebnisse der Versionsanalyse (vollständig): DirSize: start v2, status v1 - MD5: start v2, status v1 - - Wichtig: MD5-Status ist ONE-SHOT — nach dem ersten erfolgreichen - Abruf verfällt die Task-ID sofort (code 599 bei allen Folgeabfragen). + MD5: start v2, status v2 + - Wichtig: MD5-Status ist ONE-SHOT -- nach dem ersten erfolgreichen + Abruf verfaellt die Task-ID sofort (code 599 bei allen Folgeabfragen). - Erster status-Aufruf muss direkt nach start kommen (delay~0). DirSize status-Response Felder: finished: bool num_dir: int (Anzahl Unterordner) num_file: int (Anzahl Dateien) - total_size: int (Gesamtgrösse in Bytes) + total_size: int (Gesamtgroesse in Bytes) MD5 status-Response Felder: finished: bool md5: str (Hex-String, 32 Zeichen) -Ausführen: uv run python test_dirsize_md5.py +Hypothese: _poll_task() schlaeft 200ms vor dem ersten status-Call. +Winzige Daten -> Task abgeschlossen bevor erster Poll -> 599. +Fix: initial_delay=0 fuer DirSize und MD5. + +WICHTIG: Rohe HTTP-Tests treffen evt. falschen API-Pfad (entry.cgi), +waehrend die API wirklich auf einem anderen Pfad (z.B. FileStation.cgi) +laeuft. Test unten zeigt API-Pfad aus dem Cache. + +Ausfuehren: uv run python test_dirsize_md5.py """ import asyncio import json +import time import httpx @@ -33,15 +42,17 @@ DIRSIZE_PATH = "/test-mcp" MD5_PATH = "/test-mcp/test.zip" -def pp(label: str, data: object) -> None: +def pp(label: str, data: object, elapsed_ms: float | None = None) -> None: print(f"\n{'='*60}") - print(f" {label}") + suffix = f" [{elapsed_ms:.1f} ms nach start]" 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, base_url: str, sid: str, **params) -> dict: - r = await http.get(f"{base_url}/webapi/entry.cgi", params={"_sid": sid, **params}) +async def raw(http: httpx.AsyncClient, url: str, sid: str, **params) -> dict: + """Hit a specific URL (not hardcoded to entry.cgi).""" + r = await http.get(url, params={"_sid": sid, **params}) r.raise_for_status() try: return r.json() @@ -49,6 +60,145 @@ async def raw(http: httpx.AsyncClient, base_url: str, sid: str, **params) -> dic return {"_raw": r.text[:300], "_http_status": r.status_code} +async def probe_dirsize( + http: httpx.AsyncClient, base: str, sid: str, api_url: str +) -> None: + print(f"\n{'#'*60}") + print(f" DIRSIZE — Timing-Probe (URL: {api_url})") + print(" Delays: 0ms, 50ms, 200ms, 500ms, stale, fake") + 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([DIRSIZE_PATH]), + ) + pp("DirSize::start", start_body, (time.perf_counter() - t0) * 1000) + + taskid = (start_body.get("data") or {}).get("taskid") + if not taskid: + print("[!] No taskid -- aborting probe.") + return + + for label, pre_sleep in [ + ("0ms delay", 0), + ("~50ms delay", 0.05), + ("~200ms delay", 0.15), + ("~500ms delay", 0.3), + ("stale ~2.5s", 2.0), + ]: + if pre_sleep: + await asyncio.sleep(pre_sleep) + t1 = time.perf_counter() + r = await raw( + http, api_url, sid, + api="SYNO.FileStation.DirSize", version="1", method="status", + taskid=taskid, + ) + pp(f"DirSize::status [{label}]", r, (t1 - t0) * 1000) + data = (r.get("data") or {}) + if data.get("finished"): + print("[*] finished=True. Stopping here.") + break + if not r.get("success"): + error_code = (r.get("error") or {}).get("code") + print(f"[!] Error {error_code}") + + # Fake taskid + rf = await raw( + http, api_url, sid, + api="SYNO.FileStation.DirSize", version="1", method="status", + taskid="fake-task-id-does-not-exist", + ) + pp("DirSize::status [fake taskid]", rf) + + +async def probe_md5( + http: httpx.AsyncClient, base: str, sid: str, api_url: str +) -> None: + print(f"\n{'#'*60}") + print(f" MD5 -- Timing-Probe (URL: {api_url})") + print(" Delays: 0ms, 50ms, 200ms, stale, fake") + print(f"{'#'*60}") + + t0 = time.perf_counter() + start_body = await raw( + http, api_url, sid, + api="SYNO.FileStation.MD5", version="2", method="start", + file_path=json.dumps(MD5_PATH), + ) + pp("MD5::start", start_body, (time.perf_counter() - t0) * 1000) + + taskid = (start_body.get("data") or {}).get("taskid") + if not taskid: + print("[!] No taskid -- aborting probe.") + return + + # Try both versions at 0ms for the same taskid + for ver in [1, 2]: + t1 = time.perf_counter() + r = await raw( + http, api_url, sid, + api="SYNO.FileStation.MD5", version=str(ver), method="status", + taskid=taskid, + ) + pp(f"MD5::status v{ver} [0ms delay]", r, (t1 - t0) * 1000) + data = (r.get("data") or {}) + if data.get("finished"): + print(f"[*] v{ver} finished=True!") + + for label, pre_sleep in [ + ("~50ms delay", 0.05), + ("~200ms delay", 0.15), + ("stale ~2.5s", 2.0), + ]: + await asyncio.sleep(pre_sleep) + for ver in [1, 2]: + t1 = time.perf_counter() + r = await raw( + http, api_url, sid, + api="SYNO.FileStation.MD5", version=str(ver), method="status", + taskid=taskid, + ) + pp(f"MD5::status v{ver} [{label}]", r, (t1 - t0) * 1000) + data = (r.get("data") or {}) + if data.get("finished"): + print(f"[*] v{ver} finished=True! md5={data.get('md5')}") + + # What does start response look like for a new task? + print("\n[*] Starting a second MD5 task to compare...") + t2 = time.perf_counter() + start2 = await raw( + http, api_url, sid, + api="SYNO.FileStation.MD5", version="2", method="start", + file_path=json.dumps(MD5_PATH), + ) + pp("MD5::start (2nd)", start2, (time.perf_counter() - t2) * 1000) + taskid2 = (start2.get("data") or {}).get("taskid") + if taskid2: + # Poll immediately with both versions + for ver in [1, 2]: + t3 = time.perf_counter() + r = await raw( + http, api_url, sid, + api="SYNO.FileStation.MD5", version=str(ver), method="status", + taskid=taskid2, + ) + pp(f"MD5::status v{ver} [0ms, task2]", r, (t3 - t2) * 1000) + data = (r.get("data") or {}) + if data.get("finished"): + print(f"[*] v{ver} task2 finished=True! md5={data.get('md5')}") + + # Fake taskid + rf = await raw( + http, api_url, sid, + api="SYNO.FileStation.MD5", version="2", method="status", + taskid="fake-task-id-does-not-exist", + ) + pp("MD5::status [fake taskid]", rf) + + async def main() -> None: config = load_config() auth = AuthManager(config) @@ -58,72 +208,25 @@ async def main() -> None: await client._ensure_initialized() # noqa: SLF001 sid = client.sid base = config.base_url - print(f"[*] SID: {'OK' if sid else 'MISSING'}") + + # --- Show API paths from cache --- + print(f"\n[*] SID: {'OK' if sid else 'MISSING'}") + for api_name in ["SYNO.FileStation.DirSize", "SYNO.FileStation.MD5"]: + info = client._api_cache.get(api_name) # noqa: SLF001 + if info: + print(f"[*] {api_name}: path={info['path']} v{info['minVersion']}-v{info['maxVersion']}") + else: + print(f"[!] {api_name}: NOT in API cache!") + + dirsize_info = client._api_cache.get("SYNO.FileStation.DirSize", {}) # noqa: SLF001 + md5_info = client._api_cache.get("SYNO.FileStation.MD5", {}) # noqa: SLF001 + + dirsize_url = f"{base}/webapi/{dirsize_info.get('path', 'entry.cgi')}" + md5_url = f"{base}/webapi/{md5_info.get('path', 'entry.cgi')}" async with httpx.AsyncClient(verify=config.connection.verify_ssl, timeout=30.0) as http: - - # ── DirSize: start v2, status v1 ───────────────────────────── - print(f"\n{'#'*60}") - print(" SYNO.FileStation.DirSize (start v2, status v1)") - print(f"{'#'*60}") - - start_body = await raw( - http, base, sid, - api="SYNO.FileStation.DirSize", version="2", method="start", - path=json.dumps([DIRSIZE_PATH]), - ) - pp("DirSize::start", start_body) - - taskid = (start_body.get("data") or {}).get("taskid") - if taskid: - for attempt in range(1, 11): - await asyncio.sleep(0.2) - status_body = await raw( - http, base, sid, - api="SYNO.FileStation.DirSize", version="1", method="status", - taskid=taskid, - ) - pp(f"DirSize::status (attempt {attempt})", status_body) - data = (status_body.get("data") or {}) - if data.get("finished"): - print("\n[*] FINISHED. Fields:") - for k, v in data.items(): - print(f" {k!r}: {type(v).__name__} = {v!r}") - break - - # ── MD5: start v2, status v1 (one-shot!) ───────────────────── - print(f"\n{'#'*60}") - print(" SYNO.FileStation.MD5 (start v2, status v1, ONE-SHOT)") - print(f"{'#'*60}") - - start_body = await raw( - http, base, sid, - api="SYNO.FileStation.MD5", version="2", method="start", - file_path=json.dumps(MD5_PATH), - ) - pp("MD5::start", start_body) - - taskid = (start_body.get("data") or {}).get("taskid") - if taskid: - # Immediately poll status v1 — one-shot window, don't delay - for attempt in range(1, 6): - if attempt > 1: - await asyncio.sleep(0.05) - status_body = await raw( - http, base, sid, - api="SYNO.FileStation.MD5", version="1", method="status", - taskid=taskid, - ) - pp(f"MD5::status (attempt {attempt})", status_body) - data = (status_body.get("data") or {}) - if data.get("finished"): - print("\n[*] FINISHED. Fields:") - for k, v in data.items(): - print(f" {k!r}: {type(v).__name__} = {v!r}") - break - if not status_body.get("success"): - print("[!] 599 — task window closed, done probing.") - break + await probe_dirsize(http, base, sid, dirsize_url) + await probe_md5(http, base, sid, md5_url) await auth.logout(client) print("\n[*] Logout OK.")