diff --git a/CLAUDE.md b/CLAUDE.md index d4a7dce..0e21dcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,15 @@ See [SPEC.md](SPEC.md) for full tool specifications and DSM call details. maps error 400 to a clear "requires Btrfs-formatted volume" message. - **`SYNO.FileStation.BackgroundTask`:** Only the `list` method is available (v1–v3). No stop/cancel/clear methods exist on this firmware. +- **`SYNO.FileStation.Thumb` — `quality` parameter ignored:** DSM accepts the `quality` + field in the POST body but always returns the same JPEG regardless of the value. + No server-side quality control is available. +- **`SYNO.FileStation.Thumb` — size limits:** `small` thumbnails range from ~5 KB to + ~548 KB raw depending on source image resolution. `medium`/`large` can exceed 380 KB + raw. `original` reflects the full stored image and may be several MB. + The `get_thumbnail` tool enforces: abort >1.5 MB raw (≈2 MB base64), warning >375 KB + raw (≈500 KB base64). Default changed from `large` to `small` to avoid MCP buffer + overflows. ## Roadmap diff --git a/SPEC.md b/SPEC.md index f2e8b7c..60573ff 100644 --- a/SPEC.md +++ b/SPEC.md @@ -460,12 +460,26 @@ pagination hint when more results are available. | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `path` | str | yes | — | Share-relative path to the image or video file | -| `size` | str | no | `large` | `small`, `medium`, `large`, or `original` | +| `size` | str | no | `small` | `small`, `medium`, `large`, or `original` | **Returns:** JSON `{filename, size_bytes, content_base64}` (JPEG bytes encoded as base64). +If the thumbnail exceeds the soft limit (≈500 KB base64 / 375 KB raw), the JSON includes +an additional `"warning"` field advising to use `size='small'`. +If the thumbnail exceeds the hard limit (≈2 MB base64 / 1.5 MB raw), the tool returns +`"Error: Thumbnail too large (… KB base64) — use size='small' to get a smaller version."` +instead of the JSON payload. **DSM call:** `SYNO.FileStation.Thumb::get` (POST, v2) +> **`quality` parameter is ignored:** DSM accepts `quality` in the request but always +> returns the same JPEG regardless of the value. No server-side quality control is available. +> +> **Size limits confirmed against this NAS:** +> - `small` → 5 KB–548 KB raw depending on source image resolution. +> - `medium` / `large` → can exceed 380 KB raw even for modest photos. +> - `original` → reflects the actual stored image; may be several MB. +> Default was changed from `large` to `small` to avoid hitting MCP buffer limits. +> > DSM returns image bytes directly when the file has a thumbnail. Non-image content-type > indicates a DSM error envelope — the tool parses and surfaces the error code. diff --git a/pyproject.toml b/pyproject.toml index d8e8203..b2d47da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-filestation" -version = "0.3.4" +version = "0.3.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 cabd3c2..a026195 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.3.4" +__version__ = "0.3.5" diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index 311a283..d972028 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -25,6 +25,11 @@ _VALID_SORT_DIR = frozenset({"asc", "desc"}) # Cap on items returned by list_dir — DSM hard limit is 10000, we enforce lower. _MAX_LIMIT = 500 +# get_thumbnail: abort if raw bytes exceed this (≈2 MB base64 after encoding). +_THUMB_ABORT_BYTES = 1_500_000 +# get_thumbnail: include a warning field if raw bytes exceed this (≈500 KB base64). +_THUMB_WARN_BYTES = 375_000 + def _fmt_size(size: int | None) -> str: """Format a byte count as a human-readable string.""" @@ -1126,8 +1131,8 @@ def register_filestation( # ── thumbnail + favorites tools ─────────────────────────────────────── @mcp.tool() - async def get_thumbnail(path: str, size: str = "large"): - """Fetch a thumbnail for an image. Returns JSON: filename, size_bytes, content_base64.""" + async def get_thumbnail(path: str, size: str = "small"): + """Fetch a thumbnail for an image/video. Returns JSON with filename and base64 content.""" import base64 import json as _json @@ -1142,14 +1147,32 @@ def register_filestation( except SynologyError as e: return f"Error: {e}" + raw_len = len(img_bytes) + + # Hard limit: refuse to return a payload that would exceed ~2 MB base64. + if raw_len > _THUMB_ABORT_BYTES: + b64_kb = raw_len * 4 // 3 // 1024 + return ( + f"Error: Thumbnail too large ({b64_kb} KB base64) — " + f"use size='small' to get a smaller version." + ) + filename = path.rsplit("/", 1)[-1] - return _json.dumps( - { - "filename": filename, - "size_bytes": len(img_bytes), - "content_base64": base64.b64encode(img_bytes).decode(), - } - ) + payload: dict = { + "filename": filename, + "size_bytes": raw_len, + "content_base64": base64.b64encode(img_bytes).decode(), + } + + # Soft warning: note large payload without refusing it. + if raw_len > _THUMB_WARN_BYTES: + b64_kb = raw_len * 4 // 3 // 1024 + payload["warning"] = ( + f"Thumbnail is large ({b64_kb} KB base64). " + "Consider using size='small' for faster responses." + ) + + return _json.dumps(payload) @mcp.tool() async def list_favorites(): diff --git a/tests/test_tools_filestation.py b/tests/test_tools_filestation.py index a7f793f..2439234 100644 --- a/tests/test_tools_filestation.py +++ b/tests/test_tools_filestation.py @@ -1768,6 +1768,105 @@ async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None: assert "md5" in result.lower() or "hash" in result.lower() +# ────────────────────────────────────────────────────────────────────────── +# get_thumbnail +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_thumbnail_success(config: AppConfig) -> None: + """get_thumbnail returns JSON with filename, size_bytes, content_base64 for small images.""" + import base64 + + client = MagicMock() + img_bytes = b"\xff\xd8\xff" + b"\x00" * 1000 # fake JPEG, well under limits + client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes) + tools = _make_mcp_and_tools(config, client) + + result = await tools["get_thumbnail"](path="/photo/test.jpg", size="small") + + payload = json.loads(result) + assert payload["filename"] == "test.jpg" + assert payload["size_bytes"] == len(img_bytes) + assert base64.b64decode(payload["content_base64"]) == img_bytes + assert "warning" not in payload + + +@pytest.mark.asyncio +async def test_get_thumbnail_invalid_size(config: AppConfig) -> None: + """get_thumbnail returns Error: for an unrecognised size value.""" + client = MagicMock() + tools = _make_mcp_and_tools(config, client) + + result = await tools["get_thumbnail"](path="/photo/test.jpg", size="tiny") + + assert result.startswith("Error:") + assert "size" in result.lower() + + +@pytest.mark.asyncio +async def test_get_thumbnail_dsm_error(config: AppConfig) -> None: + """get_thumbnail returns Error: when the client raises SynologyError.""" + from mcp_synology_filestation.client import SynologyError + + client = MagicMock() + client.get_thumbnail_bytes = AsyncMock( + side_effect=SynologyError("File not found: /photo/missing.jpg", code=404) + ) + tools = _make_mcp_and_tools(config, client) + + result = await tools["get_thumbnail"](path="/photo/missing.jpg") + + assert result.startswith("Error:") + assert "not found" in result.lower() + + +@pytest.mark.asyncio +async def test_get_thumbnail_large_warning(config: AppConfig) -> None: + """get_thumbnail includes a warning field when image exceeds the soft limit (~375 KB raw).""" + client = MagicMock() + # 400 000 bytes raw > 375 000 threshold but < 1 500 000 hard limit + img_bytes = b"\x00" * 400_000 + client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes) + tools = _make_mcp_and_tools(config, client) + + result = await tools["get_thumbnail"](path="/photo/big.jpg", size="medium") + + payload = json.loads(result) + assert "content_base64" in payload + assert "warning" in payload + assert "small" in payload["warning"].lower() + + +@pytest.mark.asyncio +async def test_get_thumbnail_too_large_aborts(config: AppConfig) -> None: + """get_thumbnail returns Error: (no base64) when image exceeds the hard limit (~1.5 MB raw).""" + client = MagicMock() + # 2 000 000 bytes raw > 1 500 000 hard limit + img_bytes = b"\x00" * 2_000_000 + client.get_thumbnail_bytes = AsyncMock(return_value=img_bytes) + tools = _make_mcp_and_tools(config, client) + + result = await tools["get_thumbnail"](path="/photo/huge.jpg", size="original") + + assert result.startswith("Error:") + assert "large" in result.lower() or "too large" in result.lower() + # Must NOT contain base64 payload + assert "content_base64" not in result + + +@pytest.mark.asyncio +async def test_get_thumbnail_default_size_is_small(config: AppConfig) -> None: + """get_thumbnail defaults to size='small'.""" + client = MagicMock() + client.get_thumbnail_bytes = AsyncMock(return_value=b"\xff\xd8" + b"\x00" * 100) + tools = _make_mcp_and_tools(config, client) + + await tools["get_thumbnail"](path="/photo/test.jpg") + + client.get_thumbnail_bytes.assert_called_once_with("/photo/test.jpg", "small") + + # ────────────────────────────────────────────────────────────────────────── # background_tasks # ──────────────────────────────────────────────────────────────────────────