Compare commits

...

20 Commits

Author SHA1 Message Date
marcus 7bb9b00dcc feat: v0.7.0 — inspect_container (full-path mount source)
New tool inspect_container surfaces the full configuration of a single
container as the foundation for a future GUI-container → Compose
migration workflow. Output covers image, status, restart policy,
network mode + per-network IPs, port bindings, volume mounts, env
vars, labels, entrypoint/command, links, and capabilities.

Mount paths come from details.Mounts[].Source (full /volume1/...
path), NOT from profile.volume_bindings[].host_volume_file — the
latter is share-relative (e.g. /docker/foo for /volume1/docker/foo)
and not directly Compose-usable. Verified live against the NAS;
quirk documented in CLAUDE.md.

DSM API: SYNO.Docker.Container/get with name JSON-encoded (action
inspect does not exist and returns code 103). Hash-prefixed names
are resolved transparently, matching the convention of the other
container tools.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:59:51 +02:00
marcus 036429e9bf feat: v0.6.0 — read build_stream log instead of dropping it (#2)
DSM emits a readable plaintext build log over the build_stream HTTP
body (one short status line per step) and closes the connection when
the build is done. The 0.2.5 implementation sent the request and
dropped the body unread, leaving users with nothing more than a
BUILD_FAILED polling status and no actionable diagnostic.

DsmClient.trigger_build_stream now consumes the body line-by-line and
returns the collected log as a string. Wall-clock budget of 210 s
(under the Claude Desktop ~4 min ceiling); on timeout the partial log
is returned with a "[build_stream: timeout — stream still open
server-side]" marker so callers know the build continues server-side.
Per-chunk ReadTimeout is treated the same way. JSON error envelope,
transport-error mapping (M-4), and SID-scrubbed HTTP-error formatting
are unchanged.

redeploy_project and create_project now parse the returned log via
_parse_build_stream_log (any line containing "Error response from
daemon:" or ending in " Error" counts as a failure). On a failed log
the tools abort immediately, surface the daemon line(s) in the result
(e.g. "Error response from daemon: manifest for nginx:9.9.9 not
found: manifest unknown"), and skip the polling step. The BUILD_FAILED
polling guard (M-5) stays as a second safety net for late failures
where the stream was clean but the container exited after start.

No new MCP tool: the build log is a live stream and cannot be
re-fetched after the build ends, so it is surfaced during
redeploy_project / create_project rather than exposed as a standalone
get_project_build_log call.

Minor version bump because redeploy_project and create_project return
materially different strings on a failed build and exit earlier in
the failure path. Signatures unchanged.

Tests: streamed-log collection, daemon-error log, header ReadTimeout
marker, per-chunk ReadTimeout partial log, wall-clock budget
truncation, _parse_build_stream_log unit tests, redeploy/create end-
to-end behavior with a failing log.

Closes #2

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:58:55 +02:00
marcus 18fe063691 fix: v0.5.1 — pull_image API (Image/pull_start, not Registry/pull_start)
The 0.5.0 prompt mis-attributed the API: pull_start lives on
SYNO.Docker.Image, not SYNO.Docker.Registry (live DSM capture).
search and tags ARE correctly on Registry; only pull_start belongs
to Image. Registry/pull_start returns "Method does not exist".

Parameters are unchanged (repository + tag both JSON-encoded), and
the Image/list polling for completion detection is untouched.

Tests updated to assert SYNO.Docker.Image/pull_start. CLAUDE.md
DSM-quirks section consolidates the Image vs. Registry split so
this trap is documented for future surface additions. References
#3 (already closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:32:13 +02:00
marcus f27a5456f6 feat: v0.5.0 — welle B Teil 1 (registry tools: search, tags, pull)
Three new SYNO.Docker.Registry tools, reverse-engineered from a live
DSM API capture (n4s4 reference disagrees on param names and methods).

- search_registry (#5): SYNO.Docker.Registry/search v1 with JSON-encoded
  q, plus offset/limit/page_size. Renders stars, downloads, official
  flag, truncated description, and total match count. Read-only.
- list_image_tags: SYNO.Docker.Registry/tags v1 with JSON-encoded repo
  (not name — DSM live capture diverges from n4s4). Response shape is
  unusual: tag list comes back as the envelope's data field directly.
  Output capped by limit (default 50); accepts both list and dict
  response shapes defensively. Read-only.
- pull_image (#3): SYNO.Docker.Registry/pull_start v1 with both
  repository and tag JSON-encoded. Async pull — no pull_status method
  confirmed on this DSM, so completion is detected by polling
  SYNO.Docker.Image/list (2–10 s backoff, 240 s budget under the
  Claude Desktop ~4 min tool-call ceiling). Timeout returns a
  non-fatal "still running" hint. Short-circuits when the image is
  already present locally. Confirmation gate required.

Tool count: 31 → 34. CLAUDE.md confirmation list updated. New DSM
quirks documented for pull_start (no pull_status) and tags (repo
param name, top-level data array).

Closes #3
Closes #5

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:25:59 +02:00
marcus 82e8167f67 fix: v0.4.3 — inspect_image rendering polish (follow-up to #4)
Three live-NAS observations that the 0.4.2 implementation got wrong:

1. Header showed "?:?" — DSM Image/get returns image="" and tag=""
   for name:tag lookups; the response fields are unreliable. The
   header now echoes the user-supplied image_id, falling back to the
   sha256 id only when image_id itself is empty.

2. Ports printed as raw Python dicts ({'port':'22','protocol':'tcp'}).
   The ports array is a list of {port, protocol} objects — each entry
   now renders as "22/tcp".

3. Environment printed as raw Python dicts ({'key':'PATH','value':...}).
   The env array is a list of {key, value} objects — each entry now
   renders as "PATH=/usr/local/...".

cmd, entrypoint, and volumes (plain string arrays) were already
correct and are untouched. Tests updated to match the live shape;
two new tests guard the header fallback. References #4 (already
closed by 0.4.2 — no need to reopen for a rendering polish).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:13:42 +02:00
marcus 4030b8d5ee fix: v0.4.2 — inspect_image used wrong DSM parameter contract
Live DSM API capture revealed the actual SYNO.Docker.Image/get
contract: a single JSON-encoded parameter named `identity` that
accepts both `name:tag` and `sha256:<hash>` forms. The 0.4.0 code
passed `name` + `tag` + `id` and was rejected by DSM with error 114.

Response shape also corrected — the endpoint returns flat top-level
fields (image, tag, id, digest, size, virtual_size, author,
docker_version, cmd, entrypoint, env, ports, volumes), NOT the
Docker-engine inspect shape with details.Config.* + RootFS.Layers
that the previous implementation assumed. Layer rendering removed;
digest / author / docker_version / volumes are now displayed.

Pre-resolution against list_images is no longer needed — the user
input goes straight into `identity` JSON-encoded.

Closes #4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:07:53 +02:00
marcus 24b97338ba fix: v0.4.1 — remove pause_container / unpause_container (DSM unsupported)
Live test on this DSM firmware: SYNO.Docker.Container has no pause/
unpause method ("Method does not exist"). The Container Manager GUI
action menu only exposes Start / Stop / Force-Stop / Restart / Reset —
pause/resume simply isn't a feature here.

The two tools were briefly shipped in 0.4.0 (implemented by symmetry
with the verified stop call) and have now been removed rather than
left as a broken surface. The remaining lifecycle tools
(start_container, stop_container, restart_container) are unaffected.

Tool count: 33 → 31. Closes #7 (won't fix — DSM limitation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:52:52 +02:00
marcus 12d532da7b feat: v0.4.0 — welle A (8 new tools: container lifecycle, inspect_image, system_overview)
Closes #1, #4, #6, #7.

Container lifecycle (#1, #7):
- start_container, stop_container, restart_container, pause_container,
  unpause_container — all via SYNO.Docker.Container with JSON-encoded
  name parameter, routed through _resolve_container_name for hash-
  prefix resolution. stop is live-verified; the other four are
  implemented by symmetry on the same API surface.

inspect_image (#4):
- Returns full image detail (layers, env, ports, entrypoint/cmd,
  labels) via SYNO.Docker.Image/get. Accepts name:tag, registry-
  prefixed names, and bare hashes. Defensive response parsing
  handles both wrapped (details.*) and flat envelopes.

system_overview (#6):
- Aggregates CPU %, RAM, network and block I/O across all running
  containers plus running/stopped counts. No new DSM endpoint —
  composed from list + stats, reusing the container_stats CPU
  formula. Per-source errors are non-fatal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:40:11 +02:00
marcus 8adcf93b6a fix: v0.3.3 — delete_container params (error 114) + delete_project orphan guard
Bug 1 — delete_container (DSM error 114):
SYNO.Docker.Container/delete requires three parameters: name
(JSON-encoded), force=false, and preserve_profile=false. Previously
only a bare `name` string was sent, causing DSM to reject the call
with error 114. Added the two missing fields and JSON-encode name to
match the DSM convention. The connector-side running-container guard
is unchanged; force stays hard-coded to false.

Bug 2 — delete_project orphan containers:
Production test revealed that DSM does NOT reject Project/delete on a
running project — it silently removes the registration and leaves the
containers running without any project context. The previous
implementation tried to handle this via a caught SynologyError that
never actually fires. Fix: check the project status from _find_project
connector-side before issuing any DSM call; if RUNNING, return an
error pointing at stop_project. The delete request is never sent for
a running project.

The corresponding unit test (test_delete_project_running_returns_stop_hint)
was a false positive — it mocked a DSM rejection that real DSM never
produces. Replaced with test_delete_project_running_blocked_connector_side
which asserts that client.request("delete") is never called when the
project is RUNNING.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:38:36 +02:00
marcus 3f73ed0aef feat: v0.3.2 — delete_project tool
Closes the project lifecycle (create → start/stop/redeploy → delete).
The tool calls SYNO.Docker.Project/delete with the UUID JSON-encoded
as the `id` parameter (per DSM convention) and removes only the
Container Manager registration — the project folder and compose
file remain on the NAS. This mirrors DSM's own "Delete project"
behaviour, not a bug; the success message states the folder was
preserved so the user is not surprised.

Safety:
- Project-name validation runs before any I/O.
- A `_find_project` pre-flight returns "not found" with a clear
  message rather than letting DSM reject an unknown UUID.
- No automatic stop. If the project is RUNNING and DSM rejects
  the delete, the response tells the user to `stop_project` first
  rather than silently halting containers under the guise of a
  "delete" call.
- Requires confirmed=True; preview shows name, UUID, status, full
  path, and share path so the user can verify before deleting.

Tests cover preview-only, not-found, invalid-name, happy path
(verifies the UUID is JSON-encoded in the delete call), and the
running-project rejection path that surfaces the stop_project hint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:29:09 +02:00
marcus 801dbe15dc feat: v0.3.1 — create_project tool
Adds `create_project` for registering a new Container Manager project
from a compose YAML string. Three-step flow that mirrors the DSM
"Create Project" wizard:

  1. SYNO.FileStation.CreateFolder with force_parent=true (idempotent
     — does not fail if the folder already exists, and creates missing
     intermediate directories). Without this step, Docker.Project/create
     fails with DSM error 2100.
  2. SYNO.Docker.Project/create (form-encoded POST; JSON-encoded string
     parameters per DSM convention) returns the new project UUID.
  3. trigger_build_stream + _wait_for_project_running, reusing the
     existing image-pull / start / poll machinery (including the
     BUILD_FAILED early-exit from welle 2).

Safety:
- Project-name validation (Welle-1 regex) runs before any I/O.
- Compose content is YAML-parsed and must contain a top-level
  `services` key before any side effects.
- A pre-flight list_projects check rejects duplicate names with a
  clear message rather than leaving an orphaned folder on the NAS.
- share_path defaults to compose_base_path + project_name (e.g.
  /volume1/docker + myapp → /docker/myapp); a caller-supplied value
  overrides it.
- Requires confirmed=True; the preview shows the resolved share path
  and the service count parsed from the compose content.
- DSM error 2100 surfaces as "target folder issue" with the attempted
  path. A build_stream failure after a successful Project/create tells
  the user the project is registered-but-not-started and points at
  redeploy_project for recovery.

Tests cover preview-only, already-exists, happy path (with parameter
JSON-encoding assertions), explicit share_path, malformed YAML,
missing services key, invalid project name, error 2100, and
build_stream failure after registration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:13:18 +02:00
marcus 13e10fa52f feat: v0.3.0 — review welle 2 (M-4, M-5, M-6)
Three resilience and honesty fixes from the v0.2.8 review. Minor
version bump because redeploy_project and system_prune return
different strings.

M-4: trigger_build_stream now converts every non-ReadTimeout
httpx.HTTPError (ConnectError, ConnectTimeout, WriteError,
RemoteProtocolError, ...) into a SynologyError with a clear
message. Previously only ReadTimeout was handled; everything else
propagated as a raw httpx exception. redeploy_project now tracks
whether stop was actually issued and, when build_stream fails after
a successful stop, tells the user the project is in STOPPED state
and recommends start_project / retry rather than the misleading
"use stop + start separately" workaround.

M-5: _wait_for_project_running exits early on BUILD_FAILED / ERROR
(new _TERMINAL_FAILURE_STATUSES frozenset). DSM signals these
statuses within seconds of a failed image pull; the old polling
loop kept waiting up to 5 minutes for RUNNING. redeploy_project
now surfaces the terminal status with a BUILD_FAILED-specific hint
to update_image_tag.

M-6: system_prune preview now enumerates user-created networks
that have no containers attached (excluding the three built-in
networks bridge/host/none, which Docker never prunes). Previously
the preview noted "Unused networks: (not counted)" even though
SYNO.Docker.Utils/prune does delete them — users could lose
networks they had not been warned about.

Tests:
- 2 new dsm_client tests: ConnectError and RemoteProtocolError
  both raise SynologyError, not raw httpx exceptions.
- 2 new project tests: recovery hint after stop+build_stream
  failure (RUNNING case); old workaround retained for the
  STOPPED case where no stop was issued.
- 3 new polling tests: BUILD_FAILED and ERROR each trigger early
  exit; redeploy_project surfaces BUILD_FAILED with update_image_tag
  hint.
- 2 new system_prune preview tests: counts unused networks
  correctly, excludes built-ins; network-fetch failure is non-fatal.

245 tests pass. ruff check + ruff format clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:57:20 +02:00
marcus 6ba4c7ca92 chore: ruff cleanup — fix 7 long-standing lint findings
Mechanical, no behavior change. `ruff check src/ tests/` now passes
with zero findings.

- cli.py:147 (SIM105) — replace `try/except SynologyError/pass` around
  the cleanup logout with `contextlib.suppress(SynologyError)`.
- compose.py:271 (B007) — drop the unused `i` from the env_list
  preview-detection loop (the apply loop below still uses enumerate).
- compose.py:329 (E501) — extract `verb = "Updated" if … else "Added"`
  into a local before the return so the f-string fits in 100 cols.
- images.py:237 (E501) — extract `stopped_name = in_use_stopped[0]`
  before the return and split the message across two f-strings.
- test_auth.py:38, 127, 140 (SIM117) — combine nested `with patch(…):`
  / `with pytest.raises(…):` into single parenthesised with-statements.

236 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:15:50 +02:00
marcus 8878eda0b2 docs: CLAUDE.md — codify changelog-update rule
Add an explicit rule that every user-visible change updates
CHANGELOG.md in the same commit, under an `## [Unreleased]` heading
between releases. References the C-2 gap (0.2.7 and 0.2.8 shipped
without changelog entries) so the motivation is durable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:11:40 +02:00
marcus 4b8b1a0a6e docs: CLAUDE.md — push-retry rule and version-consistency invariant
- Document that git push to gitea.gecheckt.de occasionally returns
  Unauthorized on the first attempt and must be retried once after a
  1 s wait before reporting an auth failure.
- Codify the version-consistency invariant: pyproject.toml, uv.lock,
  and the latest CHANGELOG heading must move together;
  src/mcp_synology_container/__init__.py reads __version__ from
  importlib.metadata and is never hand-edited.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:09:30 +02:00
marcus 661460bfd9 fix: v0.2.9 — review welle 1 (C-1, C-2, M-3)
C-1: __version__ now derived from package metadata via
importlib.metadata.version() so pyproject.toml is the single source of
truth. Previously stuck at "0.1.0" since the initial release.

C-2: Backfill CHANGELOG entries for 0.2.7 and 0.2.8 (both releases had
shipped without changelog updates) and add a 0.2.9 entry covering this
welle.

M-3: Reject project names containing path separators or other unsafe
characters before they reach _find_compose_path. Previously a name like
"../../etc" could traverse out of compose_base_path when the project was
not yet registered with Container Manager. Adds _validate_project_name
(regex ^[a-zA-Z0-9_-]+$, applied in read_compose, update_compose,
update_image_tag, update_env_var) plus parametrized tests for valid and
unsafe names and one rejection test per tool. 236 tests pass.

Also: ruff format autofix on three pre-existing files (cli.py,
config.py, test_config.py) — cosmetic only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:07:00 +02:00
marcus a1a9388d88 test: v0.2.8 — comprehensive test suite for dsm_client
Adds tests/test_dsm_client.py covering:
- _scrub_url and _error_message pure helpers
- DsmClient.request happy-path, API-not-cached, SID scrubbing in
  HTTPStatusError, sensitive-param log masking
- Session re-auth retry: single-retry semantics, auth-manager-absent
  path, re-auth failure path, thundering-herd (login called once
  under concurrent 106 responses)
- trigger_build_stream: SSE fire-and-forget, JSON error detection,
  ReadTimeout swallowing, HTTP-error scrubbing
- upload_text and download_text happy-path + error-response branches
- _ensure_initialized double-checked-locking and M4 negative-cache
  cooldown behavior

Addresses C3 from the 0.2.7 review; paired with 4caac3a for 0.2.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:43 +02:00
marcus 4caac3a6c7 fix: v0.2.8 — init cooldown to prevent repeated failed-login hammering
Cache failed `_ensure_initialized` outcomes for 60 seconds so that
repeated tool calls during a credential outage (wrong password,
IP-blocked 407, DNS failure) don't keep hammering DSM. Each caller
gets the same exception raised from the cache until the cooldown
window expires, after which a fresh attempt is made.

- Adds INIT_ERROR_COOLDOWN module constant (60.0 s).
- Adds self._init_error / self._init_error_until state on DsmClient.
- Re-raises cached error inside the init lock, emits warning log on
  cache entry.

Addresses M4 from the 0.2.7 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:06:35 +02:00
marcus e17a70aecf chore: sync uv.lock to 0.2.7; ignore .claude/ local settings
- uv.lock: package version bumped 0.2.2 → 0.2.7 (was stale).
- .gitignore: exclude .claude/ (Claude Code per-user settings, not shared).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:18 +02:00
marcus 5b14af8ea1 chore: ruff autofix — import sorting, remove unused imports
Mechanical cleanup via `ruff check --fix` + `ruff format`:
- cli.py, test_auth.py: import sorting (isort convention)
- cli.py: remove unused AuthenticationError import in _run_setup
- config.py: remove unused `field` import
- test_auth.py: remove unused MagicMock import
- test_config.py: remove unused Path import

No functional change. All 131 tests remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:12 +02:00
25 changed files with 5032 additions and 74 deletions
+3
View File
@@ -17,3 +17,6 @@ __pycache__/
# Reference material (not part of this project's source)
reference/
# Claude Code local settings
.claude/
+417
View File
@@ -2,6 +2,423 @@
All notable changes to this project will be documented in this file.
## [0.7.0] - 2026-05-18
### Added
**`inspect_container`** — new tool that returns the full configuration
of a single container, intended as the foundation for a future
GUI-container → Compose migration workflow.
- API: `SYNO.Docker.Container/get` with `name` JSON-encoded (the same
endpoint already used by `get_container_status` and `delete_container`,
but called directly with a focus on the migration-relevant fields).
The DSM action `inspect` (no `get`) does not exist and returns DSM
code 103 — use `get`.
- Output covers: image, status / running, restart policy, network
mode and per-network IP addresses, port bindings, volume mounts
(with FULL `/volume1/...` host path — see DSM quirk below),
environment variables, labels, entrypoint / command, links, and
capabilities / privileged.
- Hash-prefixed container names (`abcdef012345_myservice`) are resolved
to the actual DSM name for the API call and stripped from the
display header — same convention as the other container tools.
### Documented (DSM quirk)
**`profile.volume_bindings[].host_volume_file` is share-relative**, not
the full host path. A live capture against a container with a
`/volume1/docker/homeassistant` bind mount returned
`host_volume_file = "/docker/homeassistant"` in `profile`, while
`details.Mounts[].Source` carried the full
`/volume1/docker/homeassistant`. For a Compose-rebuild use case the
full path is required — `inspect_container` therefore reads mount
sources from `details.Mounts[].Source`, not from
`profile.volume_bindings[].host_volume_file`. Recorded in CLAUDE.md.
## [0.6.0] - 2026-05-18
### Changed
**`build_stream` is no longer fire-and-forget (#2).** A live stream
capture confirmed that DSM emits a readable plaintext build log over
the HTTP body — one short status line per step — and closes the
connection when the build is done. The 0.2.5 implementation sent the
request and dropped the body unread, which meant a failed image pull
left users with nothing more than a `BUILD_FAILED` polling status and
no actionable diagnostic.
- `DsmClient.trigger_build_stream` now consumes the body line by line
and returns the collected log as a string instead of `None`. A wall-
clock budget of 210 s (`BUILD_STREAM_BUDGET`, kept under the Claude
Desktop ~4 min tool-call ceiling) caps the read; on timeout the
partial log is returned with a `[build_stream: timeout — stream
still open server-side]` marker appended so the caller knows the
build is still going server-side. Per-chunk `ReadTimeout` is treated
the same way — return what we have plus the marker.
- `redeploy_project` and `create_project` now parse the returned log
via `_parse_build_stream_log`, which classifies any line containing
`Error response from daemon:` or ending in ` Error` as a failure.
When the log contains errors the tools abort immediately, surface
the daemon line(s) in the result (e.g. `Error response from daemon:
manifest for nginx:9.9.9-nonexistent not found: manifest unknown`),
and skip the polling step entirely — DSM has already told us the
build is dead. The existing `BUILD_FAILED` / `ERROR` polling guard
(M-5) stays as a second safety net for late failures where the
stream log was clean but the container exited after start.
- No new MCP tool: the build log is a live stream and cannot be
re-fetched after the build ends, so it is surfaced during
`redeploy_project` / `create_project` rather than exposed as a
standalone `get_project_build_log` call.
JSON error envelope handling, transport-error mapping (M-4), and the
SID-scrubbed HTTP-error formatting are unchanged. Closes #2.
Minor version bump because `redeploy_project` and `create_project`
return materially different strings on a failed build and exit
earlier in the failure path; signatures unchanged.
## [0.5.1] - 2026-05-18
### Fixed
- `pull_image` (#3): the 0.5.0 implementation called
`SYNO.Docker.Registry/pull_start`, which DSM rejects with
"Method does not exist". A live DSM API capture confirmed that
`pull_start` actually lives on `SYNO.Docker.Image`, not
`SYNO.Docker.Registry`. The Registry API only exposes the
synchronous read-only methods (`search`, `tags`, `get/set/create/
delete`, `using`). Parameters are unchanged — both `repository`
and `tag` are still JSON-encoded — and the `Image/list` polling
for completion detection works as before. Note: #3 is already
closed; this is the follow-up fix.
## [0.5.0] - 2026-05-18
### Added
**Welle B Teil 1 — Registry tools (#3, #5).** Three new `SYNO.Docker.Registry`
tools, reverse-engineered from a live DSM API capture (the n4s4 reference
disagrees on parameter names and methods; the live capture wins):
- `search_registry` (#5) — search the active Docker registry by query string.
Uses `SYNO.Docker.Registry/search` (version 1) with the query JSON-encoded
as `q`, plus `offset`, `limit`, and `page_size`. Renders stars, downloads,
the official-image flag, and a truncated description per hit, and shows
the total match count so the caller knows when to raise `limit`. No
confirmation gate (read-only).
- `list_image_tags` — list available tags for a repository (bonus tool).
Uses `SYNO.Docker.Registry/tags` (version 1) with the repository
JSON-encoded as `repo` — note the parameter name diverges from the n4s4
reference which uses `name`. The response shape is unusual: DSM returns
the tag list as the envelope's `data` field directly (not wrapped in a
sub-key), so the tool accepts both shapes defensively. Output is capped
by `limit` (default 50) because popular images like `alpine` ship 200+
tags. No confirmation gate (read-only).
- `pull_image` (#3) — pull an image into the local cache via
`SYNO.Docker.Registry/pull_start` (version 1) with both `repository` and
`tag` JSON-encoded. Requires `confirmed=True`. DSM exposes no confirmed
`pull_status` method, so completion is detected by polling
`SYNO.Docker.Image/list` for the new `repository:tag` pair with a 210 s
backoff schedule and a 240 s overall budget (kept under the Claude
Desktop ~4 min tool-call ceiling). A timeout returns a non-fatal "still
running" hint pointing at `list_images` instead of raising — DSM keeps
pulling server-side regardless. Short-circuits when the image is already
present locally so a repeated call is cheap. Closes #3 and #5.
Tool count rises from 31 to 34.
### Fixed
- `inspect_image` rendering polish (follow-up to #4 after the 0.4.2
parameter-contract fix). Three live-NAS observations that the
initial implementation got wrong:
- **Header showed `?:?`** — DSM `SYNO.Docker.Image/get` returns
`image=""` and `tag=""` when the lookup is by name:tag, so the
response fields are unreliable. The header now echoes the user-
supplied `image_id` (e.g. `gitea/gitea:1.26.1`), falling back to
the sha256 `id` only if `image_id` itself were empty.
- **Ports rendered as raw Python dicts** like
`{'port': '22', 'protocol': 'tcp'}`. The `ports` array is actually
a list of `{"port", "protocol"}` objects; each entry is now
formatted as `22/tcp`.
- **Environment rendered as raw Python dicts** like
`{'key': 'PATH', 'value': '...'}`. The `env` array is actually a
list of `{"key", "value"}` objects; each entry is now formatted
as `PATH=/usr/local/...`.
`cmd`, `entrypoint`, and `volumes` (plain string arrays) were
already correct and are unchanged. Referencing #4 (already closed).
## [0.4.2] - 2026-05-18
### Fixed
- `inspect_image` (#4): the 0.4.0 implementation called
`SYNO.Docker.Image/get` with `name` + `tag` + `id` parameters, which
DSM rejected with error 114 ("invalid parameter"). Reverse-
engineering the live API capture revealed the correct contract:
one JSON-encoded parameter named `identity` that accepts either form:
- `name:tag` (e.g. `"gitea/gitea:1.26.1"`)
- `sha256:<hash>` (e.g. `"sha256:cd21e54e..."`)
The pre-resolution lookup against `list_images` is no longer needed —
the user input is JSON-encoded and passed straight through.
- `inspect_image` response parsing: the endpoint does NOT return
`details.Config.*` / `RootFS.Layers` (the Docker-engine inspect shape
the previous code assumed). The actual top-level fields are `image`,
`tag`, `id`, `digest`, `size`, `virtual_size`, `author`,
`docker_version`, `cmd`, `entrypoint`, `env`, `ports`, `volumes`.
Layer rendering is removed (the endpoint does not surface layers);
digest, author, docker version, and volumes are now displayed.
## [0.4.1] - 2026-05-18
### Removed
- `pause_container` and `unpause_container` (added in 0.4.0) — live
test against DSM revealed that Container Manager on this firmware
does not expose pause/resume at all. The GUI action menu only offers
Start / Stop / Force-Stop / Restart / Reset, and direct calls to
`SYNO.Docker.Container/pause` and `/unpause` return "Method does not
exist". Both tools have been removed rather than left as a broken
surface. The remaining three lifecycle tools (`start_container`,
`stop_container`, `restart_container`) are unaffected. Tool count
drops from 33 to 31. Closes #7 (won't fix — DSM-side limitation).
## [0.4.0] - 2026-05-18
### Added
**Container lifecycle (#1, #7)** — five new tools fill the gap between
"project" and "exec" operations. Until now the only way to stop or
restart a single container was to stop the whole project. All five
use `SYNO.Docker.Container` (version=1) with the same JSON-encoded
`name` parameter pattern as `delete_container`, and route through
`_resolve_container_name` so a clean name like `jenkins` resolves
to DSM's hash-prefixed `f93cb8b504f7_jenkins`:
- `start_container` — start a stopped container (no confirmation).
- `stop_container` — stop a running container (confirmation required).
- `restart_container` — stop + start in one call (confirmation required).
- `pause_container` — SIGSTOP the container's processes (confirmation
required).
- `unpause_container` — SIGCONT the container's processes (no
confirmation; reversing a pause is non-destructive).
`stop` is live-verified against DSM; `start`, `restart`, `pause`, and
`unpause` are implemented by symmetry on the same API surface. If any
of them turns out to need a different parameter shape in production,
it will be reverse-engineered via DevTools capture in a follow-up.
**Image inspection (#4)**`inspect_image` returns the full image
detail blob (layers + sizes, environment variables, exposed ports,
entrypoint/cmd, working directory, labels) via
`SYNO.Docker.Image/get`. Accepts the same identifier forms as
`delete_image``name:tag`, registry-prefixed `ghcr.io/foo/bar:v1`,
and bare hash — and resolves the user input against `list_images`
first so typos produce a clean "not found" rather than an opaque DSM
error. The response parser is defensive about wrapper shape
(`details.<docker fields>` vs. flat) so the tool keeps working if DSM
varies the envelope between firmware versions.
**System overview (#6)**`system_overview` aggregates CPU %, RAM
used/limit, network I/O, and block I/O across all running containers
plus running/stopped counts. No new DSM endpoint: composed from
`SYNO.Docker.Container/list` + `SYNO.Docker.Container/stats`,
re-using the same Docker-formula CPU calculation as
`container_stats`. Errors from either upstream call are non-fatal —
the available section is rendered and the failure is surfaced under a
`Warnings:` block.
## [0.3.3] - 2026-05-18
### Fixed
- `delete_container`: `SYNO.Docker.Container/delete` requires three
parameters — `name` (JSON-encoded), `force=false`, and
`preserve_profile=false`. Previously only `name` was sent (without
JSON-encoding), causing DSM to reject the call with error 114.
- `delete_project`: DSM does **not** reject `Project/delete` on a
running project — it silently removes the registration and leaves
the containers running as orphans. The connector now blocks the call
itself when the project status is `RUNNING`, before issuing any DSM
request, and tells the user to `stop_project` first. The previous
implementation relied on a DSM-level rejection that never occurs in
practice; the corresponding unit test was a false positive (mocked
an error that real DSM never returns) and has been replaced with a
test that asserts `Project/delete` is never called for a `RUNNING`
project.
### Added
- `delete_project` — remove a Container Manager project's
registration via `SYNO.Docker.Project/delete` with the UUID
JSON-encoded as the `id` parameter (per DSM convention).
Mirrors the "Delete project" action in Container Manager: only the
registration is removed; the project folder and compose file remain
on the NAS. The success message explicitly states the folder was
preserved so the user is not surprised. Closes the project
lifecycle (create → start/stop/redeploy → delete).
Safety:
- Project-name validation runs before any I/O.
- A `_find_project` pre-flight returns "not found" with a clear
message rather than letting DSM reject an unknown UUID.
- The tool deliberately does NOT auto-stop a running project. If
DSM rejects the delete on a `RUNNING` project, the response tells
the user to `stop_project` first rather than silently halting
containers under the guise of a "delete" call.
- Requires `confirmed=True`; the preview shows name, UUID, status,
full path, and share path so the user can verify before deleting.
### Added
- `create_project` — register a new Container Manager project from a
compose YAML string. Three-step flow:
1. Create the target folder via `SYNO.FileStation.CreateFolder` with
`force_parent=true` (idempotent — does not fail if the folder
already exists, and creates missing intermediate directories).
Without this step, `SYNO.Docker.Project/create` fails with DSM
error code 2100.
2. `SYNO.Docker.Project/create` (form-encoded POST, JSON-encoded
string parameters per DSM convention) returns the new project's
UUID.
3. `trigger_build_stream` + `_wait_for_project_running` — reuses the
existing image-pull / start / poll machinery (including the
`BUILD_FAILED` early-exit from welle 2).
Defaults: `share_path` is derived from `compose_base_path` (e.g.
`/volume1/docker` + `myapp``/docker/myapp`). The compose content
is validated as YAML before any side effects. A pre-flight
`list_projects` check rejects duplicate names with a clear message
rather than leaving an orphaned folder on the NAS. Requires
`confirmed=True`; the preview shows the resolved share path and the
service count parsed from the compose content.
### Fixed
- `DsmClient.trigger_build_stream`: broaden transport-error handling
(M-4). `httpx.ReadTimeout` is still treated as a success signal (the
request was sent; DSM is processing it server-side), but all other
`httpx.HTTPError` subclasses (`ConnectError`, `ConnectTimeout`,
`WriteError`, `RemoteProtocolError`, …) are now converted into a
`SynologyError` with a clear message. Previously these propagated as
raw httpx exceptions and left callers with a stack trace.
- `redeploy_project`: when build_stream (or the polling step) fails
after the project has already been stopped, the response now
explicitly tells the user that the project is in `STOPPED` state and
must be recovered with `start_project` or another `redeploy_project`
call. Previously the response suggested "use stop + start
separately", which was misleading because stop had already happened.
- `_wait_for_project_running`: exit the polling loop early on
`BUILD_FAILED` / `ERROR` (M-5). DSM signals these statuses within
seconds of a failed image pull; the old polling loop kept waiting up
to 5 minutes for `RUNNING`. `redeploy_project` surfaces the terminal
status with a hint to `update_image_tag` and retry when the cause is
`BUILD_FAILED`.
- `system_prune` preview: count unused user-created networks alongside
dangling images and stopped containers (M-6). Built-in networks
(`bridge`, `host`, `none`) are excluded because Docker never prunes
them. Previously the preview noted "Unused networks: (not counted)",
even though the underlying `SYNO.Docker.Utils/prune` call deletes
them — users could lose networks they had not been warned about.
### Changed
- Minor version bump because `redeploy_project` and `system_prune`
return different strings (and `redeploy_project` returns earlier on
`BUILD_FAILED`). No tool signatures changed.
## [0.2.9] - 2026-05-18
### Fixed
- `__version__` is now derived from package metadata via
`importlib.metadata.version()`, eliminating the drift between
`pyproject.toml` and `src/mcp_synology_container/__init__.py` (was stuck
at `0.1.0` since the initial release).
- `compose.py`: reject project names that contain path separators or other
unsafe characters before they reach `_find_compose_path`. Previously a
name like `"../../etc"` could traverse out of `compose_base_path` when
the project was not yet registered with Container Manager. The new
`_validate_project_name` helper enforces `^[a-zA-Z0-9_-]+$` and is
applied to `read_compose`, `update_compose`, `update_image_tag`, and
`update_env_var`. Addresses M-3 from the v0.2.8 review.
### Docs
- `CHANGELOG.md` backfilled for releases 0.2.7 and 0.2.8 (entries had been
missed during those releases).
## [0.2.8] - 2026-04-21
### Added
- `tests/test_dsm_client.py` — comprehensive offline test suite for
`DsmClient`:
- `_scrub_url` and `_error_message` pure helpers.
- `request()` happy-path, API-not-cached, `_sid` scrubbing in
`HTTPStatusError`, sensitive-param log masking.
- Session re-auth retry: single-retry semantics, auth-manager-absent
path, re-auth failure path, thundering-herd (login called once under
concurrent 106 responses).
- `trigger_build_stream`: SSE fire-and-forget, JSON error detection,
`ReadTimeout` swallowing, HTTP-error scrubbing.
- `upload_text` and `download_text` happy-path + error-response branches.
- `_ensure_initialized` double-checked locking and negative-cache
cooldown behavior.
### Fixed
- `DsmClient._ensure_initialized`: cache failed init outcomes for 60 s so
that repeated tool calls during a credential outage (wrong password,
IP-blocked 407, DNS failure) do not keep hammering DSM. Each caller
receives the cached exception until the cooldown window expires, after
which a fresh attempt is made. Adds `INIT_ERROR_COOLDOWN` module
constant and `_init_error` / `_init_error_until` state. Addresses M4
from the 0.2.7 review.
## [0.2.7] - 2026-04-21
### Fixed
- `DsmClient`: scrub `_sid` query-parameter values from URLs embedded in
`httpx.HTTPStatusError` messages so the raw DSM session ID never reaches
log output or MCP tool responses (C1).
- `DsmClient`: re-auth lock now snapshots `_sid` before entering the lock
and skips the redundant login if another task has already refreshed the
session, eliminating duplicate logins on concurrent 106/107/119
responses (M3).
- `DsmClient.trigger_build_stream`: re-instates immediate JSON-error
detection (regression from 0.2.6). Inspects the `Content-Type` header
and reads a small capped prefix of the body for `application/json`
responses to surface DSM error codes without forcing the caller into a
multi-minute polling timeout. SSE responses remain fire-and-forget
(C2/M8).
- `compose.update_env_var`: parenthesise the apply-branch match condition
so `(isinstance AND startswith) OR (entry == var_name)` no longer
evaluates the equality branch for non-string entries — aligns the apply
side with the preview-side detection logic (M1).
### Changed
- All 23 `@mcp.tool()` functions: strip `-> str` return annotations and
trim docstrings to a single line (≤100 chars). FastMCP generates an
`outputSchema` entry for every annotated tool, which roughly doubles
the `tools/list` payload size; multi-line docstrings with
`Args:`/`Returns:` sections add further bulk that Claude Desktop must
parse on every connection.
### Chore
- `uv.lock` resynced (was stale at `0.2.2`).
- `.gitignore`: exclude `.claude/` per-user Claude Code settings.
- Mechanical `ruff check --fix` + `ruff format` cleanup (import sorting,
unused-import removal). No functional change.
## [0.2.6] - 2026-04-21
### Fixed
+75 -9
View File
@@ -26,18 +26,24 @@ containers, images, compose files, networks, and system housekeeping.
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 (23)
## Implemented tools (35)
| Category | Tools |
|---|---|
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` |
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container` |
| 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` |
| 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 | `system_df`, `system_prune`, `system_overview` |
---
@@ -50,28 +56,88 @@ containers, images, compose files, networks, and system housekeeping.
- **Async project start** — `SYNO.Docker.Project/start` returns immediately
while containers are still initialising. `redeploy_project` polls
`SYNO.Docker.Project/list` every 2 s for up to 30 s after issuing start.
- **`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` consumes the
body line-by-line with a 210 s wall-clock budget (under the Claude
Desktop ~4 min ceiling) and returns the log as a string; on timeout
the partial log is returned with a marker appended so callers know
the build is still running server-side. `redeploy_project` and
`create_project` grep the returned log for daemon errors and abort
early — these errors are much more actionable than the eventual
`BUILD_FAILED` polling status. 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`** — API method exists but behaviour varies by
DSM version; not exposed as a standalone tool.
- **`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, so completion
is detected by polling `SYNO.Docker.Image/list` until `repository:tag`
appears (210 s backoff, 240 s budget). Timeout returns a "still
running" hint instead of raising — DSM keeps pulling server-side
regardless of the HTTP response.
- **`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`, `exec_in_container`, `update_image_tag`,
`update_env_var`, `update_compose`, `delete_container`
`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`
- 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.
---
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mcp-synology-container"
version = "0.2.7"
version = "0.7.0"
description = "MCP server for Synology Container Manager"
requires-python = ">=3.12"
dependencies = [
+6 -1
View File
@@ -1,3 +1,8 @@
"""MCP server for Synology Container Manager."""
__version__ = "0.1.0"
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("mcp-synology-container")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
+6 -6
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import sys
@@ -43,8 +44,8 @@ def setup(verbose: bool) -> None:
async def _run_setup() -> None:
"""Interactive setup flow."""
from mcp_synology_container.auth import AuthManager, AuthenticationError
from mcp_synology_container.config import AppConfig, ConnectionConfig, CONFIG_PATH, save_config
from mcp_synology_container.auth import AuthManager
from mcp_synology_container.config import CONFIG_PATH, AppConfig, ConnectionConfig, save_config
from mcp_synology_container.dsm_client import DsmClient, SynologyError
click.echo("=== mcp-synology-container setup ===\n")
@@ -144,10 +145,8 @@ async def _run_setup() -> None:
client.sid = sid
click.echo(click.style("Login successful!", fg="green"))
# Logout cleanly
try:
with contextlib.suppress(SynologyError):
await client.request("SYNO.API.Auth", "logout", version=6, params={})
except SynologyError:
pass
else:
click.echo(click.style("Login failed: no session ID returned.", fg="red"), err=True)
sys.exit(1)
@@ -198,7 +197,7 @@ def check(config_path: str | None, verbose: bool) -> None:
async def _run_check(config_path: str | None) -> bool:
"""Run connectivity check. Returns True on success."""
from mcp_synology_container.auth import AuthManager, AuthenticationError
from mcp_synology_container.auth import AuthenticationError, AuthManager
from mcp_synology_container.config import load_config
from mcp_synology_container.dsm_client import DsmClient, SynologyError
@@ -291,6 +290,7 @@ def serve(config_path: str | None) -> None:
# and anyio.run() ensures the correct backend context is set up.
# asyncio.run() can cause issues on Windows (ProactorEventLoop + anyio).
import anyio
anyio.run(_run_serve, config_path)
+2 -5
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -132,10 +132,7 @@ def _validate_config(raw: dict[str, Any]) -> AppConfig:
"""
schema_version = raw.get("schema_version")
if schema_version != CURRENT_SCHEMA_VERSION:
msg = (
f"Config schema_version is {schema_version!r}, "
f"expected {CURRENT_SCHEMA_VERSION}."
)
msg = f"Config schema_version is {schema_version!r}, expected {CURRENT_SCHEMA_VERSION}."
raise ValueError(msg)
conn_raw = raw.get("connection", {})
+123 -28
View File
@@ -27,6 +27,10 @@ logger = logging.getLogger(__name__)
# Session error codes that trigger transparent re-auth
_SESSION_ERROR_CODES = frozenset({106, 107, 119})
# Cooldown after a failed init so repeated tool calls don't hammer DSM
# (and don't escalate 407 "IP blocked" lockouts).
INIT_ERROR_COOLDOWN = 60.0
# Parameters to mask in debug logging
_SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"})
@@ -111,6 +115,8 @@ class DsmClient:
self._init_lock = asyncio.Lock()
self._initialized = False
self._initializing = False # True while inside _ensure_initialized
self._init_error: Exception | None = None
self._init_error_until: float = 0.0
logger.debug(
"DsmClient: base_url=%s verify_ssl=%s timeout=%d",
self._base_url,
@@ -141,6 +147,10 @@ class DsmClient:
async with self._init_lock:
if self._initialized: # re-check inside lock
return
# Negative cache: re-raise cached error during cooldown window.
now = asyncio.get_event_loop().time()
if self._init_error is not None and now < self._init_error_until:
raise self._init_error
self._initializing = True
try:
sys.stderr.write(f"[dsm] Connecting to {self._base_url}...\n")
@@ -157,7 +167,18 @@ class DsmClient:
sys.stderr.write("[dsm] Auth OK\n")
sys.stderr.flush()
self._initialized = True
self._init_error = None
self._init_error_until = 0.0
logger.debug("Lazy init complete")
except Exception as e:
self._init_error = e
self._init_error_until = now + INIT_ERROR_COOLDOWN
logger.warning(
"Lazy init failed (%s); will retry after %.0fs cooldown",
type(e).__name__,
INIT_ERROR_COOLDOWN,
)
raise
finally:
self._initializing = False
@@ -398,27 +419,53 @@ class DsmClient:
logger.debug("DSM POST response: %s/%s — error code %d", api, method, code)
raise SynologyError(_error_message(code, api), code=code)
async def trigger_build_stream(self, project_id: str) -> None:
"""Trigger SYNO.Docker.Project/build_stream — the "Erstellen" button equivalent.
# Wall-clock budget for consuming the build_stream body. DSM keeps the
# connection open until the build is done (success or failure), so we
# need to give it enough time for an image pull while staying under the
# Claude Desktop ~4 min tool-call ceiling. When the budget is exhausted
# the partial log is returned with a "still running" marker appended.
BUILD_STREAM_BUDGET = 210.0
# Marker appended to the returned log when the stream did not finish
# within BUILD_STREAM_BUDGET. Callers can grep for this to decide
# 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.
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 on success; we send the request, check
the response headers, and close without consuming the SSE 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.
Container Manager (confirmed via browser DevTools). The endpoint
returns a streamed plaintext response (one short status line per step,
e.g. ``Container <name> Running`` on success or ``<svc> Error`` followed
by ``Error response from daemon: <cause>`` on failure). DSM closes the
stream when the build is done.
Error detection: DSM signals application-level rejection (e.g. project
locked, invalid id) as an HTTP-200 JSON body `{"success": false, ...}`
rather than as an SSE stream. We inspect the `Content-Type` header and,
when it is `application/json`, read a small capped prefix of the body
to surface the DSM error code immediately instead of forcing the caller
into a multi-minute polling timeout. SSE responses are not read.
The body is consumed line-by-line and returned as a single string so
callers (redeploy_project, create_project) can surface the real cause
of a failed build instead of waiting for the polling step to report
``BUILD_FAILED`` with no context.
Error detection precedence:
1. HTTP status (4xx/5xx) → SynologyError, body not consumed.
2. JSON content-type → DSM rejected the request before streaming
(e.g. project locked, invalid id); a ``success: false`` envelope is
raised as SynologyError. Malformed JSON is treated as accepted.
3. Streamed plaintext → returned verbatim; per-line failure parsing
is the caller's responsibility (look for ``Error response from
daemon:`` or ``<svc> Error``).
Args:
project_id: Project UUID from SYNO.Docker.Project/list.
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.
Raises:
SynologyError: If DSM rejects the build with a JSON error body, or
if the HTTP response status indicates a transport-level error.
@@ -445,16 +492,23 @@ class DsmClient:
sys.stderr.flush()
logger.debug("build_stream: project_id=%s", project_id)
# Fire-and-forget for the SSE body, but detect immediate JSON errors.
# The read timeout only applies to waiting for response *headers* and
# for the (small, capped) JSON error body we read; we never consume SSE
# events, so DSM's streaming cannot block this call indefinitely.
# Wall-clock deadline for the streamed body. Individual chunks get a
# 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
try:
async with http.stream(
"GET",
url,
params=params,
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0),
timeout=httpx.Timeout(
connect=10.0,
read=60.0,
write=10.0,
pool=5.0,
),
) as resp:
try:
resp.raise_for_status()
@@ -467,8 +521,8 @@ class DsmClient:
content_type = resp.headers.get("content-type", "")
if "application/json" in content_type:
# DSM rejected the build — read the JSON error body (capped
# at ~4 KB; DSM error envelopes are tiny).
# DSM rejected the build before streaming — read the JSON
# error body (capped at ~4 KB; DSM error envelopes are tiny).
body = b""
async for chunk in resp.aiter_bytes():
body += chunk
@@ -479,17 +533,58 @@ class DsmClient:
except json.JSONDecodeError:
# Malformed response — treat as accepted and let the
# caller's polling surface any real failure.
return
return ""
if not parsed.get("success", True):
code = parsed.get("error", {}).get("code", 0)
raise SynologyError(_error_message(code, api), code=code)
# success=true with JSON content-type: odd, treat as accepted.
return
# SSE or anything else → fire-and-forget, close without reading.
# success=true with JSON content-type: no streamed log.
return ""
# Streamed plaintext (DSM uses text/html for build_stream).
# Collect line-by-line until DSM closes the stream or the
# wall-clock budget runs out.
lines: list[str] = []
timed_out = False
try:
async for raw_line in resp.aiter_lines():
# Strip Windows line endings; aiter_lines already
# splits on \n but preserves trailing \r on some httpx
# versions.
line = raw_line.rstrip("\r\n")
if line:
lines.append(line)
if loop.time() >= deadline:
timed_out = True
break
except httpx.ReadTimeout:
# A chunk didn't arrive within the per-read window. DSM is
# still building server-side; surface what we have.
timed_out = True
log_text = "\n".join(lines)
if timed_out:
log_text = (
f"{log_text}\n{self.BUILD_STREAM_TIMEOUT_MARKER}"
if log_text
else self.BUILD_STREAM_TIMEOUT_MARKER
)
return log_text
except httpx.ReadTimeout:
# Headers not received within 10 s, but the GET request was already
# sent. DSM received it and started the build. Proceed to polling.
pass
# Headers not received within the connect/read window, but the GET
# was already sent. DSM received it and started the build; return
# the timeout marker so the caller knows there's no log yet.
return self.BUILD_STREAM_TIMEOUT_MARKER
except httpx.HTTPError as e:
# Other transport-level failures (ConnectError, ConnectTimeout,
# WriteError, RemoteProtocolError, …) mean DSM never received the
# build request. Surface a clear SynologyError instead of letting
# the raw httpx exception bubble up — the caller (redeploy_project)
# has typically already stopped the project and needs to know that
# the build did not start.
raise SynologyError(
f"build_stream transport error: {type(e).__name__}: {e}",
code=0,
) from None
async def upload_text(
self,
+32 -2
View File
@@ -25,6 +25,11 @@ logger = logging.getLogger(__name__)
_VOLUME_PREFIX_RE = re.compile(r"^/volume\d+")
# Project names are used as path components for FileStation lookups when the
# project is not yet registered with Container Manager. Restrict to a safe
# subset so a malicious name like "../../etc" cannot escape compose_base_path.
_PROJECT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
def _to_filestation_path(path: str) -> str:
"""Strip /volumeN prefix so paths work with the FileStation API.
@@ -35,6 +40,22 @@ def _to_filestation_path(path: str) -> str:
return _VOLUME_PREFIX_RE.sub("", path)
def _validate_project_name(project_name: str) -> str | None:
"""Return an error message if project_name is unsafe, else None.
Allowed characters: letters, digits, underscore, hyphen. Anything else
(including '/', '\\', '..', whitespace, empty string) is rejected because
the name flows into FileStation path construction when the project is
not yet known to Container Manager.
"""
if not project_name or not _PROJECT_NAME_RE.match(project_name):
return (
f"Error: invalid project name {project_name!r}. "
"Allowed: letters, digits, '_' and '-' (no slashes, dots, or spaces)."
)
return None
# Recognized compose file names (in priority order)
_COMPOSE_FILENAMES = [
"docker-compose.yml",
@@ -68,6 +89,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
@mcp.tool()
async def read_compose(project_name: str):
"""Read the compose file (YAML) for a project."""
if (err := _validate_project_name(project_name)) is not None:
return err
path = await _find_compose_path(client, config, project_name)
if path is None:
project = await _find_project(client, project_name)
@@ -97,6 +120,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
confirmed: bool = False,
):
"""Update a service's image tag in the compose file. Requires confirmed=True."""
if (err := _validate_project_name(project_name)) is not None:
return err
path = await _find_compose_path(client, config, project_name)
if path is None:
return f"No compose file found for project '{project_name}'."
@@ -216,6 +241,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
confirmed: bool = False,
):
"""Add or update an env var in a service's compose definition. Requires confirmed=True."""
if (err := _validate_project_name(project_name)) is not None:
return err
path = await _find_compose_path(client, config, project_name)
if path is None:
return f"No compose file found for project '{project_name}'."
@@ -241,7 +268,7 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
# Determine previous value and build description
old_value: str | None = None
if isinstance(env_list, list):
for i, entry in enumerate(env_list):
for entry in env_list:
if isinstance(entry, str) and entry.startswith(f"{var_name}="):
old_value = entry.split("=", 1)[1]
break
@@ -298,8 +325,9 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
except Exception as e:
return f"Error writing compose file: {e}"
verb = "Updated" if action == "update" else "Added"
return (
f"{'Updated' if action == 'update' else 'Added'} env var in '{service_name}' ({project_name}):\n"
f"{verb} env var in '{service_name}' ({project_name}):\n"
f" {var_name}={var_value}\n\n"
f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change."
)
@@ -311,6 +339,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
confirmed: bool = False,
):
"""Replace the entire compose file with new YAML content. Requires confirmed=True."""
if (err := _validate_project_name(project_name)) is not None:
return err
# Validate YAML before anything else
try:
parsed = yaml.safe_load(new_content)
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import re
from typing import TYPE_CHECKING, Any
@@ -319,12 +320,95 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
await client.request(
"SYNO.Docker.Container",
"delete",
params={"name": resolved_name},
params={
"name": json.dumps(resolved_name),
"force": "false",
"preserve_profile": "false",
},
)
return f"Deleted container '{display_name}'."
except Exception as e:
return f"Error deleting '{display_name}': {e}"
@mcp.tool()
async def start_container(container_name: str):
"""Start a stopped container."""
resolved_name = await _resolve_container_name(client, container_name)
display_name = _strip_hash_prefix(resolved_name)
try:
await client.request(
"SYNO.Docker.Container",
"start",
version=1,
params={"name": json.dumps(resolved_name)},
)
return f"Started container '{display_name}'."
except Exception as e:
return f"Error starting container '{container_name}': {e}"
@mcp.tool()
async def stop_container(container_name: str, confirmed: bool = False):
"""Stop a running container. Requires confirmed=True."""
if not confirmed:
return (
f"Preview: would stop container '{container_name}'.\n"
f"Call this tool again with confirmed=True to proceed."
)
resolved_name = await _resolve_container_name(client, container_name)
display_name = _strip_hash_prefix(resolved_name)
try:
await client.request(
"SYNO.Docker.Container",
"stop",
version=1,
params={"name": json.dumps(resolved_name)},
)
return f"Stopped container '{display_name}'."
except Exception as e:
return f"Error stopping container '{container_name}': {e}"
@mcp.tool()
async def inspect_container(container_name: str):
"""Inspect a container — image, ports, mounts (full host paths), env, labels, network."""
resolved_name = await _resolve_container_name(client, container_name)
display_name = _strip_hash_prefix(resolved_name)
try:
data = await client.request(
"SYNO.Docker.Container",
"get",
params={"name": json.dumps(resolved_name)},
)
except Exception as e:
return f"Error inspecting container '{container_name}': {e}"
if not data:
return f"Container '{container_name}' not found."
return _format_container_inspect(display_name, data)
@mcp.tool()
async def restart_container(container_name: str, confirmed: bool = False):
"""Restart a container. Requires confirmed=True."""
if not confirmed:
return (
f"Preview: would restart container '{container_name}'.\n"
f"Call this tool again with confirmed=True to proceed."
)
resolved_name = await _resolve_container_name(client, container_name)
display_name = _strip_hash_prefix(resolved_name)
try:
await client.request(
"SYNO.Docker.Container",
"restart",
version=1,
params={"name": json.dumps(resolved_name)},
)
return f"Restarted container '{display_name}'."
except Exception as e:
return f"Error restarting container '{container_name}': {e}"
def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
"""Check if a container belongs to a project based on its labels."""
@@ -392,3 +476,144 @@ def _format_container_detail(name: str, data: dict[str, Any]) -> str:
lines.append(f" {src}{dst}{type_tag}{rw}")
return "\n".join(lines)
def _format_container_inspect(name: str, data: dict[str, Any]) -> str:
"""Format SYNO.Docker.Container/get for the migration-oriented inspect view.
Reads the volume host path from ``details.Mounts[].Source`` (full
``/volume1/...`` path), NOT from ``profile.volume_bindings[].host_volume_file``
— the latter is share-relative (e.g. ``/docker/foo`` for ``/volume1/docker/foo``)
and is not directly Compose-usable. See CLAUDE.md DSM quirks.
"""
details: dict[str, Any] = data.get("details", {}) or {}
profile: dict[str, Any] = data.get("profile", {}) or {}
state: dict[str, Any] = details.get("State", {}) or {}
host_config: dict[str, Any] = details.get("HostConfig", {}) or {}
image_str = profile.get("image") or details.get("Config", {}).get("Image", "?")
status_str = state.get("Status", "?")
running = state.get("Running", False)
restart_count = details.get("RestartCount", 0)
lines = [
f"Container: {name}",
f" Image: {image_str}",
f" Status: {status_str} (running={running})",
]
if restart_count:
lines.append(f" Restarts: {restart_count}")
# ── Restart policy ──────────────────────────────────────────────────────
restart_policy = host_config.get("RestartPolicy", {}) or {}
policy_name = restart_policy.get("Name", "")
if policy_name:
max_retry = restart_policy.get("MaximumRetryCount", 0)
suffix = f" (max {max_retry})" if policy_name == "on-failure" and max_retry else ""
lines.append(f" Restart: {policy_name}{suffix}")
elif profile.get("enable_restart_policy"):
lines.append(" Restart: enabled")
# ── Network ─────────────────────────────────────────────────────────────
net_mode = profile.get("network_mode") or host_config.get("NetworkMode", "")
use_host = profile.get("use_host_network", False)
if use_host:
lines.append(" Network: host")
elif net_mode:
lines.append(f" Network: {net_mode}")
networks: dict[str, Any] = (details.get("NetworkSettings", {}) or {}).get("Networks", {}) or {}
for net_name, net in networks.items():
ip = (net or {}).get("IPAddress", "")
if ip:
lines.append(f" {net_name}: {ip}")
# ── Ports ───────────────────────────────────────────────────────────────
port_bindings: list[dict[str, Any]] = profile.get("port_bindings", []) or []
if port_bindings:
lines.append("")
lines.append(f"Ports ({len(port_bindings)}):")
for pb in port_bindings:
host = pb.get("host_port", "?")
ctr = pb.get("container_port", "?")
proto = pb.get("type", "tcp")
lines.append(f" {host}{ctr}/{proto}")
# ── Mounts ──────────────────────────────────────────────────────────────
# Use details.Mounts[].Source — it's the full /volume1/... path.
# profile.volume_bindings[].host_volume_file is share-relative and not
# Compose-usable (DSM quirk; see CLAUDE.md).
mounts: list[dict[str, Any]] = details.get("Mounts", []) or []
if mounts:
lines.append("")
lines.append(f"Mounts ({len(mounts)}):")
for m in mounts:
src = m.get("Source", "?")
dst = m.get("Destination", "?")
mtype = m.get("Type", "")
rw = "" if m.get("RW", True) else " [ro]"
type_tag = f" ({mtype})" if mtype else ""
lines.append(f" {src}{dst}{type_tag}{rw}")
# ── Environment ─────────────────────────────────────────────────────────
env_vars: list[Any] = profile.get("env_variables") or []
if env_vars:
lines.append("")
lines.append(f"Environment ({len(env_vars)}):")
for var in env_vars:
if isinstance(var, dict):
lines.append(f" {var.get('key', '?')}={var.get('value', '')}")
else:
lines.append(f" {var}")
# ── Labels ──────────────────────────────────────────────────────────────
labels: dict[str, Any] = profile.get("labels") or {}
if labels:
lines.append("")
lines.append(f"Labels ({len(labels)}):")
for key in sorted(labels):
lines.append(f" {key}={labels[key]}")
# ── Command / Entrypoint ────────────────────────────────────────────────
cfg = details.get("Config", {}) or {}
entrypoint = cfg.get("Entrypoint") or []
cmd = cfg.get("Cmd") or profile.get("cmd_v2") or profile.get("cmd") or ""
if entrypoint:
ep_str = (
" ".join(str(x) for x in entrypoint)
if isinstance(entrypoint, list)
else str(entrypoint)
)
lines.append("")
lines.append(f"Entrypoint: {ep_str}")
if cmd:
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
if not entrypoint:
lines.append("")
lines.append(f"Cmd: {cmd_str}")
# ── Links ───────────────────────────────────────────────────────────────
links: list[Any] = profile.get("links") or []
if links:
lines.append("")
lines.append(f"Links ({len(links)}):")
for link in links:
lines.append(f" {link}")
# ── Capabilities / Privileged ───────────────────────────────────────────
cap_add = profile.get("CapAdd") or host_config.get("CapAdd") or []
cap_drop = profile.get("CapDrop") or host_config.get("CapDrop") or []
privileged = profile.get("privileged") or host_config.get("Privileged", False)
if cap_add or cap_drop or privileged:
lines.append("")
lines.append("Security:")
if privileged:
lines.append(" privileged=true")
if cap_add:
lines.append(f" CapAdd: {', '.join(str(c) for c in cap_add)}")
if cap_drop:
lines.append(f" CapDrop: {', '.join(str(c) for c in cap_drop)}")
return "\n".join(lines)
+99 -1
View File
@@ -233,8 +233,10 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
)
if in_use_stopped:
stopped_name = in_use_stopped[0]
return (
f"Cannot delete '{display_name}': image is used by stopped container '{in_use_stopped[0]}'.\n"
f"Cannot delete '{display_name}': image is used by stopped container "
f"'{stopped_name}'.\n"
f"Delete the container first or run system_prune to clean up stopped containers."
)
@@ -269,6 +271,102 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
return f"Deleted {display_name}{size_str} freed."
@mcp.tool()
async def inspect_image(image_id: str):
"""Inspect a local image by name:tag or sha256 hash — shows config, env, ports."""
# SYNO.Docker.Image/get version=1 expects the parameter "identity"
# (JSON-encoded). It accepts both `name:tag` and `sha256:<hash>` —
# no pre-resolution needed. Confirmed via DSM API capture.
try:
data = await client.request(
"SYNO.Docker.Image",
"get",
version=1,
params={"identity": json.dumps(image_id)},
)
except Exception as e:
return f"Error inspecting image '{image_id}': {e}"
if not isinstance(data, dict) or not data:
return f"Image '{image_id}' not found."
img_hash = data.get("id") or ""
# The DSM Image/get response leaves `image` and `tag` empty when the
# lookup is by name:tag (live-confirmed against the NAS), so the
# response fields are unreliable for the header. Prefer the user-
# supplied image_id, then fall back to the id hash.
display_name = image_id or img_hash or "?"
digest = data.get("digest") or ""
size_val = data.get("size") or 0
virtual_size_val = data.get("virtual_size") or 0
author = data.get("author") or ""
docker_version = data.get("docker_version") or ""
cmd = data.get("cmd") or []
entrypoint = data.get("entrypoint") or []
env_list: list[Any] = data.get("env") or []
ports: list[Any] = data.get("ports") or []
volumes: list[Any] = data.get("volumes") or []
lines = [f"Image: {display_name}"]
if img_hash:
lines.append(f" Hash: {img_hash}")
if digest:
lines.append(f" Digest: {digest}")
if size_val:
lines.append(f" Size: {_human_size(size_val)}")
if virtual_size_val and virtual_size_val != size_val:
lines.append(f" Virtual: {_human_size(virtual_size_val)}")
if author:
lines.append(f" Author: {author}")
if docker_version:
lines.append(f" Docker: {docker_version}")
if entrypoint:
ep_str = (
" ".join(str(x) for x in entrypoint)
if isinstance(entrypoint, list)
else str(entrypoint)
)
lines.append("")
lines.append(f" Entrypoint: {ep_str}")
if cmd:
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
if not entrypoint:
lines.append("")
lines.append(f" Cmd: {cmd_str}")
if ports:
lines.append("")
lines.append(f"Exposed ports ({len(ports)}):")
for port in ports:
# Live shape: {"port": "22", "protocol": "tcp"}.
if isinstance(port, dict):
port_num = port.get("port", "?")
proto = port.get("protocol", "tcp")
lines.append(f" {port_num}/{proto}")
else:
lines.append(f" {port}")
if volumes:
lines.append("")
lines.append(f"Volumes ({len(volumes)}):")
for vol in volumes:
lines.append(f" {vol}")
if env_list:
lines.append("")
lines.append(f"Environment ({len(env_list)}):")
for var in env_list:
# Live shape: {"key": "PATH", "value": "/usr/local/sbin"}.
if isinstance(var, dict):
key = var.get("key", "?")
value = var.get("value", "")
lines.append(f" {key}={value}")
else:
lines.append(f" {var}")
return "\n".join(lines)
@mcp.tool()
async def check_image_updates(project_name: str | None = None):
"""Check which local images have updates available (upgradable flag from NAS registry)."""
+332 -3
View File
@@ -1,12 +1,15 @@
"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy."""
"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy, create."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
@@ -19,6 +22,46 @@ _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)
# 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.
_TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"})
# Substrings that mark a line in the build_stream log as a failure. Live
# DSM capture: image-pull or container-start errors surface as either a
# bare "<svc> Error" status line OR a more verbose "Error response from
# daemon: <cause>" line emitted right after it. Both forms are treated as
# build failures by `_parse_build_stream_log`.
_BUILD_DAEMON_ERROR = "Error response from daemon:"
def _parse_build_stream_log(log: str) -> tuple[list[str], list[str]]:
"""Split a build_stream log into (error_lines, info_lines).
A line is classified as an error when it contains "Error response from
daemon:" or ends with " Error" (the per-service status DSM emits when
a container fails to start). Everything else — including success
markers like "Container <name> Running" — is treated as info.
Args:
log: Raw log text returned by ``DsmClient.trigger_build_stream``.
Returns:
Tuple of (errors, info) lines with empty lines stripped.
"""
errors: list[str] = []
info: list[str] = []
for raw in log.splitlines():
line = raw.strip()
if not line:
continue
if _BUILD_DAEMON_ERROR in line or line.endswith(" Error"):
errors.append(line)
else:
info.append(line)
return errors, info
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all project management tools with the MCP server."""
@@ -124,6 +167,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
)
results: list[str] = []
# Track whether we issued a stop that DSM accepted. Used to give the
# caller an accurate recovery hint if a later step (build_stream)
# fails — the project would be left in STOPPED state.
stop_was_issued = False
try:
# ── Step 1: Stop ──────────────────────────────────────────────────
@@ -133,15 +180,36 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
results.append("Step 1/3: Stopping failed build...")
with contextlib.suppress(Exception):
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
stop_was_issued = True
results.append(" Stopped.")
else: # RUNNING
results.append("Step 1/3: Stopping project...")
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
stop_was_issued = True
results.append(" Stopped.")
# ── Step 2: build_stream (pull images + start) ────────────────────
results.append("Step 2/3: Triggering image pull and project start (build_stream)...")
await client.trigger_build_stream(project_id)
build_log = await client.trigger_build_stream(project_id)
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.
results.append(" Build failed — DSM reported:")
results.extend(f" {line}" for line in build_errors)
results.append(
f"\nProject '{project_name}' redeploy aborted (build_stream errors)."
)
if stop_was_issued:
results.append(
f"Note: project '{project_name}' was stopped before this error and is "
f"now in STOPPED state. Run start_project('{project_name}') or retry "
f"redeploy_project to recover."
)
return "\n".join(results)
results.append(" Build request accepted by DSM.")
# ── Step 3: Poll ──────────────────────────────────────────────────
@@ -155,6 +223,19 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
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(
" Check the image tag in the compose file "
"(update_image_tag) and retry redeploy_project."
)
results.append(
f"\nProject '{project_name}' redeploy aborted (status: {final_status})."
)
else:
results.append(
f" Warning: project status is '{final_status}' after "
@@ -165,10 +246,252 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
except Exception as e:
results.append(f"Error during redeploy: {e}")
results.append("Workaround: use stop_project + start_project separately.")
if stop_was_issued:
# M-4: build_stream (or polling) failed AFTER we stopped the
# project. The project is now in STOPPED state and the caller
# needs to know that — the previous "use stop + start"
# workaround was misleading because stop already happened.
results.append(
f"Note: project '{project_name}' was stopped before this error and is "
f"now in STOPPED state. Run start_project('{project_name}') or retry "
f"redeploy_project to recover."
)
else:
results.append("Workaround: use stop_project + start_project separately.")
return "\n".join(results)
@mcp.tool()
async def create_project(
project_name: str,
compose_content: str,
share_path: str | None = None,
confirmed: bool = False,
):
"""Create a new Container Manager project from compose YAML. Requires confirmed=True."""
# Lazy import avoids a circular dependency between projects.py and compose.py.
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.compose import (
_to_filestation_path,
_validate_project_name,
)
if (err := _validate_project_name(project_name)) is not None:
return err
# Parse compose YAML up-front so a malformed input is rejected before
# any side effects (folder creation, project registration).
try:
parsed = yaml.safe_load(compose_content)
except yaml.YAMLError as e:
return f"Invalid YAML content: {e}"
if not isinstance(parsed, dict) or "services" not in parsed:
return "Invalid compose file: must be a YAML document with a 'services' key."
services = parsed.get("services") or {}
service_count = len(services) if isinstance(services, dict) else 0
# Resolve share_path. When the caller omits it, derive it from
# compose_base_path (e.g. "/volume1/docker" + "myapp" → "/docker/myapp").
if share_path is None:
parent_share = _to_filestation_path(config.compose_base_path).rstrip("/")
resolved_share_path = f"{parent_share}/{project_name}"
folder_name = project_name
else:
resolved_share_path = share_path.rstrip("/")
parent_share, _, folder_name = resolved_share_path.rpartition("/")
if not parent_share:
parent_share = "/"
if not folder_name:
return f"Invalid share_path '{share_path}': missing folder name."
# Check for an existing project with the same name BEFORE creating
# the folder — avoids leaving an orphaned directory on the NAS.
existing = await _find_project(client, project_name)
if existing is not None:
return (
f"Project '{project_name}' already exists "
f"(status: {existing.get('status', '?')}, path: {existing.get('path', '?')})."
)
if not confirmed:
return (
f"About to create new project '{project_name}':\n"
f" Share path: {resolved_share_path}\n"
f" Services: {service_count}\n\n"
f"Call this tool again with confirmed=True to apply."
)
results: list[str] = []
# ── Step 1: Create the target folder via FileStation ──────────────────
# force_parent=true makes the call idempotent: it does not fail if the
# folder already exists, and it creates any missing intermediate
# directories. Without this step Docker.Project/create fails with
# error code 2100 ("target folder issue").
results.append("Step 1/3: Creating target folder...")
try:
await client.request(
"SYNO.FileStation.CreateFolder",
"create",
version=2,
params={
"folder_path": json.dumps(parent_share),
"name": json.dumps(folder_name),
"force_parent": "true",
},
)
results.append(f" Folder ready: {resolved_share_path}")
except SynologyError as e:
return (
f"Error creating folder for project '{project_name}': {e}\n"
f" Attempted path: {parent_share}/{folder_name}"
)
# ── Step 2: Register the project with Container Manager ───────────────
results.append("Step 2/3: Registering project with Container Manager...")
try:
data = await client.post_request(
"SYNO.Docker.Project",
"create",
version=1,
params={
"name": json.dumps(project_name),
"share_path": json.dumps(resolved_share_path),
"content": json.dumps(compose_content),
"enable_service_portal": json.dumps(False),
"service_portal_name": json.dumps(""),
"service_portal_port": 0,
"service_portal_protocol": json.dumps("http"),
},
)
except SynologyError as e:
if e.code == 2100:
return (
f"Project creation failed — target folder issue (DSM error 2100).\n"
f" Share path: {resolved_share_path}\n"
f" Folder was created in step 1 but DSM rejected it. "
f"Verify the share exists and the user has write access."
)
return f"Error registering project '{project_name}': {e}"
project_id = (data.get("id") if isinstance(data, dict) else "") or ""
if not project_id:
return (
f"Project registered but DSM returned no project ID. "
f"Check list_projects to confirm — response was: {data!r}"
)
results.append(f" Registered (id={project_id}).")
# ── Step 3: Trigger the build (pull images + start containers) ────────
results.append("Step 3/3: Triggering build_stream (image pull and start)...")
try:
build_log = await client.trigger_build_stream(project_id)
except Exception as e:
results.append(f" Error triggering build: {e}")
results.append(
f"\nProject '{project_name}' is registered but was not started. "
f"Run redeploy_project('{project_name}', confirmed=True) to retry."
)
return "\n".join(results)
build_errors, _ = _parse_build_stream_log(build_log)
if build_errors:
results.append(" Build failed — DSM reported:")
results.extend(f" {line}" for line in build_errors)
results.append(
f"\nProject '{project_name}' is registered but failed to build. "
f"Fix the compose content (e.g. update_image_tag) and run "
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 final_status == "RUNNING":
results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' created and started successfully.")
elif final_status in _TERMINAL_FAILURE_STATUSES:
results.append(f" Build failed — project status is '{final_status}'.")
if final_status == "BUILD_FAILED":
results.append(
" Check the image tag(s) in the compose content "
"(update_image_tag) and retry redeploy_project."
)
results.append(
f"\nProject '{project_name}' is registered but failed to start "
f"(status: {final_status})."
)
else:
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."
)
results.append(f"\nProject '{project_name}' created (final status: {final_status}).")
return "\n".join(results)
@mcp.tool()
async def delete_project(project_name: str, confirmed: bool = False):
"""Remove a project registration from Container Manager. Requires confirmed=True."""
# Lazy import: see create_project for why this avoids a circular dep.
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.compose import _validate_project_name
if (err := _validate_project_name(project_name)) is not None:
return err
project = await _find_project(client, project_name)
if project is None:
return f"Project '{project_name}' not found."
project_id = project.get("id", "")
status = (project.get("status") or "?").upper()
path = project.get("path", "?")
share_path = project.get("share_path", "?")
if not confirmed:
return (
f"About to delete project registration '{project_name}':\n"
f" UUID: {project_id}\n"
f" Status: {status}\n"
f" Path: {path}\n"
f" Share path: {share_path}\n\n"
f"Note: only the Container Manager registration is removed — "
f"the folder and compose file will remain on the NAS.\n\n"
f"Call this tool again with confirmed=True to proceed."
)
# Guard: DSM does NOT reject a delete call on a running project — it
# removes the registration silently and leaves the containers running
# as orphans. Reject here so we never put the NAS into that state.
if status == "RUNNING":
return (
f"Error: project '{project_name}' is RUNNING. "
f"Stop it first with stop_project, then delete_project."
)
try:
await client.request(
"SYNO.Docker.Project",
"delete",
version=1,
params={"id": json.dumps(project_id)},
)
except SynologyError as e:
return f"Error deleting project '{project_name}': {e}"
return (
f"Project '{project_name}' deleted (registration removed).\n"
f"Note: the project folder {share_path} was NOT deleted — "
f"its files remain on the NAS."
)
async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
"""Find a project by name from the list.
@@ -220,6 +543,12 @@ async def _wait_for_project_running(
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"
@@ -0,0 +1,206 @@
"""MCP tools for SYNO.Docker.Registry: search, tags, pull."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import TYPE_CHECKING, Any
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__)
# 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."""
text = (text or "").replace("\n", " ").replace("\r", " ").strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
async def _image_present(client: DsmClient, repository: str, tag: str) -> bool:
"""Return True if repository:tag is in the local image list."""
try:
data = await client.request(
"SYNO.Docker.Image",
"list",
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
)
except Exception as e:
logger.debug("Pull polling: image list failed: %s", e)
return False
images: list[dict[str, Any]] = data.get("images", []) if isinstance(data, dict) else []
for img in images:
repo = img.get("repository", "")
tags = img.get("tags") or []
if repo == repository and tag in tags:
return True
return False
def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all registry management tools with the MCP server."""
@mcp.tool()
async def search_registry(query: str, limit: int = 20):
"""Search the active Docker registry for images matching query."""
if limit < 1:
limit = 1
elif limit > 100:
limit = 100
try:
data = await client.request(
"SYNO.Docker.Registry",
"search",
version=1,
params={
"q": json.dumps(query),
"offset": "0",
"limit": str(limit),
"page_size": str(limit),
},
)
except Exception as e:
return f"Error searching registry for '{query}': {e}"
results: list[dict[str, Any]] = []
total = 0
if isinstance(data, dict):
results = data.get("data", []) or []
total = int(data.get("total", 0) or 0)
if not results:
return f"No results found for '{query}'."
lines = [f"Search results for '{query}' ({len(results)} of {total} total):", ""]
lines.append(" Stars Downloads Name (official) Description")
for hit in results:
name = hit.get("name", "?")
description = _truncate(hit.get("description", ""))
stars = int(hit.get("star_count", 0) or 0)
downloads = int(hit.get("downloads", 0) or 0)
official = " [official]" if hit.get("is_official") else ""
lines.append(f" {stars:>5} {downloads:>9} {name}{official}")
if description:
lines.append(f" {description}")
if total > len(results):
lines.append("")
lines.append(f"Showing {len(results)} of {total}; raise limit to see more.")
return "\n".join(lines)
@mcp.tool()
async def list_image_tags(repository: str, limit: int = 50):
"""List available tags for a registry image (e.g. 'nginx', 'grafana/grafana')."""
if limit < 1:
limit = 1
try:
data = await client.request(
"SYNO.Docker.Registry",
"tags",
version=1,
params={"repo": json.dumps(repository)},
)
except Exception as e:
return f"Error fetching tags for '{repository}': {e}"
# DSM returns the tag list as the envelope's `data` field directly.
# When empty, DsmClient.request() coerces to {} via `or {}`, so we
# accept both shapes here.
if isinstance(data, list):
entries: list[Any] = data
elif isinstance(data, dict):
# Defensive: some DSM versions wrap as {"tags": [...]}.
entries = data.get("tags") or data.get("data") or []
else:
entries = []
tags: list[str] = []
for entry in entries:
if isinstance(entry, dict):
tag = entry.get("tag")
if isinstance(tag, str) and tag:
tags.append(tag)
elif isinstance(entry, str):
tags.append(entry)
if not tags:
return f"No tags found for '{repository}'."
total = len(tags)
shown = tags[:limit]
lines = [f"Tags for '{repository}' ({total} total):", ""]
lines.extend(f" {t}" for t in shown)
if total > limit:
lines.append("")
lines.append(f"Showing first {limit} of {total}; raise limit to see more.")
return "\n".join(lines)
@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."""
target = f"{repository}:{tag}"
if not confirmed:
return (
f"Preview: would pull {target} from the active registry.\n"
f"Call pull_image(repository={repository!r}, tag={tag!r}, "
"confirmed=True) to proceed."
)
# Short-circuit: if the image already exists locally, no pull needed.
if await _image_present(client, repository, tag):
return f"{target} is already present locally — nothing to pull."
# `pull_start` lives on SYNO.Docker.Image, NOT SYNO.Docker.Registry
# (live DSM capture, confirmed). 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".
try:
await client.request(
"SYNO.Docker.Image",
"pull_start",
version=1,
params={
"repository": json.dumps(repository),
"tag": json.dumps(tag),
},
)
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
return (
f"Pull of {target} started, still running — "
f"verify later with list_images or check_image_updates."
)
+134 -1
View File
@@ -15,6 +15,11 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Built-in Docker networks are never removed by `docker network prune`
# regardless of attached-container count. Skip them when counting what
# the prune will actually delete.
_BUILTIN_NETWORKS = frozenset({"bridge", "host", "none"})
def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all system-level tools with the MCP server."""
@@ -88,6 +93,114 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
return "\n".join(lines)
@mcp.tool()
async def system_overview():
"""Aggregated CPU, memory, network, and block I/O across all running containers."""
errors: list[str] = []
# ── Container list (for running/stopped counts) ──────────────────────
containers: list[dict[str, Any]] = []
try:
ctr_data = await client.request(
"SYNO.Docker.Container",
"list",
params={"limit": "-1", "offset": "0", "type": "all"},
)
containers = ctr_data.get("containers", []) or []
except Exception as e:
errors.append(f"list: {e}")
running = sum(1 for c in containers if c.get("status") in ("running", "up"))
stopped = len(containers) - running
# ── Stats (for aggregation) ──────────────────────────────────────────
stats_data: dict[str, Any] = {}
try:
stats_data = await client.request("SYNO.Docker.Container", "stats") or {}
except Exception as e:
errors.append(f"stats: {e}")
total_cpu_pct = 0.0
total_mem_usage = 0
total_mem_limit = 0
total_net_rx = 0
total_net_tx = 0
total_blk_read = 0
total_blk_write = 0
aggregated_count = 0
for entry in stats_data.values():
if not isinstance(entry, dict):
continue
aggregated_count += 1
# CPU % — Docker formula, mirrors container_stats logic
cpu_stats = entry.get("cpu_stats", {}) or {}
precpu_stats = entry.get("precpu_stats", {}) or {}
cpu_usage = cpu_stats.get("cpu_usage", {}) or {}
precpu_usage = precpu_stats.get("cpu_usage", {}) or {}
cpu_delta = cpu_usage.get("total_usage", 0) - precpu_usage.get("total_usage", 0)
system_delta = cpu_stats.get("system_cpu_usage", 0) - precpu_stats.get(
"system_cpu_usage", 0
)
online_cpus = cpu_stats.get("online_cpus") or len(cpu_usage.get("percpu_usage") or [1])
if system_delta > 0 and cpu_delta >= 0:
total_cpu_pct += (cpu_delta / system_delta) * online_cpus * 100.0
# Memory
mem_stats = entry.get("memory_stats", {}) or {}
total_mem_usage += mem_stats.get("usage", 0)
total_mem_limit += mem_stats.get("limit", 0)
# Network I/O
for iface in (entry.get("networks") or {}).values():
if not isinstance(iface, dict):
continue
total_net_rx += iface.get("rx_bytes", 0)
total_net_tx += iface.get("tx_bytes", 0)
# Block I/O
for entry_io in (entry.get("blkio_stats", {}) or {}).get(
"io_service_bytes_recursive"
) or []:
if not isinstance(entry_io, dict):
continue
op = (entry_io.get("op") or "").lower()
val = entry_io.get("value", 0)
if op == "read":
total_blk_read += val
elif op == "write":
total_blk_write += val
# ── Format output ────────────────────────────────────────────────────
lines = ["Docker System Overview", ""]
lines.append(" Containers:")
lines.append(f" {running:>3} running")
lines.append(f" {stopped:>3} stopped")
if aggregated_count > 0:
mem_limit_str = _human_size(total_mem_limit) if total_mem_limit else ""
lines.append("")
lines.append(" Aggregated (running containers):")
lines.append(f" CPU: {total_cpu_pct:.2f}%")
lines.append(f" Memory: {_human_size(total_mem_usage)} / {mem_limit_str}")
lines.append(
f" Net I/O: rx {_human_size(total_net_rx)} / tx {_human_size(total_net_tx)}"
)
lines.append(
f" Block I/O: read {_human_size(total_blk_read)}"
f" / write {_human_size(total_blk_write)}"
)
if errors:
lines.append("")
lines.append(" Warnings:")
for err in errors:
lines.append(f" {err}")
return "\n".join(lines)
@mcp.tool()
async def system_prune(confirmed: bool = False):
"""Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
@@ -132,6 +245,21 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
dangling_size = sum(img.get("size", 0) for img in dangling_images)
if not confirmed:
# M-6: also enumerate networks that would be removed so the
# preview matches the actual prune scope. Networks are only
# fetched in preview mode — the prune call itself doesn't
# need them.
unused_networks: list[dict[str, Any]] = []
try:
net_data = await client.request("SYNO.Docker.Network", "list")
for net in net_data.get("network", []) or []:
name = net.get("name", "")
attached = net.get("containers") or []
if not attached and name not in _BUILTIN_NETWORKS:
unused_networks.append(net)
except Exception as e:
logger.debug("Could not fetch networks for prune preview: %s", e)
lines = ["system_prune — preview (nothing deleted yet):", ""]
lines.append(
f" Dangling/unused images: {len(dangling_images)} ({_human_size(dangling_size)})"
@@ -149,7 +277,12 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
if len(stopped_containers) > 10:
lines.append(f" … and {len(stopped_containers) - 10} more")
lines.append(" Unused networks: (not counted — run prune to remove)")
lines.append(f" Unused networks: {len(unused_networks)}")
for net in unused_networks[:10]:
driver = net.get("driver", "?")
lines.append(f" - {net.get('name', '?')} ({driver})")
if len(unused_networks) > 10:
lines.append(f" … and {len(unused_networks) - 10} more")
lines.append("")
lines.append(
f"Call system_prune(confirmed=True) to free ~{_human_size(dangling_size)}."
+2
View File
@@ -31,6 +31,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
from mcp_synology_container.modules.images import register_images
from mcp_synology_container.modules.networks import register_networks
from mcp_synology_container.modules.projects import register_projects
from mcp_synology_container.modules.registry import register_registry
from mcp_synology_container.modules.system import register_system
register_projects(mcp, config, client)
@@ -39,6 +40,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
register_images(mcp, config, client)
register_system(mcp, config, client)
register_networks(mcp, config, client)
register_registry(mcp, config, client)
logger.info("MCP server configured with all tool modules")
return mcp
+19 -12
View File
@@ -1,9 +1,10 @@
"""Tests for auth.py."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
from mcp_synology_container.auth import AuthManager, AuthenticationError
import pytest
from mcp_synology_container.auth import AuthenticationError, AuthManager
from mcp_synology_container.config import AppConfig, ConnectionConfig
@@ -34,9 +35,11 @@ def test_resolve_credentials_no_credentials(monkeypatch):
config = make_config()
auth = AuthManager(config)
with patch("keyring.get_password", return_value=None):
with pytest.raises(AuthenticationError, match="No credentials found"):
auth.resolve_credentials()
with (
patch("keyring.get_password", return_value=None),
pytest.raises(AuthenticationError, match="No credentials found"),
):
auth.resolve_credentials()
def test_resolve_credentials_from_keyring(monkeypatch):
@@ -123,9 +126,11 @@ async def test_login_2fa_required():
mock_client = AsyncMock()
mock_client.request.side_effect = SynologyError("2FA required", code=403)
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
with pytest.raises(AuthenticationError, match="2FA is required"):
await auth.login(mock_client)
with (
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
pytest.raises(AuthenticationError, match="2FA is required"),
):
await auth.login(mock_client)
@pytest.mark.asyncio
@@ -136,9 +141,11 @@ async def test_login_no_sid_returned():
mock_client = AsyncMock()
mock_client.request.return_value = {} # No 'sid' key
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
with pytest.raises(AuthenticationError, match="no session ID"):
await auth.login(mock_client)
with (
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
pytest.raises(AuthenticationError, match="no session ID"),
):
await auth.login(mock_client)
@pytest.mark.asyncio
+1 -2
View File
@@ -2,13 +2,12 @@
import pytest
import yaml
from pathlib import Path
from mcp_synology_container.config import (
AppConfig,
ConnectionConfig,
_validate_config,
_merge_env_overrides,
_validate_config,
load_config,
save_config,
)
File diff suppressed because it is too large Load Diff
+120
View File
@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock
import pytest
import yaml
from mcp_synology_container.modules.compose import _validate_project_name
def make_mock_mcp():
tools: dict = {}
@@ -345,3 +347,121 @@ def test_extract_version_prefix():
assert _extract_version_prefix("1.24") is None # no suffix
assert _extract_version_prefix("") is None
assert _extract_version_prefix("v2.0-rc1") is None # starts with 'v'
# ──────────────────────────────────────────────────────────────────────
# _validate_project_name — path-traversal guard
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"name",
[
"myapp",
"MyApp",
"my-app",
"my_app",
"app123",
"A",
"1",
"snake_case-and-dash_42",
],
)
def test_validate_project_name_accepts_safe_names(name: str) -> None:
assert _validate_project_name(name) is None
@pytest.mark.parametrize(
"name",
[
"", # empty
"../etc", # parent traversal
"../../etc/passwd", # multi-level traversal
"foo/../bar", # embedded traversal
"foo/bar", # forward slash
"foo\\bar", # backslash
".", # bare dot
"..", # bare dotdot
".hidden", # leading dot
"foo.bar", # dot inside (.yaml extensions, etc.)
"foo bar", # whitespace
" foo", # leading space
"foo ", # trailing space
"foo\tbar", # tab
"foo\nbar", # newline
"foo;rm", # shell metachar
"foo|bar",
"foo&bar",
"foo*bar",
"foo?bar",
"foo:bar",
"foo'bar",
'foo"bar',
"foo$bar",
"foo`bar",
"café", # non-ASCII letter
],
)
def test_validate_project_name_rejects_unsafe_names(name: str) -> None:
msg = _validate_project_name(name)
assert msg is not None
assert "invalid project name" in msg
@pytest.mark.asyncio
async def test_read_compose_rejects_traversal_name() -> None:
"""Traversal name must be rejected before any DSM call."""
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["read_compose"]("../../etc")
assert "invalid project name" in result
client.request.assert_not_called()
client.download_text.assert_not_called()
@pytest.mark.asyncio
async def test_update_compose_rejects_traversal_name() -> None:
"""update_compose with a traversal name must not validate YAML or upload."""
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_compose"](
"foo/../bar", "services:\n web:\n image: nginx\n", confirmed=True
)
assert "invalid project name" in result
client.upload_text.assert_not_called()
@pytest.mark.asyncio
async def test_update_image_tag_rejects_traversal_name() -> None:
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_image_tag"]("foo/bar", "web", "1.25", confirmed=True)
assert "invalid project name" in result
client.upload_text.assert_not_called()
client.download_text.assert_not_called()
@pytest.mark.asyncio
async def test_update_env_var_rejects_traversal_name() -> None:
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_env_var"]("..", "web", "FOO", "bar", confirmed=True)
assert "invalid project name" in result
client.upload_text.assert_not_called()
client.download_text.assert_not_called()
+398
View File
@@ -317,6 +317,32 @@ async def test_delete_container_stopped_confirmed():
assert "myapp_web" in result
@pytest.mark.asyncio
async def test_delete_container_sends_required_params():
"""SYNO.Docker.Container/delete must include name (json-encoded), force=false,
and preserve_profile=false — without them DSM returns error 114."""
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {
"details": {"State": {"Running": False, "Status": "exited"}},
"profile": {"image": "nginx:latest"},
}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
await tools["delete_container"]("myapp_web", confirmed=True)
# Find the delete call (second call — first is Container/get)
delete_calls = [c for c in client.request.call_args_list if c.args[1] == "delete"]
assert len(delete_calls) == 1
params = delete_calls[0].kwargs.get("params") or {}
assert params["name"] == '"myapp_web"'
assert params["force"] == "false"
assert params["preserve_profile"] == "false"
@pytest.mark.asyncio
async def test_container_stats_cpu_calculation():
"""CPU% is computed via the standard Docker formula."""
@@ -597,6 +623,230 @@ async def test_get_container_status_shows_mounts():
assert "/var/jenkins_home" in result
# ──────────────────────────────────────────────────────────────────────────────
# inspect_container
# ──────────────────────────────────────────────────────────────────────────────
# Live-captured-style response (homeassistant) — proves that
# details.Mounts[].Source carries the full /volume1/... path while
# profile.volume_bindings[].host_volume_file is share-relative.
INSPECT_RESPONSE = {
"details": {
"Config": {
"Image": "homeassistant/home-assistant:stable",
"Entrypoint": ["/init"],
"Cmd": None,
"Env": ["TZ=Europe/Berlin"],
},
"HostConfig": {
"RestartPolicy": {"Name": "always", "MaximumRetryCount": 0},
"NetworkMode": "frostiq_net",
"Privileged": False,
"CapAdd": None,
"CapDrop": None,
"Binds": ["/volume1/docker/homeassistant:/config:rw"],
},
"Mounts": [
{
"Type": "bind",
"Source": "/volume1/docker/homeassistant",
"Destination": "/config",
"RW": True,
}
],
"NetworkSettings": {
"Networks": {
"frostiq_net": {"IPAddress": "172.18.0.5"},
}
},
"RestartCount": 0,
"State": {"Status": "running", "Running": True},
},
"profile": {
"image": "homeassistant/home-assistant:stable",
"name": "homeassistant",
"enable_restart_policy": True,
"network_mode": "frostiq_net",
"use_host_network": False,
"port_bindings": [{"container_port": 8123, "host_port": 8123, "type": "tcp"}],
# Share-relative — must NOT be the path the tool reports.
"volume_bindings": [
{
"host_volume_file": "/docker/homeassistant",
"mount_point": "/config",
"type": "rw",
"is_directory": True,
}
],
"env_variables": [
{"key": "TZ", "value": "Europe/Berlin"},
{"key": "LANG", "value": "C.UTF-8"},
],
"labels": {"io.hass.type": "core", "io.hass.version": "2026.2.3"},
"links": [],
"cmd": "",
"cmd_v2": "",
"privileged": False,
"CapAdd": None,
"CapDrop": None,
},
}
@pytest.mark.asyncio
async def test_inspect_container_uses_full_host_path():
"""inspect_container must report /volume1/docker/... (full) — not
/docker/... (share-relative) — for volume mounts. The compose-rebuild
workflow depends on the full host path."""
from mcp_synology_container.modules.containers import register_containers
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return {"containers": [{"name": "homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
return INSPECT_RESPONSE
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
assert "/volume1/docker/homeassistant" in result
# The share-relative shortcut must not appear as a mount source.
assert " /docker/homeassistant " not in result # not as standalone path
assert "→ /config" in result
@pytest.mark.asyncio
async def test_inspect_container_shows_core_fields():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = INSPECT_RESPONSE
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
# Header
assert "Container: homeassistant" in result
assert "homeassistant/home-assistant:stable" in result
assert "running" in result
# Restart policy
assert "always" in result
# Network
assert "frostiq_net" in result
assert "172.18.0.5" in result
# Ports
assert "8123" in result
# Env
assert "TZ=Europe/Berlin" in result
# Labels
assert "io.hass.version=2026.2.3" in result
# Entrypoint
assert "/init" in result
@pytest.mark.asyncio
async def test_inspect_container_calls_get_with_json_name():
"""inspect_container must send name= as a JSON-encoded string (DSM
Container/get is documented to accept both but json.dumps keeps the
convention shared with start/stop/restart/delete)."""
from mcp_synology_container.modules.containers import register_containers
seen: dict[str, object] = {}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return {"containers": [{"name": "homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
seen["params"] = kwargs.get("params")
return INSPECT_RESPONSE
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
await tools["inspect_container"]("homeassistant")
assert seen["params"] == {"name": '"homeassistant"'}
@pytest.mark.asyncio
async def test_inspect_container_resolves_hash_prefix():
"""If DSM stores the container as 'abcdef012345_homeassistant', a user
request for 'homeassistant' must resolve to the prefixed name and the
displayed header must show the clean name."""
from mcp_synology_container.modules.containers import register_containers
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return {"containers": [{"name": "abcdef012345_homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
assert kwargs["params"]["name"] == '"abcdef012345_homeassistant"'
return INSPECT_RESPONSE
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
assert "Container: homeassistant" in result
assert "abcdef012345" not in result
@pytest.mark.asyncio
async def test_inspect_container_not_found():
from mcp_synology_container.modules.containers import register_containers
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return {"containers": []}
if api == "SYNO.Docker.Container" and method == "get":
return None
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("ghost")
assert "not found" in result
@pytest.mark.asyncio
async def test_inspect_container_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.containers import register_containers
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return {"containers": [{"name": "homeassistant"}]}
if api == "SYNO.Docker.Container" and method == "get":
raise SynologyError("API error", code=102)
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["inspect_container"]("homeassistant")
assert "Error" in result
assert "homeassistant" in result
@pytest.mark.asyncio
async def test_get_container_logs_resolves_hash_prefix():
"""get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call."""
@@ -678,3 +928,151 @@ async def test_container_stats_no_precpu_graceful():
result = await tools["container_stats"]("myapp")
assert "0.00%" in result # graceful fallback to 0
# ──────────────────────────────────────────────────────────────────────────────
# Lifecycle: start / stop / restart
# ──────────────────────────────────────────────────────────────────────────────
# Tools that don't have a confirmation gate
_NO_CONFIRM_LIFECYCLE = [
("start_container", "start", "Started"),
]
# Tools that require confirmed=True
_CONFIRM_LIFECYCLE = [
("stop_container", "stop", "Stopped", "stop"),
("restart_container", "restart", "Restarted", "restart"),
]
@pytest.mark.parametrize("tool_name, dsm_method, success_word", _NO_CONFIRM_LIFECYCLE)
@pytest.mark.asyncio
async def test_lifecycle_no_confirm_calls_dsm(tool_name, dsm_method, success_word):
"""start_container calls DSM directly with json-encoded name."""
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools[tool_name]("myapp_web")
# Find the matching lifecycle call (a list() may also occur via _resolve_container_name)
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
assert len(calls) == 1
call = calls[0]
assert call.args[0] == "SYNO.Docker.Container"
assert call.kwargs["version"] == 1
assert call.kwargs["params"] == {"name": '"myapp_web"'}
assert success_word in result
assert "myapp_web" in result
@pytest.mark.parametrize("tool_name, dsm_method, success_word, _action", _CONFIRM_LIFECYCLE)
@pytest.mark.asyncio
async def test_lifecycle_confirmed_calls_dsm(tool_name, dsm_method, success_word, _action):
"""stop/restart call DSM with confirmed=True using json-encoded name."""
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools[tool_name]("myapp_web", confirmed=True)
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
assert len(calls) == 1
call = calls[0]
assert call.args[0] == "SYNO.Docker.Container"
assert call.kwargs["version"] == 1
assert call.kwargs["params"] == {"name": '"myapp_web"'}
assert success_word in result
assert "myapp_web" in result
@pytest.mark.parametrize("tool_name, _dsm_method, _success_word, action", _CONFIRM_LIFECYCLE)
@pytest.mark.asyncio
async def test_lifecycle_preview_without_confirmation(
tool_name, _dsm_method, _success_word, action
):
"""stop/restart without confirmed=True must NOT call DSM."""
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools[tool_name]("myapp_web")
assert "Preview" in result
assert action in result
assert "myapp_web" in result
assert "confirmed=True" in result
client.request.assert_not_called()
@pytest.mark.parametrize(
"tool_name, dsm_method, kwargs",
[
("start_container", "start", {}),
("stop_container", "stop", {"confirmed": True}),
("restart_container", "restart", {"confirmed": True}),
],
)
@pytest.mark.asyncio
async def test_lifecycle_resolves_hash_prefix(tool_name, dsm_method, kwargs):
"""All lifecycle tools resolve 'jenkins' to 'f93cb8b504f7_jenkins' for the DSM call."""
from mcp_synology_container.modules.containers import register_containers
async def mock_request(api, method, **kw):
if api == "SYNO.Docker.Container" and method == "list":
return HASH_PREFIXED_CONTAINERS_DATA
if api == "SYNO.Docker.Container" and method == dsm_method:
assert kw["params"]["name"] == '"f93cb8b504f7_jenkins"'
assert kw["version"] == 1
return {}
return {}
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
# User passes the clean name; resolved to the hash-prefixed name for DSM,
# but the display name in the success message must be hash-stripped.
result = await tools[tool_name]("jenkins", **kwargs)
assert "jenkins" in result
assert "f93cb8b504f7" not in result
@pytest.mark.parametrize(
"tool_name, kwargs, error_verb",
[
("start_container", {}, "starting"),
("stop_container", {"confirmed": True}, "stopping"),
("restart_container", {"confirmed": True}, "restarting"),
],
)
@pytest.mark.asyncio
async def test_lifecycle_api_error(tool_name, kwargs, error_verb):
"""API errors during lifecycle ops return a human-readable error message."""
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.side_effect = SynologyError("API error", code=102)
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools[tool_name]("myapp_web", **kwargs)
assert "Error" in result
assert error_verb in result
assert "myapp_web" in result
+290
View File
@@ -411,6 +411,296 @@ async def test_delete_image_api_error():
assert "114" in result
# ──────────────────────────────────────────────────────────────────────────────
# inspect_image
# ──────────────────────────────────────────────────────────────────────────────
# Authoritative DSM SYNO.Docker.Image/get response shape (v1).
# Live-captured peculiarities:
# - `image` and `tag` come back as empty strings when the lookup is
# by name:tag — the header therefore has to fall back to image_id.
# - `ports` is an array of {"port", "protocol"} dicts, NOT strings.
# - `env` is an array of {"key", "value"} dicts, NOT strings.
# - `volumes` and `cmd`/`entrypoint` are plain string arrays.
# - There is no `layers` field on this endpoint.
SAMPLE_INSPECT = {
"image": "",
"tag": "",
"id": "sha256:aaaa1234567890abcdef",
"digest": "sha256:digestabcdef",
"size": 50 * 1024 * 1024,
"virtual_size": 50 * 1024 * 1024,
"author": "NGINX Team",
"docker_version": "20.10.0",
"cmd": ["nginx", "-g", "daemon off;"],
"entrypoint": ["/docker-entrypoint.sh"],
"env": [
{"key": "NGINX_VERSION", "value": "1.24"},
{"key": "PATH", "value": "/usr/local/sbin"},
],
"ports": [
{"port": "80", "protocol": "tcp"},
{"port": "443", "protocol": "tcp"},
],
"volumes": ["/var/cache/nginx"],
}
def _make_inspect_client(inspect_payload=None):
"""Build a mock DsmClient that returns inspect_payload for SYNO.Docker.Image/get."""
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "get":
return inspect_payload if inspect_payload is not None else SAMPLE_INSPECT
return {}
client.request.side_effect = mock_request
return client
@pytest.mark.asyncio
async def test_inspect_image_by_name_tag():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "nginx" in result
assert "1.24" in result
assert "MiB" in result # size formatted via _human_size
@pytest.mark.asyncio
async def test_inspect_image_by_hash():
from mcp_synology_container.modules.images import register_images
# Inspect data shaped for redis returned by hash lookup.
# image/tag come back empty (matches live DSM behavior) — display
# falls back to image_id, then to the id hash.
redis_inspect = {
"image": "",
"tag": "",
"id": "sha256:ccccredis",
"digest": "sha256:rdigest",
"size": 30 * 1024 * 1024,
"virtual_size": 30 * 1024 * 1024,
"cmd": ["redis-server"],
"entrypoint": [],
"env": [],
"ports": [{"port": "6379", "protocol": "tcp"}],
"volumes": [],
}
client = _make_inspect_client(inspect_payload=redis_inspect)
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="sha256:cccc")
# The header echoes the user-supplied image_id; "redis" surfaces via the cmd line.
assert "sha256:cccc" in result
assert "redis" in result
assert "6379/tcp" in result
@pytest.mark.asyncio
async def test_inspect_image_header_uses_image_id_when_fields_empty():
"""DSM Image/get returns image='' and tag='' for name:tag lookups — the header
must fall back to the user-supplied image_id, NOT render '?:?'."""
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client() # SAMPLE_INSPECT has image='' / tag=''
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="gitea/gitea:1.26.1")
assert "Image: gitea/gitea:1.26.1" in result
assert "?:?" not in result
@pytest.mark.asyncio
async def test_inspect_image_header_falls_back_to_id_when_image_id_empty():
"""If image_id were empty (defensive — should not happen in practice), the
header falls back to the sha256 id field."""
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client() # SAMPLE_INSPECT.id == sha256:aaaa1234567890abcdef
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="")
assert "Image: sha256:aaaa1234567890abcdef" in result
@pytest.mark.asyncio
async def test_inspect_image_uses_identity_param():
"""DSM SYNO.Docker.Image/get expects parameter 'identity' (JSON-encoded), version=1.
Using name/tag/id (the old shape) returned DSM error 114 — this test guards
against regressing to that contract.
"""
import json
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
await tools["inspect_image"](image_id="nginx:1.24")
get_calls = [
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
]
assert len(get_calls) == 1
call = get_calls[0]
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params") or {}
assert params == {"identity": json.dumps("nginx:1.24")}
@pytest.mark.asyncio
async def test_inspect_image_empty_response_treated_as_not_found():
"""An empty response dict surfaces as a clean 'not found' message."""
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client(inspect_payload={})
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="bogus:latest")
assert "not found" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_env_vars():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "NGINX_VERSION=1.24" in result
assert "PATH=/usr/local/sbin" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_exposed_ports():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "80/tcp" in result
assert "443/tcp" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_volumes():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "/var/cache/nginx" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_entrypoint_cmd():
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "/docker-entrypoint.sh" in result
assert "nginx" in result
assert "daemon off;" in result
@pytest.mark.asyncio
async def test_inspect_image_shows_digest_author_docker_version():
"""Identity-block fields specific to the DSM Image/get response are surfaced."""
from mcp_synology_container.modules.images import register_images
client = _make_inspect_client()
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "sha256:digestabcdef" in result
assert "NGINX Team" in result
assert "20.10.0" in result
@pytest.mark.asyncio
async def test_inspect_image_registry_prefixed():
"""A registry-prefixed identifier like 'ghcr.io/foo/bar:v1' is passed through verbatim
in the JSON-encoded identity parameter."""
import json
from mcp_synology_container.modules.images import register_images
registry_inspect = {
# image / tag come back empty for name:tag lookups (live DSM behavior).
"image": "",
"tag": "",
"id": "sha256:dddd",
"digest": "sha256:gdigest",
"size": 100 * 1024 * 1024,
"virtual_size": 100 * 1024 * 1024,
"cmd": ["/app"],
"entrypoint": [],
"env": [],
"ports": [],
"volumes": [],
}
client = _make_inspect_client(inspect_payload=registry_inspect)
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="ghcr.io/foo/bar:v1")
assert "ghcr.io/foo/bar" in result
assert "v1" in result
# The full identifier (with ':') is JSON-encoded into a single string —
# registry-prefixed names are not split into name + tag.
get_calls = [
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
]
assert get_calls
params = get_calls[0].kwargs.get("params") or {}
assert params.get("identity") == json.dumps("ghcr.io/foo/bar:v1")
@pytest.mark.asyncio
async def test_inspect_image_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "get":
raise SynologyError("inspect failed", code=120)
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["inspect_image"](image_id="nginx:1.24")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# check_image_updates (existing tests preserved)
# ──────────────────────────────────────────────────────────────────────────────
+643 -1
View File
@@ -210,6 +210,7 @@ def make_stateful_redeploy_mock(
initial_status: str,
stop_raises=None,
build_stream_raises=None,
build_stream_log: str = "",
):
"""Create a stateful client mock for redeploy tests.
@@ -235,6 +236,7 @@ def make_stateful_redeploy_mock(
if build_stream_raises:
raise build_stream_raises
build_done = True # After build_stream, polling returns RUNNING
return build_stream_log
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream)
@@ -346,6 +348,7 @@ async def test_redeploy_poll_timeout():
async def mock_build_stream(project_id):
nonlocal build_done
build_done = True
return ""
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
@@ -374,10 +377,649 @@ async def test_redeploy_unknown_status_returns_error():
return {}
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock()
client.trigger_build_stream = AsyncMock(return_value="")
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "UPDATING" in result
assert "Workaround" in result or "stop_project" in result
# ──────────────────────────────────────────────────────────────────────
# M-4: clear recovery hint when build_stream fails after stop succeeded
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_redeploy_build_stream_transport_error_shows_stopped_recovery_hint():
"""M-4: build_stream transport error after RUNNING-stop must tell the user the
project is now STOPPED and recommend start_project / retry."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_raises=SynologyError(
"build_stream transport error: ConnectError: nas offline", code=0
),
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
# No raw stack trace — clean message
assert "transport error" in result
assert "ConnectError" in result
# The recovery hint must point at the actual situation
assert "STOPPED" in result
assert "start_project" in result
# Old misleading workaround text must NOT appear
assert "stop_project + start_project separately" not in result
@pytest.mark.asyncio
async def test_redeploy_build_stream_error_on_stopped_project_keeps_old_workaround():
"""If the project was STOPPED to begin with, no stop was issued, so the
'STOPPED recovery' hint is NOT appropriate keep the original workaround."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_stateful_redeploy_mock(
"STOPPED",
build_stream_raises=SynologyError("build failed", code=114),
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "build failed" in result or "Error during redeploy" in result
# Stop was never issued; new recovery hint should not appear
assert "was stopped before this error" not in result
# ──────────────────────────────────────────────────────────────────────
# Issue #2: surface build_stream daemon errors directly
# ──────────────────────────────────────────────────────────────────────
def test_parse_build_stream_log_extracts_errors_and_info():
"""The helper splits a streamed build log into error and info lines."""
from mcp_synology_container.modules.projects import _parse_build_stream_log
log = (
"Container vault Pulling\n"
"nginx Error\n"
"Error response from daemon: manifest for nginx:9.9.9 not found: "
"manifest unknown\n"
"\n"
"Container vault Running\n"
)
errors, info = _parse_build_stream_log(log)
assert errors == [
"nginx Error",
("Error response from daemon: manifest for nginx:9.9.9 not found: manifest unknown"),
]
assert info == ["Container vault Pulling", "Container vault Running"]
def test_parse_build_stream_log_clean_log_no_errors():
from mcp_synology_container.modules.projects import _parse_build_stream_log
log = "Container vault Running\nContainer vault-db Running\n"
errors, info = _parse_build_stream_log(log)
assert errors == []
assert info == ["Container vault Running", "Container vault-db Running"]
@pytest.mark.asyncio
async def test_redeploy_surfaces_build_stream_daemon_error():
"""build_stream log containing 'Error response from daemon:' aborts redeploy
with that line visible and skips the polling step entirely."""
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_log=(
"nginx Error\n"
"Error response from daemon: manifest for nginx:9.9.9-nonexistent "
"not found: manifest unknown\n"
),
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "Build failed" in result
assert "Error response from daemon: manifest for nginx:9.9.9-nonexistent" in result
assert "redeploy aborted" in result
assert "redeployed successfully" not in result
# Recovery hint must appear because the project was stopped first.
assert "was stopped before this error" in result
# The polling step must NOT run — build_stream errors short-circuit.
methods = [m for _, m in calls]
list_calls = [m for m in methods if m == "list"]
assert len(list_calls) == 1 # only the initial _find_project lookup
@pytest.mark.asyncio
async def test_redeploy_clean_log_proceeds_to_polling():
"""A clean build_stream log keeps the original happy-path behavior."""
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_log="Container myapp Pulling\nContainer myapp Running\n",
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result
@pytest.mark.asyncio
async def test_create_project_surfaces_build_stream_daemon_error():
"""build_stream log with daemon error → registered-but-failed-to-build hint."""
client, calls = make_create_project_client(
build_stream_log=(
"web Error\n"
"Error response from daemon: manifest for nginx:0.0.0-bad not found: "
"manifest unknown\n"
),
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "Build failed" in result
assert "Error response from daemon: manifest for nginx:0.0.0-bad" in result
assert "is registered but failed to build" in result
assert "redeploy_project" in result
assert "created and started successfully" not in result
# ──────────────────────────────────────────────────────────────────────
# M-5: polling exits early on BUILD_FAILED / ERROR
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wait_for_project_running_returns_early_on_build_failed():
"""_wait_for_project_running must exit as soon as DSM reports BUILD_FAILED,
not wait the full timeout."""
from mcp_synology_container.modules.projects import _wait_for_project_running
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("BUILD_FAILED")
return {}
client.request.side_effect = mock_request
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
# 100s timeout, 2s interval — if the early-exit isn't there the test
# would still terminate quickly because sleep is mocked, but the call
# count assertion below catches a non-exiting loop.
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
assert result == "BUILD_FAILED"
# Only a few list() calls — exit was on the first poll iteration.
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
assert len(list_calls) <= 2
@pytest.mark.asyncio
async def test_wait_for_project_running_returns_early_on_error():
from mcp_synology_container.modules.projects import _wait_for_project_running
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("ERROR")
return {}
client.request.side_effect = mock_request
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
assert result == "ERROR"
@pytest.mark.asyncio
async def test_redeploy_surfaces_build_failed_with_hint():
"""When polling reports BUILD_FAILED, redeploy_project must include a clear
hint to inspect the image tag and retry."""
client = AsyncMock()
build_done = False
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("BUILD_FAILED") if build_done else project_list("RUNNING")
return {}
async def mock_build_stream(project_id):
nonlocal build_done
build_done = True
return "" # Clean stream log — failure surfaces via polling.
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "Redeploy failed" in result
assert "BUILD_FAILED" in result
assert "update_image_tag" in result
assert "redeployed successfully" not in result
# Polling must have exited early, not run to the full timeout.
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
# Generous upper bound — early exit means handful of polls, not hundreds.
assert len(list_calls) <= 5
# ──────────────────────────────────────────────────────────────────────
# create_project
# ──────────────────────────────────────────────────────────────────────
SIMPLE_COMPOSE = """
services:
web:
image: nginx:1.25
worker:
image: redis:7
"""
def make_create_project_client(
*,
existing_projects: dict | None = None,
create_folder_raises: Exception | None = None,
create_project_raises: Exception | None = None,
build_stream_raises: Exception | None = None,
build_stream_log: str = "",
project_id: str = "uuid-new",
final_status: str = "RUNNING",
):
"""Build a stateful mock client for create_project tests.
Tracks:
- whether Docker.Project/create has been called (so post_create_calls
to /list return the newly-registered project at `final_status`)
- which API/method/version each call used
"""
client = AsyncMock()
calls: list[tuple[str, str, dict]] = []
project_created = False
async def mock_request(api, method, version=None, params=None, **kwargs):
calls.append((api, method, dict(params or {})))
if api == "SYNO.Docker.Project" and method == "list":
if project_created:
return {
project_id: {
"id": project_id,
"name": "newapp",
"status": final_status,
"path": "/volume1/docker/newapp",
"containerIds": [],
"services": [],
}
}
return existing_projects or {}
if api == "SYNO.FileStation.CreateFolder":
if create_folder_raises:
raise create_folder_raises
return {}
return {}
async def mock_post_request(api, method, version=None, params=None, **kwargs):
nonlocal project_created
calls.append((api, f"POST:{method}", dict(params or {})))
if api == "SYNO.Docker.Project" and method == "create":
if create_project_raises:
raise create_project_raises
project_created = True
return {"id": project_id}
return {}
async def mock_build_stream(pid):
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
if build_stream_raises:
raise build_stream_raises
return build_stream_log
client.request.side_effect = mock_request
client.post_request.side_effect = mock_post_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
return client, calls
@pytest.mark.asyncio
async def test_create_project_preview_only():
"""Without confirmed=True, no side effects — return a preview with service count."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE)
assert "confirmed=True" in result
assert "newapp" in result
assert "Services: 2" in result
assert "/docker/newapp" in result
# No CreateFolder, no Project/create, no build_stream
methods = [m for _, m, _ in calls]
assert "create" not in methods # FileStation.CreateFolder
assert "POST:create" not in methods
assert "build_stream" not in methods
@pytest.mark.asyncio
async def test_create_project_rejects_invalid_name():
"""Path-traversal-style names are rejected before any I/O."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("../escape", SIMPLE_COMPOSE, confirmed=True)
assert "invalid project name" in result.lower()
# No API calls at all
assert calls == []
@pytest.mark.asyncio
async def test_create_project_rejects_invalid_yaml():
"""Malformed compose content is rejected before any I/O."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", "this: is: not: yaml: [", confirmed=True)
assert "Invalid YAML" in result or "Invalid compose" in result
assert calls == []
@pytest.mark.asyncio
async def test_create_project_rejects_compose_without_services():
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", "version: '3'\n", confirmed=True)
assert "services" in result.lower()
assert calls == []
@pytest.mark.asyncio
async def test_create_project_already_exists():
"""If a project with the given name already exists, abort without creating anything."""
existing = {
"uuid-1": {
"id": "uuid-1",
"name": "newapp",
"status": "RUNNING",
"path": "/volume1/docker/newapp",
"containerIds": [],
"services": [],
}
}
client, calls = make_create_project_client(existing_projects=existing)
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "already exists" in result
assert "RUNNING" in result
# Only the list call should have happened
methods = [m for _, m, _ in calls]
assert "create" not in methods
assert "POST:create" not in methods
client.trigger_build_stream.assert_not_called()
@pytest.mark.asyncio
async def test_create_project_happy_path():
"""confirmed=True with no existing project: folder → create → build_stream → RUNNING."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "created and started successfully" in result
# Verify all three steps fired in the correct order
summarised = [(api, method) for api, method, _ in calls]
assert ("SYNO.FileStation.CreateFolder", "create") in summarised
assert ("SYNO.Docker.Project", "POST:create") in summarised
assert ("SYNO.Docker.Project", "build_stream") in summarised
cf_idx = summarised.index(("SYNO.FileStation.CreateFolder", "create"))
cp_idx = summarised.index(("SYNO.Docker.Project", "POST:create"))
bs_idx = summarised.index(("SYNO.Docker.Project", "build_stream"))
assert cf_idx < cp_idx < bs_idx
# Verify JSON-encoding of CreateFolder params
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
assert cf_params["folder_path"] == '"/docker"'
assert cf_params["name"] == '"newapp"'
assert cf_params["force_parent"] == "true"
# Verify JSON-encoding of Docker.Project/create params
cp_params = next(
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
)
assert cp_params["name"] == '"newapp"'
assert cp_params["share_path"] == '"/docker/newapp"'
assert cp_params["enable_service_portal"] == "false"
assert cp_params["service_portal_port"] == 0
@pytest.mark.asyncio
async def test_create_project_explicit_share_path():
"""Caller-supplied share_path overrides the derived default."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"](
"newapp",
SIMPLE_COMPOSE,
share_path="/projects/custom/newapp",
confirmed=True,
)
assert "created and started successfully" in result
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
assert cf_params["folder_path"] == '"/projects/custom"'
assert cf_params["name"] == '"newapp"'
cp_params = next(
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
)
assert cp_params["share_path"] == '"/projects/custom/newapp"'
@pytest.mark.asyncio
async def test_create_project_error_2100_surfaces_hint():
"""DSM error 2100 on Project/create returns a clear 'target folder' message."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_create_project_client(
create_project_raises=SynologyError("Folder issue", code=2100),
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "2100" in result
assert "target folder" in result.lower()
# build_stream must NOT have been called after a failed Project/create
client.trigger_build_stream.assert_not_called()
@pytest.mark.asyncio
async def test_create_project_build_stream_failure_keeps_registration():
"""If build_stream fails AFTER successful Project/create, the user is told the
project is registered-but-not-started and pointed at redeploy_project."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_create_project_client(
build_stream_raises=SynologyError("transport error", code=0),
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "registered but was not started" in result
assert "redeploy_project" in result
assert "created and started successfully" not in result
# ──────────────────────────────────────────────────────────────────────
# delete_project
# ──────────────────────────────────────────────────────────────────────
def make_delete_project_client(
*,
project: dict | None = None,
delete_raises: Exception | None = None,
):
"""Stateful mock client for delete_project tests.
- `project`: the project dict returned by Project/list. None no
project registered (simulates the "not found" case).
- `delete_raises`: optional exception raised when Project/delete is
called (used to simulate DSM refusing to delete a running project).
"""
client = AsyncMock()
calls: list[tuple[str, str, dict]] = []
project_deleted = False
async def mock_request(api, method, version=None, params=None, **kwargs):
nonlocal project_deleted
calls.append((api, method, dict(params or {})))
if api == "SYNO.Docker.Project" and method == "list":
if project is None or project_deleted:
return {}
return {project["id"]: project}
if api == "SYNO.Docker.Project" and method == "delete":
if delete_raises:
raise delete_raises
project_deleted = True
return {}
return {}
client.request.side_effect = mock_request
return client, calls
SAMPLE_PROJECT_RUNNING = {
"id": "uuid-abc",
"name": "myapp",
"status": "RUNNING",
"path": "/volume1/docker/myapp",
"share_path": "/docker/myapp",
"containerIds": ["c1"],
"services": [],
}
SAMPLE_PROJECT_STOPPED = {
**SAMPLE_PROJECT_RUNNING,
"status": "STOPPED",
"containerIds": [],
}
@pytest.mark.asyncio
async def test_delete_project_preview_only():
"""confirmed=False: no Project/delete call; preview shows UUID and warns about folder."""
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
tools = make_projects_tools(client)
result = await tools["delete_project"]("myapp")
assert "confirmed=True" in result
assert "uuid-abc" in result
assert "myapp" in result
assert "/docker/myapp" in result
# No delete call
methods = [m for _, m, _ in calls]
assert "delete" not in methods
@pytest.mark.asyncio
async def test_delete_project_not_found():
"""If the project isn't registered, return a clear 'not found' message — no delete."""
client, calls = make_delete_project_client(project=None)
tools = make_projects_tools(client)
result = await tools["delete_project"]("ghost", confirmed=True)
assert "not found" in result
assert "ghost" in result
methods = [m for _, m, _ in calls]
assert "delete" not in methods
@pytest.mark.asyncio
async def test_delete_project_rejects_invalid_name():
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
tools = make_projects_tools(client)
result = await tools["delete_project"]("../escape", confirmed=True)
assert "invalid project name" in result.lower()
# Not even a list call
assert calls == []
@pytest.mark.asyncio
async def test_delete_project_happy_path():
"""confirmed=True with a stopped project: UUID is json.dumps'd; success message
mentions both 'deleted' and the surviving folder path."""
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
tools = make_projects_tools(client)
result = await tools["delete_project"]("myapp", confirmed=True)
assert "deleted" in result
assert "registration removed" in result
assert "/docker/myapp" in result
assert "NOT deleted" in result
delete_call = next(
(a, m, p) for a, m, p in calls if a == "SYNO.Docker.Project" and m == "delete"
)
_api, _method, params = delete_call
# The UUID must arrive JSON-encoded per the reverse-engineered DSM convention.
assert params["id"] == '"uuid-abc"'
@pytest.mark.asyncio
async def test_delete_project_running_blocked_connector_side():
"""Live test showed that DSM does NOT reject Project/delete on a running project —
it silently orphans the containers. The connector must therefore block the call
itself when the project is RUNNING, without ever calling client.request(delete)."""
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_RUNNING)
tools = make_projects_tools(client)
result = await tools["delete_project"]("myapp", confirmed=True)
assert "RUNNING" in result
assert "stop_project" in result
# The delete endpoint must NOT have been called — no orphaned containers.
delete_calls = [m for _, m, _ in calls if m == "delete"]
assert delete_calls == []
+387
View File
@@ -0,0 +1,387 @@
"""Tests for modules/registry.py."""
import json
from unittest.mock import AsyncMock
import pytest
def make_mock_mcp():
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
def make_config():
from mcp_synology_container.config import AppConfig, ConnectionConfig
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
)
# ──────────────────────────────────────────────────────────────────────────────
# search_registry — Issue #5
# ──────────────────────────────────────────────────────────────────────────────
SEARCH_RESPONSE = {
"data": [
{
"name": "caddy",
"description": (
"Caddy 2 is a powerful, enterprise-ready, open source web "
"server with automatic HTTPS written in Go."
),
"downloads": 650989718,
"is_automated": False,
"is_official": True,
"star_count": 881,
"registry": "https://registry.hub.docker.com",
},
{
"name": "abiosoft/caddy",
"description": "Caddy is a lightweight, general-purpose web server.",
"downloads": 111974910,
"is_automated": True,
"is_official": False,
"star_count": 289,
"registry": "https://registry.hub.docker.com",
},
],
"limit": 20,
"offset": 0,
"page_size": 20,
"total": 6647,
}
@pytest.mark.asyncio
async def test_search_registry_renders_hits():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = SEARCH_RESPONSE
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="caddy")
assert "caddy" in result
assert "abiosoft/caddy" in result
assert "[official]" in result
assert "881" in result # stars
assert "650989718" in result # downloads
assert "2 of 6647" in result
@pytest.mark.asyncio
async def test_search_registry_uses_json_encoded_query():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = SEARCH_RESPONSE
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
await tools["search_registry"](query="nginx", limit=10)
call = client.request.call_args
assert call.args[0] == "SYNO.Docker.Registry"
assert call.args[1] == "search"
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params", {})
assert json.loads(params["q"]) == "nginx"
assert params["offset"] == "0"
assert params["limit"] == "10"
assert params["page_size"] == "10"
@pytest.mark.asyncio
async def test_search_registry_empty():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = {"data": [], "total": 0}
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="does-not-exist")
assert "No results" in result
@pytest.mark.asyncio
async def test_search_registry_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.side_effect = SynologyError("Auth failure", code=119)
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="caddy")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# list_image_tags — bonus
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_image_tags_array_response():
"""DSM returns the envelope's data field directly as a list."""
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = [
{"tag": "latest"},
{"tag": "3.20"},
{"tag": "3.19"},
]
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine")
assert "alpine" in result
assert "latest" in result
assert "3.20" in result
assert "3.19" in result
assert "3 total" in result
@pytest.mark.asyncio
async def test_list_image_tags_uses_json_repo_param():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = [{"tag": "latest"}]
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
await tools["list_image_tags"](repository="grafana/grafana")
call = client.request.call_args
assert call.args[0] == "SYNO.Docker.Registry"
assert call.args[1] == "tags"
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params", {})
assert json.loads(params["repo"]) == "grafana/grafana"
@pytest.mark.asyncio
async def test_list_image_tags_limit_truncates():
from mcp_synology_container.modules.registry import register_registry
tags = [{"tag": f"v{i}"} for i in range(120)]
client = AsyncMock()
client.request.return_value = tags
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine", limit=10)
assert "120 total" in result
assert "Showing first 10 of 120" in result
# Tag #10 must not appear (only v0..v9)
assert " v9" in result
assert " v10" not in result
@pytest.mark.asyncio
async def test_list_image_tags_empty():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
# Empty list → DsmClient.request coerces to {} via `or {}`
client.request.return_value = {}
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="nonexistent/image")
assert "No tags found" in result
@pytest.mark.asyncio
async def test_list_image_tags_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.side_effect = SynologyError("Boom", code=100)
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# pull_image — Issue #3
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_pull_image_preview():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24")
assert "Preview" in result
assert "nginx:1.24" in result
assert "confirmed=True" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_pull_image_already_present():
"""If image is already in Image/list, no pull_start call is made."""
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
raise AssertionError(f"Unexpected call: {api}/{method}")
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "already present" in result
# Only the Image/list pre-check was called; no pull_start of any kind.
calls = client.request.call_args_list
assert all(c.args[1] != "pull_start" for c in calls)
@pytest.mark.asyncio
async def test_pull_image_confirmed_success(monkeypatch):
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
state = {"pulled": False}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
if state["pulled"]:
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
state["pulled"] = True
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Pulled" in result
assert "nginx:1.24" in result
# Verify pull_start was invoked on SYNO.Docker.Image (not Registry)
# with JSON-encoded params.
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
assert len(pull_calls) == 1
assert pull_calls[0].args[0] == "SYNO.Docker.Image"
params = pull_calls[0].kwargs.get("params", {})
assert json.loads(params["repository"]) == "nginx"
assert json.loads(params["tag"]) == "1.24"
assert pull_calls[0].kwargs.get("version") == 1
@pytest.mark.asyncio
async def test_pull_image_timeout(monkeypatch):
"""If the image never appears, the tool returns a still-running hint."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
# Shrink the budget so the loop exits quickly
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "still running" in result
assert "nginx:1.24" in result
@pytest.mark.asyncio
async def test_pull_image_start_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
raise SynologyError("Permission denied", code=105)
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Error starting pull" in result
+295
View File
@@ -305,3 +305,298 @@ async def test_system_prune_api_error():
result = await tools["system_prune"](confirmed=True)
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────
# M-6: system_prune preview now counts unused networks
# ──────────────────────────────────────────────────────────────────────
SAMPLE_NETWORKS_FOR_PRUNE = {
"network": [
# User-created, no containers attached → will be pruned
{"name": "orphan_net", "driver": "bridge", "containers": []},
# User-created, in use → must NOT be counted
{
"name": "myapp_default",
"driver": "bridge",
"containers": ["web", "db"],
},
# Built-in networks: Docker never prunes these even if empty
{"name": "bridge", "driver": "bridge", "containers": []},
{"name": "host", "driver": "host", "containers": []},
{"name": "none", "driver": "null", "containers": []},
# Another user-created empty network
{"name": "legacy_net", "driver": "bridge", "containers": []},
]
}
@pytest.mark.asyncio
async def test_system_prune_preview_counts_unused_networks() -> None:
"""M-6: preview must enumerate user-created networks with no containers,
skipping the three built-in networks (bridge/host/none)."""
from mcp_synology_container.modules.system import register_system
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image":
return SAMPLE_IMAGES
if api == "SYNO.Docker.Container":
return SAMPLE_CONTAINERS
if api == "SYNO.Docker.Network":
return SAMPLE_NETWORKS_FOR_PRUNE
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_system(mcp, make_config(), client)
result = await tools["system_prune"]()
# Two unused user-created networks; the three built-ins must not appear.
assert "Unused networks: 2" in result
assert "orphan_net" in result
assert "legacy_net" in result
# Built-in network names must not appear in the prune preview.
assert " - bridge " not in result
assert " - host " not in result
assert " - none " not in result
# Network with containers must not be listed.
assert "myapp_default" not in result
# Old "not counted" placeholder must be gone.
assert "not counted" not in result
@pytest.mark.asyncio
async def test_system_prune_preview_network_fetch_failure_is_nonfatal() -> None:
"""If the network list fetch fails, the preview still works (0 networks)."""
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.system import register_system
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image":
return SAMPLE_IMAGES
if api == "SYNO.Docker.Container":
return SAMPLE_CONTAINERS
if api == "SYNO.Docker.Network":
raise SynologyError("network list failed", code=100)
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_system(mcp, make_config(), client)
result = await tools["system_prune"]()
# Preview still renders; networks count falls back to 0.
assert "preview" in result.lower()
assert "Unused networks: 0" in result
# ──────────────────────────────────────────────────────────────────────────────
# system_overview
# ──────────────────────────────────────────────────────────────────────────────
# Two-container stats snapshot used to verify aggregation arithmetic.
SAMPLE_STATS_OVERVIEW = {
"aaa111": {
"id": "aaa111",
"name": "/web",
"cpu_stats": {
"cpu_usage": {"total_usage": 2_000_000_000, "percpu_usage": [1, 2]},
"system_cpu_usage": 1_000_000_000_000,
"online_cpus": 2,
},
"precpu_stats": {
"cpu_usage": {"total_usage": 1_000_000_000},
"system_cpu_usage": 999_000_000_000,
},
"memory_stats": {"usage": 100 * 1024 * 1024, "limit": 1024 * 1024 * 1024},
"networks": {
"eth0": {"rx_bytes": 1_000_000, "tx_bytes": 500_000},
},
"blkio_stats": {
"io_service_bytes_recursive": [
{"op": "Read", "value": 10 * 1024 * 1024},
{"op": "Write", "value": 5 * 1024 * 1024},
]
},
},
"bbb222": {
"id": "bbb222",
"name": "/db",
"cpu_stats": {
"cpu_usage": {"total_usage": 3_000_000_000, "percpu_usage": [1, 2]},
"system_cpu_usage": 1_000_000_000_000,
"online_cpus": 2,
},
"precpu_stats": {
"cpu_usage": {"total_usage": 2_000_000_000},
"system_cpu_usage": 999_000_000_000,
},
"memory_stats": {"usage": 200 * 1024 * 1024, "limit": 2 * 1024 * 1024 * 1024},
"networks": {
"eth0": {"rx_bytes": 2_000_000, "tx_bytes": 1_000_000},
},
"blkio_stats": {
"io_service_bytes_recursive": [
{"op": "Read", "value": 20 * 1024 * 1024},
{"op": "Write", "value": 7 * 1024 * 1024},
]
},
},
}
@pytest.mark.asyncio
async def test_system_overview_aggregates_stats():
"""Sums CPU%, memory, net I/O, and block I/O across all containers in stats."""
from mcp_synology_container.modules.system import register_system
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return SAMPLE_CONTAINERS
if api == "SYNO.Docker.Container" and method == "stats":
return SAMPLE_STATS_OVERVIEW
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_system(mcp, make_config(), client)
result = await tools["system_overview"]()
# Headline section exists
assert "Docker System Overview" in result
assert "Aggregated" in result
# CPU per container: (1e9 / 1e9) * 2 * 100 = 200% each → sum ≈ 400%
assert "400.00%" in result
# Memory total usage = 100 MiB + 200 MiB = 300 MiB; limit = 1 GiB + 2 GiB = 3 GiB
assert "300 MiB" in result
assert "3.0 GiB" in result
# Net I/O present (rx/tx sums non-zero)
assert "Net I/O" in result
assert "rx " in result
assert "tx " in result
# Block I/O: read = 30 MiB, write = 12 MiB
assert "30 MiB" in result
assert "12 MiB" in result
@pytest.mark.asyncio
async def test_system_overview_running_vs_stopped_count():
"""Counts running and stopped containers from the list response."""
from mcp_synology_container.modules.system import register_system
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return SAMPLE_CONTAINERS
if api == "SYNO.Docker.Container" and method == "stats":
return SAMPLE_STATS_OVERVIEW
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_system(mcp, make_config(), client)
result = await tools["system_overview"]()
# SAMPLE_CONTAINERS has 2 running ("web", "db") and 1 stopped ("old")
assert "running" in result
assert "stopped" in result
assert " 2 running" in result
assert " 1 stopped" in result
@pytest.mark.asyncio
async def test_system_overview_no_containers():
"""Empty container list and empty stats — shows zero counts, no aggregation block."""
from mcp_synology_container.modules.system import register_system
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return {"containers": []}
if api == "SYNO.Docker.Container" and method == "stats":
return {}
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_system(mcp, make_config(), client)
result = await tools["system_overview"]()
assert " 0 running" in result
assert " 0 stopped" in result
# No aggregation block when there are no stats entries
assert "Aggregated" not in result
@pytest.mark.asyncio
async def test_system_overview_container_list_error_graceful():
"""list fails, stats works → still shows aggregated stats and a warning."""
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.system import register_system
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
raise SynologyError("list failed", code=102)
if api == "SYNO.Docker.Container" and method == "stats":
return SAMPLE_STATS_OVERVIEW
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_system(mcp, make_config(), client)
result = await tools["system_overview"]()
# Counts default to 0 (list failed), but aggregation still runs
assert "Warnings" in result
assert "list" in result
assert "Aggregated" in result
assert "400.00%" in result # stats still aggregated
@pytest.mark.asyncio
async def test_system_overview_stats_error_graceful():
"""stats fails, list works → still shows counts and a warning."""
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.system import register_system
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Container" and method == "list":
return SAMPLE_CONTAINERS
if api == "SYNO.Docker.Container" and method == "stats":
raise SynologyError("stats failed", code=102)
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_system(mcp, make_config(), client)
result = await tools["system_overview"]()
# Counts still displayed
assert " 2 running" in result
assert " 1 stopped" in result
# Warning surfaced for the stats failure
assert "Warnings" in result
assert "stats" in result
# No aggregation block — stats unavailable
assert "Aggregated" not in result
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]]
name = "mcp-synology-container"
version = "0.2.2"
version = "0.7.0"
source = { editable = "." }
dependencies = [
{ name = "click" },