diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c1dfd..563b99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 6810846..16c2628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.2.5" +version = "0.2.6" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/dsm_client.py b/src/mcp_synology_container/dsm_client.py index aa0525b..dee055f 100644 --- a/src/mcp_synology_container/dsm_client.py +++ b/src/mcp_synology_container/dsm_client.py @@ -11,7 +11,6 @@ Thin async client wrapping Synology DSM Web API conventions: from __future__ import annotations import asyncio -import json as _json import logging import sys 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 Container Manager (confirmed via browser DevTools). The endpoint is a - Server-Sent Events (SSE) stream that reports build/pull progress; we read - enough of it to confirm DSM accepted the request, then close the HTTP - connection. The build (image pull + container start) continues server-side - regardless of whether the connection stays open. Callers should poll - SYNO.Docker.Project/list for the resulting RUNNING status. + Server-Sent Events (SSE) stream; we send the request and close immediately + without reading any of the response body. DSM starts the build upon + receiving the request and continues server-side regardless of whether the + HTTP connection stays open. Callers should poll SYNO.Docker.Project/list + 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: project_id: Project UUID from SYNO.Docker.Project/list. Raises: - SynologyError: If DSM returns an immediate JSON error response. - httpx.HTTPStatusError: If the HTTP request itself fails. + httpx.HTTPStatusError: If the HTTP response status indicates an error. """ await self._ensure_initialized() http = self._get_http() @@ -399,8 +403,9 @@ class DsmClient: sys.stderr.flush() logger.debug("build_stream: project_id=%s", project_id) - # Short read timeout so we return quickly once DSM starts streaming. - # The build continues server-side after this connection closes. + # Fire-and-forget: open the stream, check HTTP status, close immediately. + # 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: async with http.stream( "GET", @@ -409,20 +414,10 @@ class DsmClient: timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0), ) as resp: resp.raise_for_status() - content_type = resp.headers.get("content-type", "") - 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 + # Body intentionally not read. Close context → connection closes. 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 async def upload_text(