Make long-running tools fire-and-return to fix intermittent timeouts

redeploy_project, create_project and pull_image used to block the tool
call while polling DSM until the project reached RUNNING / the image
appeared - up to 300s and 240s respectively. On large images this
regularly ran past Claude Desktop's ~4 min tool-call ceiling (the live
MCP log shows redeploy_project calls up to 260s), which the client
reported as a timeout even though the server kept working. Whether a
call crossed the ceiling depended on image size and NAS load, which is
why the failure was intermittent.

These tools now fire-and-return: they trigger the operation, consume
build_stream only for a short early-error window (20s, to catch fast
daemon errors like "manifest unknown"), then return a "running in the
background - check get_project_status / check_image_updates" hint.
Completion is observed via the existing fast status tools.

- DsmClient.trigger_build_stream gains a `budget` parameter
- remove _wait_for_project_running and the _POLL_*/_BUILD_POLL_TIMEOUT
  constants (projects.py) and _PULL_POLL_* constants (registry.py)
- update tests, CLAUDE.md DSM quirks and CHANGELOG; bump 0.7.0 -> 0.8.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 05:56:42 +02:00
parent 7bb9b00dcc
commit 4a49407883
9 changed files with 208 additions and 302 deletions
+32
View File
@@ -2,6 +2,38 @@
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.8.0] - 2026-06-16
### Changed (fixes intermittent "MCP not responding" timeouts)
The long-running tools no longer block the tool call until the operation
finishes. They now **fire-and-return**: trigger the work, surface any fast
failure, then return immediately so Claude can poll status with the existing
fast tools. This removes the root cause of the intermittent timeouts —
analysis of the live MCP log showed `redeploy_project` calls running up to
**260 s**, which crosses Claude Desktop's ~4 min tool-call ceiling. Whether a
call crossed it depended on image size and NAS load, which is exactly why the
failure was intermittent ("works fine for a while, then suddenly times out").
- **`redeploy_project` / `create_project`** — `build_stream` is now consumed
only for a short early-error window (`_BUILD_EARLY_BUDGET`, 20 s) to catch
fast daemon errors (e.g. `manifest unknown`). If the build is still running
after that window (large image), the tool returns a "redeploy/create
started — check `get_project_status`" hint instead of polling for up to
300 s. The pull and container start continue on the NAS.
- **`pull_image`** — calls `pull_start` and returns immediately with a
"pull started in the background — verify with `check_image_updates` /
`list_images`" hint, instead of polling `Image/list` for up to 240 s.
- **`DsmClient.trigger_build_stream`** — gained a `budget` parameter so
callers can cap how long the streamed log is consumed.
### Removed
- `_wait_for_project_running` and the `_POLL_INTERVAL` / `_POLL_TIMEOUT` /
`_BUILD_POLL_TIMEOUT` constants in `projects.py`, and the
`_PULL_POLL_TIMEOUT` / `_PULL_POLL_INTERVALS` constants in `registry.py`
the minutes-long polling loops they drove are gone.
## [0.7.0] - 2026-05-18 ## [0.7.0] - 2026-05-18
### Added ### Added
+27 -17
View File
@@ -54,22 +54,30 @@ Only a second consecutive failure is treated as a real auth problem.
`container_name`. All container tools strip this prefix transparently via `container_name`. All container tools strip this prefix transparently via
`_strip_hash_prefix` / `_resolve_container_name`. `_strip_hash_prefix` / `_resolve_container_name`.
- **Async project start** — `SYNO.Docker.Project/start` returns immediately - **Async project start** — `SYNO.Docker.Project/start` returns immediately
while containers are still initialising. `redeploy_project` polls while containers are still initialising. The long-running tools
`SYNO.Docker.Project/list` every 2 s for up to 30 s after issuing start. (`redeploy_project`, `create_project`, `pull_image`) are **fire-and-return**:
they trigger the operation, surface any fast failure, then return so the
tool call never approaches the Claude Desktop ~4 min ceiling. Completion is
observed by the model polling `get_project_status` / `check_image_updates`,
not by the tool blocking. (Blocking until RUNNING was the root cause of the
intermittent "MCP not responding" timeouts — large-image pulls regularly
ran 4+ min; see CHANGELOG 0.8.0.)
- **`SYNO.Docker.Project/build_stream`** — returns a streamed plaintext - **`SYNO.Docker.Project/build_stream`** — returns a streamed plaintext
build log (content-type `text/html`), one short line per step: build log (content-type `text/html`), one short line per step:
`Container <name> Running` on success, `<svc> Error` followed by `Container <name> Running` on success, `<svc> Error` followed by
`Error response from daemon: <cause>` on failure. The stream closes `Error response from daemon: <cause>` on failure. The stream closes
when the build is done. `DsmClient.trigger_build_stream` consumes the when the build is done. `DsmClient.trigger_build_stream(project_id,
body line-by-line with a 210 s wall-clock budget (under the Claude budget=…)` consumes the body line-by-line up to a configurable wall-clock
Desktop ~4 min ceiling) and returns the log as a string; on timeout budget and returns the log so far; if the budget elapses before the stream
the partial log is returned with a marker appended so callers know closes, the partial log is returned with `BUILD_STREAM_TIMEOUT_MARKER`
the build is still running server-side. `redeploy_project` and appended so callers know the build is still running server-side.
`create_project` grep the returned log for daemon errors and abort `redeploy_project` / `create_project` pass a **short** budget
early — these errors are much more actionable than the eventual (`_BUILD_EARLY_BUDGET`, 20 s) purely to catch fast daemon errors
`BUILD_FAILED` polling status. The log is **live-only**: it cannot (e.g. `manifest unknown`) — they grep the returned log and abort early on
be re-fetched after the build ends, which is why no standalone those, otherwise return a "build running in the background, check
`get_project_build_log` tool exists. `get_project_status`" hint. They do **not** block until RUNNING. The log is
**live-only**: it cannot be re-fetched after the build ends, which is why no
standalone `get_project_build_log` tool exists.
- **Image delete** — requires a form-encoded POST with a JSON `images` array - **Image delete** — requires a form-encoded POST with a JSON `images` array
(confirmed via browser DevTools); uses `DsmClient.post_request()`. (confirmed via browser DevTools); uses `DsmClient.post_request()`.
- **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method - **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method
@@ -80,11 +88,13 @@ Only a second consecutive failure is treated as a real auth problem.
`SYNO.Docker.Registry` — the Registry API only exposes the synchronous `SYNO.Docker.Registry` — the Registry API only exposes the synchronous
read-only methods (`search`, `tags`, `get/set/create/delete`, `using`); read-only methods (`search`, `tags`, `get/set/create/delete`, `using`);
calling `Registry/pull_start` returns "Method does not exist". No calling `Registry/pull_start` returns "Method does not exist". No
matching `pull_status` method is confirmed on either API, so completion matching `pull_status` method is confirmed on either API. `pull_image` is
is detected by polling `SYNO.Docker.Image/list` until `repository:tag` **fire-and-return**: it short-circuits if the tag is already local, calls
appears (210 s backoff, 240 s budget). Timeout returns a "still `pull_start`, then returns a "pull started in the background, verify with
running" hint instead of raising — DSM keeps pulling server-side `check_image_updates` / `list_images`" hint. DSM keeps pulling server-side
regardless of the HTTP response. regardless of the HTTP response, so the tool does not block polling
`Image/list` for the tag to appear (that loop used to run the full 240 s on
large images and trip the Claude Desktop tool-call timeout).
- **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the - **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the
parameter name; the n4s4 reference's `name` does not work on this DSM parameter name; the n4s4 reference's `name` does not work on this DSM
version. Returns the tag list as the envelope's `data` field directly, version. Returns the tag list as the envelope's `data` field directly,
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.7.0" version = "0.8.0"
description = "MCP server for Synology Container Manager" description = "MCP server for Synology Container Manager"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+13 -5
View File
@@ -431,8 +431,8 @@ class DsmClient:
# whether the log they got is complete. # whether the log they got is complete.
BUILD_STREAM_TIMEOUT_MARKER = "[build_stream: timeout — stream still open server-side]" BUILD_STREAM_TIMEOUT_MARKER = "[build_stream: timeout — stream still open server-side]"
async def trigger_build_stream(self, project_id: str) -> str: async def trigger_build_stream(self, project_id: str, *, budget: float | None = None) -> str:
"""Trigger SYNO.Docker.Project/build_stream and return the full log text. """Trigger SYNO.Docker.Project/build_stream and return the log text so far.
This is the proper way to force an image pull and project restart in DSM This is the proper way to force an image pull and project restart in DSM
Container Manager (confirmed via browser DevTools). The endpoint Container Manager (confirmed via browser DevTools). The endpoint
@@ -457,14 +457,22 @@ class DsmClient:
Args: Args:
project_id: Project UUID from SYNO.Docker.Project/list. project_id: Project UUID from SYNO.Docker.Project/list.
budget: Wall-clock seconds to keep consuming the streamed log
before giving up and returning the partial log + timeout
marker. Defaults to :attr:`BUILD_STREAM_BUDGET`. Callers
that must stay well under the MCP client's tool-call timeout
(``redeploy_project`` / ``create_project``) pass a short value
so the tool returns quickly while DSM keeps building
server-side; completion is then observed via
``get_project_status`` rather than by blocking here.
Returns: Returns:
The collected build log as a single string (may be empty if DSM The collected build log as a single string (may be empty if DSM
returned a non-JSON empty body). Appended with returned a non-JSON empty body). Appended with
:attr:`BUILD_STREAM_TIMEOUT_MARKER` when the wall-clock budget :attr:`BUILD_STREAM_TIMEOUT_MARKER` when the wall-clock budget
ran out before DSM closed the stream — in that case the build is ran out before DSM closed the stream — in that case the build is
still running server-side and the caller's polling step is the still running server-side and the caller should poll project
authoritative status source. status for the authoritative result.
Raises: Raises:
SynologyError: If DSM rejects the build with a JSON error body, or SynologyError: If DSM rejects the build with a JSON error body, or
@@ -496,7 +504,7 @@ class DsmClient:
# generous per-read timeout because DSM may pause between status lines # generous per-read timeout because DSM may pause between status lines
# during a slow image pull. # during a slow image pull.
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
deadline = loop.time() + self.BUILD_STREAM_BUDGET deadline = loop.time() + (self.BUILD_STREAM_BUDGET if budget is None else budget)
try: try:
async with http.stream( async with http.stream(
+57 -76
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib import contextlib
import json import json
import logging import logging
@@ -10,22 +9,26 @@ from typing import TYPE_CHECKING, Any
import yaml import yaml
from mcp_synology_container.dsm_client import DsmClient
if TYPE_CHECKING: if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp_synology_container.config import AppConfig from mcp_synology_container.config import AppConfig
from mcp_synology_container.dsm_client import DsmClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_POLL_INTERVAL = 2 # seconds between status checks # Short wall-clock window (seconds) for the build_stream early-error read in
_POLL_TIMEOUT = 30 # seconds for ordinary start polling # redeploy_project / create_project. We consume the stream only long enough
_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow) # to catch fast daemon errors (e.g. "manifest unknown"), then return and let
# DSM finish the pull/start in the background. Blocking until RUNNING used to
# run for minutes and intermittently exceed Claude Desktop's ~4 min tool-call
# ceiling on large images — the root cause of the "MCP not responding"
# timeouts. Completion is observed via get_project_status instead.
_BUILD_EARLY_BUDGET = 20.0
# Statuses that mean "stop polling now — this redeploy is not coming back." # Statuses that mean "this redeploy is not coming back." DSM reports these on
# DSM signals these typically within seconds of build_stream when the image # the post-build status read when the image pull or container start fails.
# pull or container start fails; without an early exit the caller would wait
# the full _BUILD_POLL_TIMEOUT for nothing.
_TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"}) _TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"})
# Substrings that mark a line in the build_stream log as a failure. Live # Substrings that mark a line in the build_stream log as a failure. Live
@@ -189,15 +192,18 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
results.append(" Stopped.") results.append(" Stopped.")
# ── Step 2: build_stream (pull images + start) ──────────────────── # ── Step 2: build_stream (pull images + start) ────────────────────
results.append("Step 2/3: Triggering image pull and project start (build_stream)...") # Consume only a short early-error window, then return. The image
build_log = await client.trigger_build_stream(project_id) # pull and container start continue on the NAS; blocking the tool
# call until RUNNING used to run for minutes and intermittently
# exceed Claude Desktop's ~4 min tool-call ceiling on large images.
# Claude polls get_project_status for completion instead.
results.append("Step 2/2: Triggering image pull and project start (build_stream)...")
build_log = await client.trigger_build_stream(project_id, budget=_BUILD_EARLY_BUDGET)
build_errors, _ = _parse_build_stream_log(build_log) build_errors, _ = _parse_build_stream_log(build_log)
if build_errors: if build_errors:
# Live build_stream reported a daemon error (e.g. manifest # Live build_stream reported a daemon error (e.g. manifest
# not found, container exited). Surface the cause now — # not found, container exited). Surface the cause now —
# no need to wait for polling to flip the project to # the daemon line is much more actionable than a bare status.
# BUILD_FAILED, and the daemon line is much more
# actionable than the bare status.
results.append(" Build failed — DSM reported:") results.append(" Build failed — DSM reported:")
results.extend(f" {line}" for line in build_errors) results.extend(f" {line}" for line in build_errors)
results.append( results.append(
@@ -210,23 +216,30 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
f"redeploy_project to recover." f"redeploy_project to recover."
) )
return "\n".join(results) return "\n".join(results)
results.append(" Build request accepted by DSM.")
# ── Step 3: Poll ────────────────────────────────────────────────── if DsmClient.BUILD_STREAM_TIMEOUT_MARKER in build_log:
# Early window elapsed with no error — a large image is still
# pulling/starting in the background. Return now; do NOT block.
results.append(" Build accepted — image pull and start running in the background.")
results.append( results.append(
f"Step 3/3: Waiting for project to reach RUNNING state " f"\nProject '{project_name}' redeploy started. The image pull and "
f"(up to {_BUILD_POLL_TIMEOUT}s)..." f"container start continue on the NAS (this can take several minutes "
) f"for large images).\n"
final_status = await _wait_for_project_running( f"Check progress with get_project_status('{project_name}') — "
client, project_name, timeout=_BUILD_POLL_TIMEOUT f"RUNNING means done, BUILD_FAILED means the image tag is wrong."
) )
return "\n".join(results)
# Stream closed within the early window → the build already
# finished server-side. Report the outcome from a single status
# read (no minutes-long polling loop).
results.append(" Build request accepted by DSM.")
final = await _find_project(client, project_name)
final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN"
if final_status == "RUNNING": if final_status == "RUNNING":
results.append(" Project is RUNNING.") results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' redeployed successfully.") results.append(f"\nProject '{project_name}' redeployed successfully.")
elif final_status in _TERMINAL_FAILURE_STATUSES: elif final_status in _TERMINAL_FAILURE_STATUSES:
# M-5: DSM signalled a hard failure during polling. The
# build_stream log above was clean, so this is a late
# failure (e.g. container exited after start).
results.append(f" Redeploy failed — project status is '{final_status}'.") results.append(f" Redeploy failed — project status is '{final_status}'.")
if final_status == "BUILD_FAILED": if final_status == "BUILD_FAILED":
results.append( results.append(
@@ -237,12 +250,11 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
f"\nProject '{project_name}' redeploy aborted (status: {final_status})." f"\nProject '{project_name}' redeploy aborted (status: {final_status})."
) )
else: else:
results.append(f" Build finished — current status: {final_status}.")
results.append( results.append(
f" Warning: project status is '{final_status}' after " f"\nProject '{project_name}' redeploy started (status: {final_status}). "
f"{_BUILD_POLL_TIMEOUT}s. " f"Check get_project_status('{project_name}')."
f"Containers may still be starting — check with get_project_status."
) )
results.append(f"\nProject '{project_name}' build issued (status: {final_status}).")
except Exception as e: except Exception as e:
results.append(f"Error during redeploy: {e}") results.append(f"Error during redeploy: {e}")
@@ -404,14 +416,23 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
f"redeploy_project('{project_name}', confirmed=True) to retry." f"redeploy_project('{project_name}', confirmed=True) to retry."
) )
return "\n".join(results) return "\n".join(results)
results.append(" Build request accepted by DSM.")
if DsmClient.BUILD_STREAM_TIMEOUT_MARKER in build_log:
# Large image still pulling/starting in the background — return now.
results.append(" Build accepted — image pull and start running in the background.")
results.append( results.append(
f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..." f"\nProject '{project_name}' created. The image pull and container "
) f"start continue on the NAS (this can take several minutes for large "
final_status = await _wait_for_project_running( f"images).\n"
client, project_name, timeout=_BUILD_POLL_TIMEOUT f"Check progress with get_project_status('{project_name}') — "
f"RUNNING means done, BUILD_FAILED means an image tag is wrong."
) )
return "\n".join(results)
# Stream closed within the early window → the build already finished.
results.append(" Build request accepted by DSM.")
final = await _find_project(client, project_name)
final_status = (final.get("status") or "UNKNOWN").upper() if final else "UNKNOWN"
if final_status == "RUNNING": if final_status == "RUNNING":
results.append(" Project is RUNNING.") results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' created and started successfully.") results.append(f"\nProject '{project_name}' created and started successfully.")
@@ -427,12 +448,11 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
f"(status: {final_status})." f"(status: {final_status})."
) )
else: else:
results.append(f" Build finished — current status: {final_status}.")
results.append( results.append(
f" Warning: project status is '{final_status}' after " f"\nProject '{project_name}' created (status: {final_status}). "
f"{_BUILD_POLL_TIMEOUT}s. " f"Check get_project_status('{project_name}')."
f"Containers may still be starting — check with get_project_status."
) )
results.append(f"\nProject '{project_name}' created (final status: {final_status}).")
return "\n".join(results) return "\n".join(results)
@@ -515,45 +535,6 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
return None return None
async def _wait_for_project_running(
client: DsmClient,
name: str,
timeout: int = _POLL_TIMEOUT,
interval: int = _POLL_INTERVAL,
) -> str:
"""Poll until the project reaches RUNNING status or timeout expires.
Args:
client: DsmClient instance.
name: Project name to watch.
timeout: Maximum seconds to wait (default 30).
interval: Seconds between polls (default 2).
Returns:
Final project status string (may not be "RUNNING" on timeout).
"""
elapsed = 0
while elapsed < timeout:
await asyncio.sleep(interval)
elapsed += interval
project = await _find_project(client, name)
if project is None:
continue
current = (project.get("status") or "").upper()
logger.debug("Polling '%s': status=%s elapsed=%ds", name, current, elapsed)
if current == "RUNNING":
return current
if current in _TERMINAL_FAILURE_STATUSES:
# DSM has reported a hard failure (e.g. image pull failed,
# container exited immediately). Returning early lets the
# caller surface the real cause instead of waiting out the
# full timeout.
return current
# Return whatever status we last saw (or UNKNOWN on repeated failures)
project = await _find_project(client, name)
return (project.get("status") or "UNKNOWN").upper() if project else "UNKNOWN"
def _format_project_detail(project: dict[str, Any]) -> str: def _format_project_detail(project: dict[str, Any]) -> str:
"""Format project details as human-readable text.""" """Format project details as human-readable text."""
lines = [ lines = [
+11 -23
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -15,11 +14,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Pull polling: backoff schedule capped at 10 s between checks. Total budget
# stays under the Claude Desktop ~4 min tool-call ceiling.
_PULL_POLL_TIMEOUT = 240.0
_PULL_POLL_INTERVALS: tuple[float, ...] = (2.0, 3.0, 5.0, 8.0, 10.0)
def _truncate(text: str, limit: int = 80) -> str: def _truncate(text: str, limit: int = 80) -> str:
"""Truncate text to limit with an ellipsis.""" """Truncate text to limit with an ellipsis."""
@@ -155,7 +149,7 @@ def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
@mcp.tool() @mcp.tool()
async def pull_image(repository: str, tag: str = "latest", confirmed: bool = False): async def pull_image(repository: str, tag: str = "latest", confirmed: bool = False):
"""Pull image from the active registry. Requires confirmed=True; polls for completion.""" """Pull image from the active registry in the background. Requires confirmed=True."""
target = f"{repository}:{tag}" target = f"{repository}:{tag}"
if not confirmed: if not confirmed:
@@ -186,21 +180,15 @@ def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
except Exception as e: except Exception as e:
return f"Error starting pull for '{target}': {e}" return f"Error starting pull for '{target}': {e}"
# Async pull — DSM exposes no confirmed pull_status method, so we poll # Fire-and-return: the pull runs asynchronously on the NAS. We do NOT
# Image/list for the new tag. Backoff schedule capped at 10 s; total # poll Image/list until the tag appears — for large images that took
# budget under the Claude Desktop ~4 min tool-call ceiling. # minutes and intermittently exceeded Claude Desktop's ~4 min tool-call
loop = asyncio.get_event_loop() # ceiling, making the whole MCP appear unresponsive. The pull continues
deadline = loop.time() + _PULL_POLL_TIMEOUT # server-side; completion is observed via check_image_updates /
attempt = 0 # list_images, which show the new tag once it finishes.
while loop.time() < deadline:
interval = _PULL_POLL_INTERVALS[min(attempt, len(_PULL_POLL_INTERVALS) - 1)]
remaining = deadline - loop.time()
await asyncio.sleep(min(interval, max(remaining, 0.0)))
if await _image_present(client, repository, tag):
return f"Pulled {target} successfully."
attempt += 1
return ( return (
f"Pull of {target} started, still running — " f"Pull of {target} started in the background on the NAS.\n"
f"verify later with list_images or check_image_updates." f"This can take a while for large images. Check completion with "
f"check_image_updates or list_images — the tag appears once the "
f"pull finishes."
) )
+31 -102
View File
@@ -1,6 +1,6 @@
"""Tests for modules/projects.py.""" """Tests for modules/projects.py."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock
import pytest import pytest
@@ -215,8 +215,8 @@ def make_stateful_redeploy_mock(
"""Create a stateful client mock for redeploy tests. """Create a stateful client mock for redeploy tests.
Returns (client, calls_list). After ``trigger_build_stream`` is called, Returns (client, calls_list). After ``trigger_build_stream`` is called,
subsequent ``list`` calls return RUNNING so the polling loop terminates subsequent ``list`` calls return RUNNING so the post-build status read sees
immediately. asyncio.sleep is NOT patched here — patch it at call-site. a finished, running project.
""" """
client = AsyncMock() client = AsyncMock()
calls = [] calls = []
@@ -230,12 +230,12 @@ def make_stateful_redeploy_mock(
return project_list("RUNNING") if build_done else project_list(initial_status) return project_list("RUNNING") if build_done else project_list(initial_status)
return {} return {}
async def mock_trigger_build_stream(project_id): async def mock_trigger_build_stream(project_id, **kwargs):
nonlocal build_done nonlocal build_done
calls.append(("SYNO.Docker.Project", "build_stream")) calls.append(("SYNO.Docker.Project", "build_stream"))
if build_stream_raises: if build_stream_raises:
raise build_stream_raises raise build_stream_raises
build_done = True # After build_stream, polling returns RUNNING build_done = True # After build_stream, the status read returns RUNNING
return build_stream_log return build_stream_log
client.request.side_effect = mock_request client.request.side_effect = mock_request
@@ -245,11 +245,10 @@ def make_stateful_redeploy_mock(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redeploy_running_project(): async def test_redeploy_running_project():
"""RUNNING project: stop → build_stream → poll until RUNNING.""" """RUNNING project: stop → build_stream → status read reports RUNNING."""
client, calls = make_stateful_redeploy_mock("RUNNING") client, calls = make_stateful_redeploy_mock("RUNNING")
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result assert "redeployed successfully" in result
@@ -261,11 +260,10 @@ async def test_redeploy_running_project():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redeploy_stopped_project_skips_stop(): async def test_redeploy_stopped_project_skips_stop():
"""STOPPED project: skip stop, call build_stream directly; polls until RUNNING.""" """STOPPED project: skip stop, call build_stream directly; status read RUNNING."""
client, calls = make_stateful_redeploy_mock("STOPPED") client, calls = make_stateful_redeploy_mock("STOPPED")
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result assert "redeployed successfully" in result
@@ -277,11 +275,10 @@ async def test_redeploy_stopped_project_skips_stop():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redeploy_build_failed_project(): async def test_redeploy_build_failed_project():
"""BUILD_FAILED project: stop (suppressed) → build_stream → poll until RUNNING.""" """BUILD_FAILED project: stop (suppressed) → build_stream → status read RUNNING."""
client, calls = make_stateful_redeploy_mock("BUILD_FAILED") client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result assert "redeployed successfully" in result
@@ -302,7 +299,6 @@ async def test_redeploy_build_failed_stop_error_nonfatal():
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result assert "redeployed successfully" in result
@@ -321,7 +317,6 @@ async def test_redeploy_build_stream_error_aborts():
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" not in result assert "redeployed successfully" not in result
@@ -333,37 +328,27 @@ async def test_redeploy_build_stream_error_aborts():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redeploy_poll_timeout(): async def test_redeploy_large_image_returns_background_hint():
"""If project never reaches RUNNING after build_stream, a warning is emitted.""" """When build_stream returns the timeout marker (early window elapsed, large
client = AsyncMock() image still pulling), redeploy returns immediately with a 'check status' hint
build_done = False and does NOT block on a polling loop."""
from mcp_synology_container.dsm_client import DsmClient
async def mock_request(api, method, **kwargs): client, calls = make_stateful_redeploy_mock(
if method == "list": "RUNNING",
# Before build: RUNNING (so initial status check is valid) build_stream_log=DsmClient.BUILD_STREAM_TIMEOUT_MARKER,
# After build: STARTING (simulate stuck containers) )
return project_list("STARTING") if build_done else project_list("RUNNING")
return {}
async def mock_build_stream(project_id):
nonlocal build_done
build_done = True
return ""
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
tools = make_projects_tools(client) tools = make_projects_tools(client)
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
with (
patch("mcp_synology_container.modules.projects.asyncio.sleep"),
patch("mcp_synology_container.modules.projects._BUILD_POLL_TIMEOUT", 1),
patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1),
):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "Warning" in result assert "redeploy started" in result
assert "get_project_status" in result
assert "redeployed successfully" not in result assert "redeployed successfully" not in result
# No post-build status read — the background branch returns immediately.
# Only the initial _find_project lookup hit Project/list.
methods = [m for _, m in calls]
assert [m for m in methods if m == "list"] == ["list"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -405,7 +390,6 @@ async def test_redeploy_build_stream_transport_error_shows_stopped_recovery_hint
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
# No raw stack trace — clean message # No raw stack trace — clean message
@@ -430,7 +414,6 @@ async def test_redeploy_build_stream_error_on_stopped_project_keeps_old_workarou
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "build failed" in result or "Error during redeploy" in result assert "build failed" in result or "Error during redeploy" in result
@@ -489,7 +472,6 @@ async def test_redeploy_surfaces_build_stream_daemon_error():
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "Build failed" in result assert "Build failed" in result
@@ -507,15 +489,14 @@ async def test_redeploy_surfaces_build_stream_daemon_error():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redeploy_clean_log_proceeds_to_polling(): async def test_redeploy_clean_log_reports_success():
"""A clean build_stream log keeps the original happy-path behavior.""" """A clean, closed build_stream log → status read RUNNING → success message."""
client, calls = make_stateful_redeploy_mock( client, calls = make_stateful_redeploy_mock(
"RUNNING", "RUNNING",
build_stream_log="Container myapp Pulling\nContainer myapp Running\n", build_stream_log="Container myapp Pulling\nContainer myapp Running\n",
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result assert "redeployed successfully" in result
@@ -533,7 +514,6 @@ async def test_create_project_surfaces_build_stream_daemon_error():
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True) result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "Build failed" in result assert "Build failed" in result
@@ -544,60 +524,14 @@ async def test_create_project_surfaces_build_stream_daemon_error():
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
# M-5: polling exits early on BUILD_FAILED / ERROR # Post-build status read surfaces BUILD_FAILED / ERROR
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wait_for_project_running_returns_early_on_build_failed():
"""_wait_for_project_running must exit as soon as DSM reports BUILD_FAILED,
not wait the full timeout."""
from mcp_synology_container.modules.projects import _wait_for_project_running
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("BUILD_FAILED")
return {}
client.request.side_effect = mock_request
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
# 100s timeout, 2s interval — if the early-exit isn't there the test
# would still terminate quickly because sleep is mocked, but the call
# count assertion below catches a non-exiting loop.
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
assert result == "BUILD_FAILED"
# Only a few list() calls — exit was on the first poll iteration.
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
assert len(list_calls) <= 2
@pytest.mark.asyncio
async def test_wait_for_project_running_returns_early_on_error():
from mcp_synology_container.modules.projects import _wait_for_project_running
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("ERROR")
return {}
client.request.side_effect = mock_request
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
assert result == "ERROR"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redeploy_surfaces_build_failed_with_hint(): async def test_redeploy_surfaces_build_failed_with_hint():
"""When polling reports BUILD_FAILED, redeploy_project must include a clear """When the post-build status read reports BUILD_FAILED, redeploy_project must
hint to inspect the image tag and retry.""" include a clear hint to inspect the image tag and retry."""
client = AsyncMock() client = AsyncMock()
build_done = False build_done = False
@@ -606,16 +540,15 @@ async def test_redeploy_surfaces_build_failed_with_hint():
return project_list("BUILD_FAILED") if build_done else project_list("RUNNING") return project_list("BUILD_FAILED") if build_done else project_list("RUNNING")
return {} return {}
async def mock_build_stream(project_id): async def mock_build_stream(project_id, **kwargs):
nonlocal build_done nonlocal build_done
build_done = True build_done = True
return "" # Clean stream log — failure surfaces via polling. return "" # Clean stream log — failure surfaces via the status read.
client.request.side_effect = mock_request client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream) client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True) result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "Redeploy failed" in result assert "Redeploy failed" in result
@@ -694,7 +627,7 @@ def make_create_project_client(
return {"id": project_id} return {"id": project_id}
return {} return {}
async def mock_build_stream(pid): async def mock_build_stream(pid, **kwargs):
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid})) calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
if build_stream_raises: if build_stream_raises:
raise build_stream_raises raise build_stream_raises
@@ -794,7 +727,6 @@ async def test_create_project_happy_path():
client, calls = make_create_project_client() client, calls = make_create_project_client()
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True) result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "created and started successfully" in result assert "created and started successfully" in result
@@ -831,7 +763,6 @@ async def test_create_project_explicit_share_path():
client, calls = make_create_project_client() client, calls = make_create_project_client()
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]( result = await tools["create_project"](
"newapp", "newapp",
SIMPLE_COMPOSE, SIMPLE_COMPOSE,
@@ -859,7 +790,6 @@ async def test_create_project_error_2100_surfaces_hint():
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True) result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "2100" in result assert "2100" in result
@@ -879,7 +809,6 @@ async def test_create_project_build_stream_failure_keeps_registration():
) )
tools = make_projects_tools(client) tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True) result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "registered but was not started" in result assert "registered but was not started" in result
+10 -52
View File
@@ -285,30 +285,15 @@ async def test_pull_image_already_present():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pull_image_confirmed_success(monkeypatch): async def test_pull_image_confirmed_starts_background():
"""pull_start succeeds, then Image/list shows the new tag on first poll.""" """pull_start is invoked, then the tool returns immediately with a background hint —
import mcp_synology_container.modules.registry as registry_mod it does NOT poll Image/list until the tag appears (fire-and-return)."""
from mcp_synology_container.modules.registry import register_registry from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
state = {"pulled": False}
async def mock_request(api, method, **kwargs): async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list": if api == "SYNO.Docker.Image" and method == "list":
if state["pulled"]: return {"images": []} # not present locally → proceed to pull
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start": if api == "SYNO.Docker.Image" and method == "pull_start":
state["pulled"] = True
return {} return {}
raise AssertionError(f"Unexpected call: {api}/{method}") raise AssertionError(f"Unexpected call: {api}/{method}")
@@ -319,11 +304,12 @@ async def test_pull_image_confirmed_success(monkeypatch):
register_registry(mcp, make_config(), client) register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True) result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Pulled" in result assert "started in the background" in result
assert "nginx:1.24" in result assert "nginx:1.24" in result
assert "check_image_updates" in result
# Verify pull_start was invoked on SYNO.Docker.Image (not Registry) # Verify pull_start was invoked on SYNO.Docker.Image (not Registry)
# with JSON-encoded params. # with JSON-encoded params, exactly once.
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"] pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
assert len(pull_calls) == 1 assert len(pull_calls) == 1
assert pull_calls[0].args[0] == "SYNO.Docker.Image" assert pull_calls[0].args[0] == "SYNO.Docker.Image"
@@ -332,37 +318,9 @@ async def test_pull_image_confirmed_success(monkeypatch):
assert json.loads(params["tag"]) == "1.24" assert json.loads(params["tag"]) == "1.24"
assert pull_calls[0].kwargs.get("version") == 1 assert pull_calls[0].kwargs.get("version") == 1
# Exactly one Image/list pre-check — no post-pull polling loop.
@pytest.mark.asyncio list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
async def test_pull_image_timeout(monkeypatch): assert len(list_calls) == 1
"""If the image never appears, the tool returns a still-running hint."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
# Shrink the budget so the loop exits quickly
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "still running" in result
assert "nginx:1.24" in result
@pytest.mark.asyncio @pytest.mark.asyncio
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]] [[package]]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.7.0" version = "0.8.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },