Files
mcp-synology-container/CLAUDE.md
T
marcus 46b36f6b08 Harden DsmClient against a slow/stalled NAS so no tool can hang
A second, independent failure mode behind the intermittent "MCP not
responding" reports: every tool shares one HTTP client with generous
timeouts (FileStation download/upload used 60s) and no retry. When DSM
briefly stalled (FileStation busy, NAS under load) a single call blocked
for up to a minute, and follow-up calls - even read-only ones like
read_compose - stalled with it, so the whole MCP appeared dead. The path
to the NAS is direct LAN (dsm.gecheckt.de -> 192.168.0.2), so this is DSM
responsiveness, not a network/NAT-loopback issue.

Route every DSM round-trip through a new DsmClient._send that adds:

- a hard wall-clock ceiling (asyncio.wait_for, HARD_CALL_TIMEOUT=25s) so a
  call returns even if DSM accepts a request but never responds;
- a single retry on a fresh connection for transient transport failures -
  always for connection-establishment errors (request provably never
  reached DSM, safe even for POST), and for GETs also on read/protocol
  errors (also absorbs the keepalive race where DSM drops an idle conn);
- a clean SynologyError ("NAS did not respond, it may be busy") instead of
  a raw transport traceback or a long silent hang.

Also tighten timeouts: default read 30s->15s, FileStation 60s->20s,
build_stream per-read 60s->20s. build_stream stays out of _send (it
streams and manages its own budget).

- add 6 tests for retry/no-retry/ceiling behaviour (320 pass)
- update CLAUDE.md and CHANGELOG; bump 0.8.0 -> 0.9.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:58:34 +02:00

168 lines
8.8 KiB
Markdown

# mcp-synology-container
## Project
`mcp-synology-container` is an MCP server for managing Docker projects on a
Synology DiskStation via Container Manager. It exposes tools for projects,
containers, images, compose files, networks, and system housekeeping.
---
## Tech stack
| | |
|---|---|
| **Language** | Python 3.12+, `uv` |
| **Key deps** | MCP SDK, `httpx`, `keyring`, `click`, `rich` |
| **Compose paths** | `/volume1/docker/<project>/` (default Synology layout) |
---
## Deploy workflow (after every code change)
```
1. Claude Code commits and pushes
2. uv tool install --reinstall git+<repo-url>
3. Restart Claude Desktop (tray icon → Quit → relaunch)
```
**Push retry:** the Gitea remote (`gitea.gecheckt.de`) occasionally
returns `Unauthorized` on the first push attempt. If `git push` fails
with an auth error, wait 1 s and retry once before reporting back.
Only a second consecutive failure is treated as a real auth problem.
---
## Implemented tools (35)
| Category | Tools |
|---|---|
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` |
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `inspect_container`, `delete_container`, `start_container`, `stop_container`, `restart_container` |
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
| Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` |
| Registry | `search_registry`, `list_image_tags`, `pull_image` |
| Networks | `list_networks`, `create_network`, `delete_network` |
| System | `system_df`, `system_prune`, `system_overview` |
---
## DSM API quirks
- **Hash-prefixed container names** — DSM sometimes returns names like
`a1b2c3d4e5f6_myservice` when the compose service name differs from
`container_name`. All container tools strip this prefix transparently via
`_strip_hash_prefix` / `_resolve_container_name`.
- **Async project start** — `SYNO.Docker.Project/start` returns immediately
while containers are still initialising. The long-running tools
(`redeploy_project`, `create_project`, `pull_image`) are **fire-and-return**:
they trigger the operation, surface any fast failure, then return so the
tool call never approaches the Claude Desktop ~4 min ceiling. Completion is
observed by the model polling `get_project_status` / `check_image_updates`,
not by the tool blocking. (Blocking until RUNNING was the root cause of the
intermittent "MCP not responding" timeouts — large-image pulls regularly
ran 4+ min; see CHANGELOG 0.8.0.)
- **`SYNO.Docker.Project/build_stream`** — returns a streamed plaintext
build log (content-type `text/html`), one short line per step:
`Container <name> Running` on success, `<svc> Error` followed by
`Error response from daemon: <cause>` on failure. The stream closes
when the build is done. `DsmClient.trigger_build_stream(project_id,
budget=…)` consumes the body line-by-line up to a configurable wall-clock
budget and returns the log so far; if the budget elapses before the stream
closes, the partial log is returned with `BUILD_STREAM_TIMEOUT_MARKER`
appended so callers know the build is still running server-side.
`redeploy_project` / `create_project` pass a **short** budget
(`_BUILD_EARLY_BUDGET`, 20 s) purely to catch fast daemon errors
(e.g. `manifest unknown`) — they grep the returned log and abort early on
those, otherwise return a "build running in the background, check
`get_project_status`" hint. They do **not** block until RUNNING. The log is
**live-only**: it cannot be re-fetched after the build ends, which is why no
standalone `get_project_build_log` tool exists.
- **Image delete** — requires a form-encoded POST with a JSON `images` array
(confirmed via browser DevTools); uses `DsmClient.post_request()`.
- **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method
exists but behaviour varies by DSM version; not exposed as a standalone
tool. `pull_image` uses `SYNO.Docker.Image/pull_start` (asynchronous
pull entry point) with both `repository` and `tag` JSON-encoded. Note
that `pull_start` lives on **`SYNO.Docker.Image`**, NOT on
`SYNO.Docker.Registry` — the Registry API only exposes the synchronous
read-only methods (`search`, `tags`, `get/set/create/delete`, `using`);
calling `Registry/pull_start` returns "Method does not exist". No
matching `pull_status` method is confirmed on either API. `pull_image` is
**fire-and-return**: it short-circuits if the tag is already local, calls
`pull_start`, then returns a "pull started in the background, verify with
`check_image_updates` / `list_images`" hint. DSM keeps pulling server-side
regardless of the HTTP response, so the tool does not block polling
`Image/list` for the tag to appear (that loop used to run the full 240 s on
large images and trip the Claude Desktop tool-call timeout).
- **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the
parameter name; the n4s4 reference's `name` does not work on this DSM
version. Returns the tag list as the envelope's `data` field directly,
not wrapped in a sub-key.
- **`SYNO.Docker.Volume`** — endpoint does not exist; volume management is
not available via the DSM WebAPI.
- **`SYNO.Docker.Registry/get`** — does not behave as documented; registry
listing omitted.
- **`SYNO.Docker.Container/pause` and `/unpause`** — not implemented in
DSM Container Manager on this firmware. The action menu only offers
start/stop/force-stop/restart/reset; calls to `pause`/`unpause` return
"Method does not exist". `pause_container` and `unpause_container`
were briefly shipped in 0.4.0 and removed in 0.4.1.
- **`SYNO.Docker.Container/get` response — `profile.volume_bindings[].host_volume_file`
is share-relative, not the full host path.** Live capture against a
container with bind mount `/volume1/docker/homeassistant:/config`
returned `host_volume_file = "/docker/homeassistant"` (21 chars,
share-relative) in `profile`, while `details.Mounts[].Source` carried
the full `/volume1/docker/homeassistant` and `details.HostConfig.Binds`
the full `/volume1/docker/homeassistant:/config:rw`. For
Compose-rebuild use cases the full path is required — `inspect_container`
reads mount sources from `details.Mounts[].Source`, not from
`profile.volume_bindings[].host_volume_file`. The DSM action `inspect`
(no `get`) does not exist (code 103 "Method does not exist"); use `get`.
---
## Implementation rules
- Confirmation required before destructive operations: `stop_project`,
`redeploy_project`, `create_project`, `delete_project`,
`exec_in_container`, `update_image_tag`, `update_env_var`,
`update_compose`, `delete_container`, `stop_container`,
`restart_container`, `pull_image`
- After compose changes: suggest `redeploy_project`
- **Transport resilience (DsmClient):** every DSM round-trip goes through
`DsmClient._send`, which enforces a hard wall-clock ceiling
(`HARD_CALL_TIMEOUT`, 25 s) via `asyncio.wait_for` and retries once on a
fresh connection for transient transport failures (always for
connection-establishment errors; for GETs also on read/protocol errors).
This is what stops a slow/stalled NAS from hanging a tool — including
read-only ones — for minutes. Read timeouts are deliberately tight (15 s
general, 20 s FileStation/build_stream). Keep `build_stream` out of `_send`:
it streams and manages its own budget. Don't widen these timeouts without a
reason — they are the guardrail against the "MCP not responding" hangs.
- DSM errors → human-readable message, no stack traces
- No secrets in stderr output
- Type hints and docstrings everywhere
- Formatter: `ruff format` · Linter: `ruff check` · Tests: `pytest`
- All text (docstrings, comments, README): English
- **CHANGELOG.md:** every user-visible change (bug fix, new/changed
tool, behavior change, security fix, dependency bump) gets a
`CHANGELOG.md` entry in the same commit — under a `## [Unreleased]`
heading between releases, which becomes `## [X.Y.Z] - YYYY-MM-DD` on
version bump. Pure internal cleanup (renames without external callers,
comment-only edits, ruff autofix) needs no entry. Don't ship a release
with a stale changelog (this was the C-2 gap that caused 0.2.7 and
0.2.8 to ship undocumented).
- **Version consistency:** the package version lives in `pyproject.toml`
and must stay in sync with `uv.lock` and the `[X.Y.Z]` heading in
`CHANGELOG.md`. `src/mcp_synology_container/__init__.py` derives
`__version__` from `importlib.metadata` and is never hand-edited.
Every version bump touches all three files in the same commit.
---
## DSM API reference
- `cmeans/mcp-synology` (GitHub) — auth, keyring, CLI structure
- `N4S4/synology-api` `docker_api.py` (GitHub) — `SYNO.Docker.*` calls