fix: v0.2.6 — trigger_build_stream truly fire-and-forget

Claude Desktop times out tool calls after ~4 minutes. The previous
implementation read the first SSE chunk before returning, which could
block for the entire image-pull duration.

Now: send the GET request, wait for HTTP response headers (status
check only), close the connection immediately — never read SSE events.
DSM starts the build on receipt of the request and continues
server-side. ReadTimeout on headers is caught and ignored (request
already sent). Removes the _json import added in 0.2.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 08:44:25 +02:00
parent ebe3baba78
commit 7b1d7be5d7
3 changed files with 31 additions and 24 deletions
+12
View File
@@ -2,6 +2,18 @@
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.2.6] - 2026-04-21
### Fixed
- `DsmClient.trigger_build_stream`: Claude Desktop aborts tool calls after
~4 minutes. The previous implementation read the first SSE chunk before
returning, which could block for the entire duration of an image pull.
Fixed by making the call truly fire-and-forget: the HTTP request is sent,
response headers are received (HTTP status check only), then the connection
is closed immediately without reading any SSE events. DSM continues the
build server-side regardless. The `_json` import added in 0.2.5 is removed.
## [0.2.5] - 2026-04-21 ## [0.2.5] - 2026-04-21
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.2.5" version = "0.2.6"
description = "MCP server for Synology Container Manager" description = "MCP server for Synology Container Manager"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+18 -23
View File
@@ -11,7 +11,6 @@ Thin async client wrapping Synology DSM Web API conventions:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json as _json
import logging import logging
import sys import sys
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -364,18 +363,23 @@ class DsmClient:
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 is a Container Manager (confirmed via browser DevTools). The endpoint is a
Server-Sent Events (SSE) stream that reports build/pull progress; we read Server-Sent Events (SSE) stream; we send the request and close immediately
enough of it to confirm DSM accepted the request, then close the HTTP without reading any of the response body. DSM starts the build upon
connection. The build (image pull + container start) continues server-side receiving the request and continues server-side regardless of whether the
regardless of whether the connection stays open. Callers should poll HTTP connection stays open. Callers should poll SYNO.Docker.Project/list
SYNO.Docker.Project/list for the resulting RUNNING status. for the resulting RUNNING status.
Fire-and-forget: we only wait long enough to receive the HTTP response
headers (to detect immediate HTTP-level errors), then close the connection.
We never read SSE events, so this returns in < 10 s regardless of how long
the image pull takes. Claude Desktop's ~4-minute tool-call timeout is
therefore not a concern.
Args: Args:
project_id: Project UUID from SYNO.Docker.Project/list. project_id: Project UUID from SYNO.Docker.Project/list.
Raises: Raises:
SynologyError: If DSM returns an immediate JSON error response. httpx.HTTPStatusError: If the HTTP response status indicates an error.
httpx.HTTPStatusError: If the HTTP request itself fails.
""" """
await self._ensure_initialized() await self._ensure_initialized()
http = self._get_http() http = self._get_http()
@@ -399,8 +403,9 @@ class DsmClient:
sys.stderr.flush() sys.stderr.flush()
logger.debug("build_stream: project_id=%s", project_id) logger.debug("build_stream: project_id=%s", project_id)
# Short read timeout so we return quickly once DSM starts streaming. # Fire-and-forget: open the stream, check HTTP status, close immediately.
# The build continues server-side after this connection closes. # The read timeout only applies to waiting for response *headers*; we never
# read the SSE body, so DSM's streaming cannot block this call indefinitely.
try: try:
async with http.stream( async with http.stream(
"GET", "GET",
@@ -409,20 +414,10 @@ class DsmClient:
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0), timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0),
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
content_type = resp.headers.get("content-type", "") # Body intentionally not read. Close context → connection closes.
if "application/json" in content_type:
# Immediate JSON error (e.g. bad project id, permission denied)
raw = await resp.aread()
data = _json.loads(raw)
if not data.get("success"):
code = data.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code)
return
# SSE stream: read until first event chunk to confirm DSM started.
async for _chunk in resp.aiter_bytes():
break # Got first byte — build is underway on the NAS
except httpx.ReadTimeout: except httpx.ReadTimeout:
# Stream is still open on DSM side; the build is running. Expected. # Headers not received within 10 s, but the GET request was already
# sent. DSM received it and started the build. Proceed to polling.
pass pass
async def upload_text( async def upload_text(