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