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:
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user