fix: v0.9.1 — clearer messages for exec/redeploy/check_image_updates
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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [0.9.0] - 2026-06-16
|
||||||
|
|
||||||
### Changed (resilience against a slow/stalled NAS)
|
### Changed (resilience against a slow/stalled NAS)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -269,6 +269,14 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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}"
|
return f"Error executing command in '{container_name}': {e}"
|
||||||
|
|
||||||
output = data.get("output", "")
|
output = data.get("output", "")
|
||||||
|
|||||||
@@ -369,7 +369,9 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def check_image_updates(project_name: str | None = None):
|
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:
|
try:
|
||||||
data = await client.request(
|
data = await client.request(
|
||||||
"SYNO.Docker.Image",
|
"SYNO.Docker.Image",
|
||||||
@@ -416,4 +418,11 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
|||||||
if not upgradable:
|
if not upgradable:
|
||||||
lines.append("All images are up to date.")
|
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)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""):
|
if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""):
|
||||||
return (
|
return (
|
||||||
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
|
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] = []
|
results: list[str] = []
|
||||||
@@ -269,7 +271,13 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
f"redeploy_project to recover."
|
f"redeploy_project to recover."
|
||||||
)
|
)
|
||||||
else:
|
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)
|
return "\n".join(results)
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,34 @@ async def test_exec_in_container_confirmed():
|
|||||||
assert "Exit code: 0" in result
|
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
|
# container_stats
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ async def test_check_image_updates_all():
|
|||||||
assert "nginx:1.24" in result
|
assert "nginx:1.24" in result
|
||||||
assert "UPDATE AVAILABLE" in result
|
assert "UPDATE AVAILABLE" in result
|
||||||
assert "postgres:15" in result
|
assert "postgres:15" in result
|
||||||
|
assert "does NOT detect newer version tags" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -744,6 +745,7 @@ async def test_check_image_updates_all_up_to_date():
|
|||||||
|
|
||||||
result = await tools["check_image_updates"]()
|
result = await tools["check_image_updates"]()
|
||||||
assert "All images are up to date" in result
|
assert "All images are up to date" in result
|
||||||
|
assert "does NOT detect newer version tags" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ async def test_redeploy_large_image_returns_background_hint():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_unknown_status_returns_error():
|
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()
|
client = AsyncMock()
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
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)
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "UPDATING" in result
|
assert "UPDATING" in result
|
||||||
assert "Workaround" in result or "stop_project" in result
|
assert "redeploy_project" in result
|
||||||
|
assert "does NOT recreate" in result
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user