From 04ffaa457bf70b48bca05d5f1ed1507d4a8276b6 Mon Sep 17 00:00:00 2001 From: Marcus Date: Mon, 22 Jun 2026 16:32:22 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20v0.9.1=20=E2=80=94=20clearer=20messages?= =?UTF-8?q?=20for=20exec/redeploy/check=5Fimage=5Fupdates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three live-verified MCP weaknesses (update run 2026-06-22): - exec_in_container: SYNO.Docker.Container/exec does not exist on this DSM firmware (code 103, like pause/unpause). Catch it and return a guiding "not supported on this firmware" message instead of the raw DSM error; the tool stays available for firmwares that do implement exec. - redeploy_project: the failure hint suggested stop_project + start_project, but start_project does not recreate on a new image (it restarts the old one). Both failure hints now point back at redeploy_project and warn about parallel pull_image lock contention (DSM 2101/2202). - check_image_updates: docstring and output now state it only reflects DSM digest drift on already-pulled tags and does NOT discover newer version tags (use list_image_tags + release notes for that). Tests updated accordingly; CHANGELOG + version bumped to 0.9.1. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 14 ++++++++++ pyproject.toml | 2 +- .../modules/containers.py | 8 ++++++ src/mcp_synology_container/modules/images.py | 11 +++++++- .../modules/projects.py | 12 ++++++-- tests/test_modules/test_containers.py | 28 +++++++++++++++++++ tests/test_modules/test_images.py | 2 ++ tests/test_modules/test_projects.py | 5 ++-- uv.lock | 2 +- 9 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9fc297..8fd9bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [0.9.1] - 2026-06-22 + +### Fixed +- `exec_in_container`: return a clear "not supported on this DSM firmware" + message instead of the raw DSM "Method does not exist" (code 103). +- `redeploy_project`: corrected the failure hint that suggested + `stop_project + start_project` — `start_project` does not recreate on a new + image. It now points back to `redeploy_project` and warns about parallel + `pull_image` lock contention (DSM 2101/2202). + +### Changed +- `check_image_updates`: docstring and output now state it only reflects digest + drift on already-pulled tags and does not discover newer version tags. + ## [0.9.0] - 2026-06-16 ### Changed (resilience against a slow/stalled NAS) diff --git a/pyproject.toml b/pyproject.toml index f5d65e0..16fc8a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.9.0" +version = "0.9.1" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/modules/containers.py b/src/mcp_synology_container/modules/containers.py index 6e68353..df81c71 100644 --- a/src/mcp_synology_container/modules/containers.py +++ b/src/mcp_synology_container/modules/containers.py @@ -269,6 +269,14 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N }, ) except Exception as e: + if getattr(e, "code", None) == 103 or "does not exist" in str(e).lower(): + return ( + "exec_in_container is not supported on this DSM firmware " + "(Container Manager exposes no 'exec' API method). " + "To read a file inside a container, use the synology-filestation " + "'download' tool on the bind-mount path; to run a command, use the " + "Container Manager UI terminal." + ) return f"Error executing command in '{container_name}': {e}" output = data.get("output", "") diff --git a/src/mcp_synology_container/modules/images.py b/src/mcp_synology_container/modules/images.py index 78af1d0..570d722 100644 --- a/src/mcp_synology_container/modules/images.py +++ b/src/mcp_synology_container/modules/images.py @@ -369,7 +369,9 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: @mcp.tool() async def check_image_updates(project_name: str | None = None): - """Check which local images have updates available (upgradable flag from NAS registry).""" + """Report which local images DSM flags as upgradable (digest drift on + already-pulled tags). Does NOT discover newer version tags (e.g. 0.8.2 → + 0.8.3) — use list_image_tags + the image's official releases for that.""" try: data = await client.request( "SYNO.Docker.Image", @@ -416,4 +418,11 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: if not upgradable: lines.append("All images are up to date.") + lines.append("") + lines.append( + "Note: 'upgradable' reflects digest drift on tags you already have; it " + "does NOT detect newer version tags. Use list_image_tags + the image's " + "release notes to find newer versions." + ) + return "\n".join(lines) diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py index 83eb601..121f41c 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -166,7 +166,9 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""): return ( f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n" - f"Workaround: use stop_project + start_project separately." + "Wait for the current operation to finish, then retry redeploy_project. " + "(start_project alone does NOT recreate on a new image — it only restarts " + "the existing container.)" ) results: list[str] = [] @@ -269,7 +271,13 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non f"redeploy_project to recover." ) else: - results.append("Workaround: use stop_project + start_project separately.") + results.append( + "The project was not modified. Retry redeploy_project — it stops the " + "project and recreates the containers on the new image. Do NOT use " + "start_project to apply a new tag (it only restarts the old image). A " + "transient DSM 2101/2202 here is usually a lock from a concurrent " + "operation — e.g. a parallel pull_image — so don't pull and deploy at once." + ) return "\n".join(results) diff --git a/tests/test_modules/test_containers.py b/tests/test_modules/test_containers.py index 63bafe4..3e67cd1 100644 --- a/tests/test_modules/test_containers.py +++ b/tests/test_modules/test_containers.py @@ -188,6 +188,34 @@ async def test_exec_in_container_confirmed(): assert "Exit code: 0" in result +@pytest.mark.asyncio +async def test_exec_in_container_unsupported_firmware(): + """DSM code 103 ('Method does not exist') → clear 'not supported' guidance, + not the raw DSM error.""" + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Container" and method == "list": + return {"containers": []} + if api == "SYNO.Docker.Container" and method == "exec": + raise SynologyError("Method does not exist", code=103) + return {} + + client.request.side_effect = mock_request + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True) + assert "not supported on this DSM firmware" in result + assert "filestation" in result.lower() + # The raw DSM phrasing must not leak as the only message. + assert not result.startswith("Error executing command") + + # ────────────────────────────────────────────────────────────────────────────── # container_stats # ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_modules/test_images.py b/tests/test_modules/test_images.py index 9531526..50a1769 100644 --- a/tests/test_modules/test_images.py +++ b/tests/test_modules/test_images.py @@ -720,6 +720,7 @@ async def test_check_image_updates_all(): assert "nginx:1.24" in result assert "UPDATE AVAILABLE" in result assert "postgres:15" in result + assert "does NOT detect newer version tags" in result @pytest.mark.asyncio @@ -744,6 +745,7 @@ async def test_check_image_updates_all_up_to_date(): result = await tools["check_image_updates"]() assert "All images are up to date" in result + assert "does NOT detect newer version tags" in result @pytest.mark.asyncio diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index 016522c..515aef5 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -353,7 +353,7 @@ async def test_redeploy_large_image_returns_background_hint(): @pytest.mark.asyncio async def test_redeploy_unknown_status_returns_error(): - """Unknown status must return a clear error with a workaround hint.""" + """Unknown status must return a clear error pointing back at redeploy_project.""" client = AsyncMock() async def mock_request(api, method, **kwargs): @@ -368,7 +368,8 @@ async def test_redeploy_unknown_status_returns_error(): result = await tools["redeploy_project"]("myapp", confirmed=True) assert "UPDATING" in result - assert "Workaround" in result or "stop_project" in result + assert "redeploy_project" in result + assert "does NOT recreate" in result # ────────────────────────────────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index 5f894ef..5a63e20 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "click" },