From c47221dc8f45c232ccd803bed1fa277c7393e5ac Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 14 Apr 2026 09:43:48 +0200 Subject: [PATCH] feat: add search and download tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 2 + .../tools/filestation.py | 178 ++++++++++++ tests/test_tools_filestation.py | 262 +++++++++++++++++- 3 files changed, 441 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 266555e..303b5de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,5 +95,7 @@ src/mcp_synology_filestation/ | `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 | +| `search` | Search for files by glob pattern with async polling | +| `download` | Download a file as base64 (max 10 MB) | See [SPEC.md](SPEC.md) for the full planned tool set. diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index d9ef23d..d3a061f 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import contextlib import json import logging from typing import TYPE_CHECKING @@ -212,6 +214,182 @@ def register_filestation( return "\n".join(lines) + @mcp.tool() + async def search( + path: str, + pattern: str, + recursive: bool = True, + max_results: int = 200, + ) -> str: + """Search for files matching a glob pattern within a directory. + + Starts an async DSM search task, polls until complete, then cleans up. + Use share paths as returned by list_shares (e.g. "/docker"). + + Args: + path: Root directory to search from (e.g. "/docker"). + pattern: Filename glob pattern (e.g. "*.yaml", "report*.pdf"). + recursive: Search subdirectories (default True). + max_results: Maximum number of matches to return (default 200, max 1000). + + Returns: + Formatted table with path, type, size, and modification time, + plus total match count. + """ + from mcp_synology_filestation.client import SynologyError + + limit = max(1, min(max_results, 1000)) + + # 1. Start search task + try: + start_data = await client.request( + "SYNO.FileStation.Search", + "start", + params={ + "folder_path": path, + "recursive": "true" if recursive else "false", + "pattern": pattern, + }, + ) + except SynologyError as e: + return f"Error: {e}" + + taskid: str = start_data.get("taskid", "") + if not taskid: + return "Error: DSM did not return a search task ID." + + # 2. Poll until finished=True (exponential backoff: 200 ms → 2 s, timeout 60 s) + delay = 0.2 + elapsed = 0.0 + timeout = 60.0 + files: list[dict] = [] + + while True: + await asyncio.sleep(delay) + elapsed += delay + + try: + poll_data = await client.request( + "SYNO.FileStation.Search", + "list", + params={ + "taskid": taskid, + "offset": 0, + "limit": limit, + "additional": json.dumps(["size", "time"]), + }, + ) + except SynologyError as e: + # Best-effort cleanup before surfacing the error + with contextlib.suppress(SynologyError): + await client.request( + "SYNO.FileStation.Search", "clean", params={"taskid": taskid} + ) + return f"Error: {e}" + + files = poll_data.get("files", []) + finished: bool = poll_data.get("finished", False) + + if finished: + break + + if elapsed >= timeout: + with contextlib.suppress(SynologyError): + await client.request( + "SYNO.FileStation.Search", "clean", params={"taskid": taskid} + ) + return "Error: Search timed out after 60 seconds." + + delay = min(delay * 2, 2.0) + + # 3. Clean up the search task + with contextlib.suppress(SynologyError): + await client.request("SYNO.FileStation.Search", "clean", params={"taskid": taskid}) + + if not files: + return f"No files matching '{pattern}' found under '{path}'." + + # 4. Format results + rows = [] + for f in files: + item_path = f.get("path") or f.get("name", "") + is_dir = f.get("isdir", False) + ftype = "dir" if is_dir else "file" + add = f.get("additional", {}) + size_str = "-" if is_dir else _fmt_size(add.get("size")) + mtime_str = _fmt_time((add.get("time") or {}).get("mtime")) + rows.append((item_path, ftype, size_str, mtime_str)) + + w_path = max(len("Path"), *(len(r[0]) for r in rows)) + w_type = max(len("Type"), *(len(r[1]) for r in rows)) + w_size = max(len("Size"), *(len(r[2]) for r in rows)) + w_mtime = max(len("Modified"), *(len(r[3]) for r in rows)) + + sep = ( + f"+{'-' * (w_path + 2)}" + f"+{'-' * (w_type + 2)}" + f"+{'-' * (w_size + 2)}" + f"+{'-' * (w_mtime + 2)}+" + ) + header = ( + f"| {'Path':<{w_path}} " + f"| {'Type':<{w_type}} " + f"| {'Size':<{w_size}} " + f"| {'Modified':<{w_mtime}} |" + ) + + lines = [f"Search: '{pattern}' under '{path}'", sep, header, sep] + for item_path, ftype, size_str, mtime_str in rows: + lines.append( + f"| {item_path:<{w_path}} " + f"| {ftype:<{w_type}} " + f"| {size_str:<{w_size}} " + f"| {mtime_str:<{w_mtime}} |" + ) + lines.append(sep) + lines.append(f"\n{len(rows)} match(es) found.") + + return "\n".join(lines) + + @mcp.tool() + async def download(path: str) -> str: + """Download a single file from the NAS and return its content as base64. + + Files larger than 10 MB are rejected — use SFTP or another method instead. + Use share paths as returned by list_shares (e.g. "/docker/app/config.yaml"). + + Args: + path: Absolute share-relative path to the file on the NAS. + + Returns: + JSON object with "filename", "size" (bytes), and "content_base64". + """ + import base64 + + from mcp_synology_filestation.client import SynologyError + + max_download_bytes = 10 * 1024 * 1024 # 10 MB + + try: + filename, content = await client.download_bytes(path) + except SynologyError as e: + return f"Error: {e}" + + size = len(content) + if size > max_download_bytes: + return ( + f"Error: File '{filename}' is {_fmt_size(size)}, which exceeds the 10 MB limit " + "for MCP downloads. Use SFTP or another file-transfer method instead." + ) + + return json.dumps( + { + "filename": filename, + "size": size, + "content_base64": base64.b64encode(content).decode(), + } + ) + @mcp.tool() async def get_info(path: str) -> str: """Get detailed metadata for one or more files or folders on the NAS. diff --git a/tests/test_tools_filestation.py b/tests/test_tools_filestation.py index edcb38b..80a53d8 100644 --- a/tests/test_tools_filestation.py +++ b/tests/test_tools_filestation.py @@ -2,8 +2,9 @@ from __future__ import annotations +import base64 import json -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -414,3 +415,262 @@ async def test_get_info_uses_getinfo_method(config: AppConfig) -> None: call_args = client.request.call_args assert call_args[0][0] == "SYNO.FileStation.List" assert call_args[0][1] == "getinfo" + + +# ────────────────────────────────────────────────────────────────────────── +# search +# ────────────────────────────────────────────────────────────────────────── + +_SEARCH_FILE = { + "path": "/docker/app/compose.yaml", + "name": "compose.yaml", + "isdir": False, + "additional": {"size": 1024, "time": {"mtime": 1700000000}}, +} + + +@pytest.mark.asyncio +async def test_search_success(config: AppConfig) -> None: + """search returns a formatted table after polling two rounds until finished=True.""" + client = MagicMock() + + call_count = 0 + + async def _request(api, method, **kwargs): + nonlocal call_count + call_count += 1 + if method == "start": + return {"taskid": "abc123", "has_not_index_share": False} + if method == "list": + # First poll: not finished yet; second poll: finished + finished = call_count >= 4 # start=1, list1=2, list2=3 → finished on call 3 + return {"files": [_SEARCH_FILE], "finished": finished, "total": 1} + if method == "clean": + return {} + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["search"](path="/docker", pattern="*.yaml") + + assert "/docker/app/compose.yaml" in result + assert "file" in result + assert "1 match(es) found" in result + # Verify start was called with correct params + start_call = client.request.call_args_list[0] + assert start_call[0][0] == "SYNO.FileStation.Search" + assert start_call[0][1] == "start" + assert start_call[1]["params"]["folder_path"] == "/docker" + assert start_call[1]["params"]["pattern"] == "*.yaml" + assert start_call[1]["params"]["recursive"] == "true" + # Verify clean was called last + last_call = client.request.call_args_list[-1] + assert last_call[0][1] == "clean" + assert last_call[1]["params"]["taskid"] == "abc123" + + +@pytest.mark.asyncio +async def test_search_polls_until_finished(config: AppConfig) -> None: + """search keeps polling when finished=False and stops once finished=True.""" + client = MagicMock() + poll_calls = 0 + + async def _request(api, method, **kwargs): + nonlocal poll_calls + if method == "start": + return {"taskid": "t1"} + if method == "list": + poll_calls += 1 + return { + "files": [_SEARCH_FILE], + "finished": poll_calls >= 3, # finish on third list call + "total": 1, + } + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["search"](path="/docker", pattern="*.yaml") + + assert poll_calls == 3 + assert "1 match(es) found" in result + + +@pytest.mark.asyncio +async def test_search_no_results(config: AppConfig) -> None: + """search returns a friendly message when no files are found.""" + client = MagicMock() + + async def _request(api, method, **kwargs): + if method == "start": + return {"taskid": "t2"} + if method == "list": + return {"files": [], "finished": True, "total": 0} + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["search"](path="/docker", pattern="*.txt") + + assert "No files" in result or "not found" in result.lower() + + +@pytest.mark.asyncio +async def test_search_start_dsm_error(config: AppConfig) -> None: + """search returns Error: immediately when the start call fails.""" + client = MagicMock() + client.request = AsyncMock( + side_effect=SynologyError("Permission denied — check DSM user permissions", code=105) + ) + + tools = _make_mcp_and_tools(config, client) + + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["search"](path="/docker", pattern="*.yaml") + + assert result.startswith("Error:") + assert "Permission denied" in result + + +@pytest.mark.asyncio +async def test_search_list_dsm_error(config: AppConfig) -> None: + """search returns Error: and cleans up when a list poll fails.""" + client = MagicMock() + + async def _request(api, method, **kwargs): + if method == "start": + return {"taskid": "t3"} + if method == "list": + raise SynologyError("Unknown error", code=100) + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["search"](path="/docker", pattern="*.yaml") + + assert result.startswith("Error:") + # clean should have been attempted + clean_calls = [c for c in client.request.call_args_list if c[0][1] == "clean"] + assert len(clean_calls) == 1 + + +@pytest.mark.asyncio +async def test_search_recursive_false(config: AppConfig) -> None: + """search passes recursive=false when recursive=False.""" + client = MagicMock() + + async def _request(api, method, **kwargs): + if method == "start": + return {"taskid": "t4"} + if method == "list": + return {"files": [], "finished": True, "total": 0} + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + + with patch("asyncio.sleep", new_callable=AsyncMock): + await tools["search"](path="/docker", pattern="*.yaml", recursive=False) + + start_call = client.request.call_args_list[0] + assert start_call[1]["params"]["recursive"] == "false" + + +@pytest.mark.asyncio +async def test_search_additional_format(config: AppConfig) -> None: + """search uses json.dumps(["size","time"]) for additional parameter.""" + client = MagicMock() + + async def _request(api, method, **kwargs): + if method == "start": + return {"taskid": "t5"} + if method == "list": + return {"files": [], "finished": True, "total": 0} + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + + with patch("asyncio.sleep", new_callable=AsyncMock): + await tools["search"](path="/docker", pattern="*.yaml") + + list_call = client.request.call_args_list[1] + assert list_call[1]["params"]["additional"] == json.dumps(["size", "time"]) + + +# ────────────────────────────────────────────────────────────────────────── +# download +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_download_success(config: AppConfig) -> None: + """download returns JSON with filename, size, and valid base64 content.""" + content = b"hello, world" + client = MagicMock() + client.download_bytes = AsyncMock(return_value=("compose.yaml", content)) + + tools = _make_mcp_and_tools(config, client) + result = await tools["download"](path="/docker/app/compose.yaml") + + parsed = json.loads(result) + assert parsed["filename"] == "compose.yaml" + assert parsed["size"] == len(content) + assert base64.b64decode(parsed["content_base64"]) == content + + +@pytest.mark.asyncio +async def test_download_size_limit(config: AppConfig) -> None: + """download returns Error: when file exceeds 10 MB.""" + large_content = b"x" * (10 * 1024 * 1024 + 1) + client = MagicMock() + client.download_bytes = AsyncMock(return_value=("bigfile.bin", large_content)) + + tools = _make_mcp_and_tools(config, client) + result = await tools["download"](path="/data/bigfile.bin") + + assert result.startswith("Error:") + assert "10 MB" in result or "exceeds" in result + + +@pytest.mark.asyncio +async def test_download_dsm_error(config: AppConfig) -> None: + """download returns Error: on SynologyError.""" + client = MagicMock() + client.download_bytes = AsyncMock( + side_effect=SynologyError("File or folder not found", code=1800) + ) + + tools = _make_mcp_and_tools(config, client) + result = await tools["download"](path="/data/missing.txt") + + assert result.startswith("Error:") + assert "not found" in result.lower() + + +@pytest.mark.asyncio +async def test_download_exactly_10mb(config: AppConfig) -> None: + """download accepts files exactly at the 10 MB boundary.""" + content = b"x" * (10 * 1024 * 1024) + client = MagicMock() + client.download_bytes = AsyncMock(return_value=("edge.bin", content)) + + tools = _make_mcp_and_tools(config, client) + result = await tools["download"](path="/data/edge.bin") + + parsed = json.loads(result) + assert parsed["size"] == len(content)