fix: get_thumbnail size limits, default=small, quality quirk docs (v0.3.5)

- Default size changed large → small (avoids MCP buffer overflows)
- Hard limit: return Error: when thumbnail exceeds ~2 MB base64 (1.5 MB raw)
- Soft limit: add "warning" field to JSON when thumbnail exceeds ~500 KB base64
  (375 KB raw), advising to use size='small'
- Constants _THUMB_ABORT_BYTES / _THUMB_WARN_BYTES moved to module level
- 6 new tests for size cap/warning/default/DSM-error paths (113 total)
- SPEC.md: document quality-ignored quirk, size ranges, soft+hard limits
- CLAUDE.md: DSM Quirks entry for Thumb quality/size behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 06:39:57 +02:00
parent 4c6de3bfc7
commit 4430807b55
6 changed files with 157 additions and 12 deletions
+9
View File
@@ -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 (v1v3).
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
+15 -1
View File
@@ -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 KB548 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.
+1 -1
View File
@@ -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 = [
+1 -1
View File
@@ -1,3 +1,3 @@
"""MCP server for Synology FileStation."""
__version__ = "0.3.4"
__version__ = "0.3.5"
@@ -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():
+99
View File
@@ -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
# ──────────────────────────────────────────────────────────────────────────