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
+13 -5
View File
@@ -431,8 +431,8 @@ class DsmClient:
# whether the log they got is complete.
BUILD_STREAM_TIMEOUT_MARKER = "[build_stream: timeout — stream still open server-side]"
async def trigger_build_stream(self, project_id: str) -> str:
"""Trigger SYNO.Docker.Project/build_stream and return the full log text.
async def trigger_build_stream(self, project_id: str, *, budget: float | None = None) -> str:
"""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
Container Manager (confirmed via browser DevTools). The endpoint
@@ -457,14 +457,22 @@ class DsmClient:
Args:
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:
The collected build log as a single string (may be empty if DSM
returned a non-JSON empty body). Appended with
:attr:`BUILD_STREAM_TIMEOUT_MARKER` when the wall-clock budget
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
authoritative status source.
still running server-side and the caller should poll project
status for the authoritative result.
Raises:
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
# during a slow image pull.
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:
async with http.stream(
+61 -80
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
@@ -10,22 +9,26 @@ from typing import TYPE_CHECKING, Any
import yaml
from mcp_synology_container.dsm_client import DsmClient
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
from mcp_synology_container.config import AppConfig
from mcp_synology_container.dsm_client import DsmClient
logger = logging.getLogger(__name__)
_POLL_INTERVAL = 2 # seconds between status checks
_POLL_TIMEOUT = 30 # seconds for ordinary start polling
_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow)
# Short wall-clock window (seconds) for the build_stream early-error read in
# redeploy_project / create_project. We consume the stream only long enough
# 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."
# DSM signals these typically within seconds of build_stream when the image
# pull or container start fails; without an early exit the caller would wait
# the full _BUILD_POLL_TIMEOUT for nothing.
# Statuses that mean "this redeploy is not coming back." DSM reports these on
# the post-build status read when the image pull or container start fails.
_TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"})
# 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.")
# ── Step 2: build_stream (pull images + start) ────────────────────
results.append("Step 2/3: Triggering image pull and project start (build_stream)...")
build_log = await client.trigger_build_stream(project_id)
# Consume only a short early-error window, then return. The image
# 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)
if build_errors:
# Live build_stream reported a daemon error (e.g. manifest
# not found, container exited). Surface the cause now —
# no need to wait for polling to flip the project to
# BUILD_FAILED, and the daemon line is much more
# actionable than the bare status.
# the daemon line is much more actionable than a bare status.
results.append(" Build failed — DSM reported:")
results.extend(f" {line}" for line in build_errors)
results.append(
@@ -210,23 +216,30 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
f"redeploy_project to recover."
)
return "\n".join(results)
results.append(" Build request accepted by DSM.")
# ── Step 3: Poll ──────────────────────────────────────────────────
results.append(
f"Step 3/3: Waiting for project to reach RUNNING state "
f"(up to {_BUILD_POLL_TIMEOUT}s)..."
)
final_status = await _wait_for_project_running(
client, project_name, timeout=_BUILD_POLL_TIMEOUT
)
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(
f"\nProject '{project_name}' redeploy started. The image pull and "
f"container start continue on the NAS (this can take several minutes "
f"for large images).\n"
f"Check progress with get_project_status('{project_name}') — "
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":
results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' redeployed successfully.")
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}'.")
if final_status == "BUILD_FAILED":
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})."
)
else:
results.append(f" Build finished — current status: {final_status}.")
results.append(
f" Warning: project status is '{final_status}' after "
f"{_BUILD_POLL_TIMEOUT}s. "
f"Containers may still be starting — check with get_project_status."
f"\nProject '{project_name}' redeploy started (status: {final_status}). "
f"Check get_project_status('{project_name}')."
)
results.append(f"\nProject '{project_name}' build issued (status: {final_status}).")
except Exception as 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."
)
return "\n".join(results)
results.append(" Build request accepted by DSM.")
results.append(
f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..."
)
final_status = await _wait_for_project_running(
client, project_name, timeout=_BUILD_POLL_TIMEOUT
)
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(
f"\nProject '{project_name}' created. The image pull and container "
f"start continue on the NAS (this can take several minutes for large "
f"images).\n"
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":
results.append(" Project is RUNNING.")
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})."
)
else:
results.append(f" Build finished — current status: {final_status}.")
results.append(
f" Warning: project status is '{final_status}' after "
f"{_BUILD_POLL_TIMEOUT}s. "
f"Containers may still be starting — check with get_project_status."
f"\nProject '{project_name}' created (status: {final_status}). "
f"Check get_project_status('{project_name}')."
)
results.append(f"\nProject '{project_name}' created (final status: {final_status}).")
return "\n".join(results)
@@ -515,45 +535,6 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | 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:
"""Format project details as human-readable text."""
lines = [
+11 -23
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import json
import logging
from typing import TYPE_CHECKING, Any
@@ -15,11 +14,6 @@ if TYPE_CHECKING:
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:
"""Truncate text to limit with an ellipsis."""
@@ -155,7 +149,7 @@ def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
@mcp.tool()
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}"
if not confirmed:
@@ -186,21 +180,15 @@ def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
except Exception as e:
return f"Error starting pull for '{target}': {e}"
# Async pull — DSM exposes no confirmed pull_status method, so we poll
# Image/list for the new tag. Backoff schedule capped at 10 s; total
# budget under the Claude Desktop ~4 min tool-call ceiling.
loop = asyncio.get_event_loop()
deadline = loop.time() + _PULL_POLL_TIMEOUT
attempt = 0
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
# Fire-and-return: the pull runs asynchronously on the NAS. We do NOT
# poll Image/list until the tag appears — for large images that took
# minutes and intermittently exceeded Claude Desktop's ~4 min tool-call
# ceiling, making the whole MCP appear unresponsive. The pull continues
# server-side; completion is observed via check_image_updates /
# list_images, which show the new tag once it finishes.
return (
f"Pull of {target} started, still running — "
f"verify later with list_images or check_image_updates."
f"Pull of {target} started in the background on the NAS.\n"
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."
)