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:
@@ -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.
|
maps error 400 to a clear "requires Btrfs-formatted volume" message.
|
||||||
- **`SYNO.FileStation.BackgroundTask`:** Only the `list` method is available (v1–v3).
|
- **`SYNO.FileStation.BackgroundTask`:** Only the `list` method is available (v1–v3).
|
||||||
No stop/cancel/clear methods exist on this firmware.
|
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
|
## Roadmap
|
||||||
|
|
||||||
|
|||||||
@@ -460,12 +460,26 @@ pagination hint when more results are available.
|
|||||||
| Name | Type | Required | Default | Description |
|
| Name | Type | Required | Default | Description |
|
||||||
|------|------|----------|---------|-------------|
|
|------|------|----------|---------|-------------|
|
||||||
| `path` | str | yes | — | Share-relative path to the image or video file |
|
| `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).
|
**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)
|
**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
|
> 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.
|
> indicates a DSM error envelope — the tool parses and surfaces the error code.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-filestation"
|
name = "mcp-synology-filestation"
|
||||||
version = "0.3.4"
|
version = "0.3.5"
|
||||||
description = "MCP server for Synology FileStation"
|
description = "MCP server for Synology FileStation"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""MCP server for Synology FileStation."""
|
"""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.
|
# Cap on items returned by list_dir — DSM hard limit is 10000, we enforce lower.
|
||||||
_MAX_LIMIT = 500
|
_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:
|
def _fmt_size(size: int | None) -> str:
|
||||||
"""Format a byte count as a human-readable string."""
|
"""Format a byte count as a human-readable string."""
|
||||||
@@ -1126,8 +1131,8 @@ def register_filestation(
|
|||||||
# ── thumbnail + favorites tools ───────────────────────────────────────
|
# ── thumbnail + favorites tools ───────────────────────────────────────
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_thumbnail(path: str, size: str = "large"):
|
async def get_thumbnail(path: str, size: str = "small"):
|
||||||
"""Fetch a thumbnail for an image. Returns JSON: filename, size_bytes, content_base64."""
|
"""Fetch a thumbnail for an image/video. Returns JSON with filename and base64 content."""
|
||||||
import base64
|
import base64
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
@@ -1142,14 +1147,32 @@ def register_filestation(
|
|||||||
except SynologyError as e:
|
except SynologyError as e:
|
||||||
return f"Error: {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]
|
filename = path.rsplit("/", 1)[-1]
|
||||||
return _json.dumps(
|
payload: dict = {
|
||||||
{
|
"filename": filename,
|
||||||
"filename": filename,
|
"size_bytes": raw_len,
|
||||||
"size_bytes": len(img_bytes),
|
"content_base64": base64.b64encode(img_bytes).decode(),
|
||||||
"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()
|
@mcp.tool()
|
||||||
async def list_favorites():
|
async def list_favorites():
|
||||||
|
|||||||
@@ -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()
|
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
|
# background_tasks
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user