feat: add search and download tools
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>
This commit is contained in:
@@ -95,5 +95,7 @@ src/mcp_synology_filestation/
|
|||||||
| `list_shares` | List all shared folders with volume usage |
|
| `list_shares` | List all shared folders with volume usage |
|
||||||
| `list_dir` | List directory contents with pagination and sorting |
|
| `list_dir` | List directory contents with pagination and sorting |
|
||||||
| `get_info` | Get detailed metadata for one or more paths |
|
| `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.
|
See [SPEC.md](SPEC.md) for the full planned tool set.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -212,6 +214,182 @@ def register_filestation(
|
|||||||
|
|
||||||
return "\n".join(lines)
|
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()
|
@mcp.tool()
|
||||||
async def get_info(path: str) -> str:
|
async def get_info(path: str) -> str:
|
||||||
"""Get detailed metadata for one or more files or folders on the NAS.
|
"""Get detailed metadata for one or more files or folders on the NAS.
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -414,3 +415,262 @@ async def test_get_info_uses_getinfo_method(config: AppConfig) -> None:
|
|||||||
call_args = client.request.call_args
|
call_args = client.request.call_args
|
||||||
assert call_args[0][0] == "SYNO.FileStation.List"
|
assert call_args[0][0] == "SYNO.FileStation.List"
|
||||||
assert call_args[0][1] == "getinfo"
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user