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>
This commit is contained in:
2026-05-18 11:13:18 +02:00
parent 13e10fa52f
commit 801dbe15dc
6 changed files with 458 additions and 8 deletions
+26 -1
View File
@@ -2,7 +2,32 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.3.0] - 2026-05-18 ## [0.3.1] - 2026-05-18
### 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 ### Fixed
+5 -4
View File
@@ -33,11 +33,11 @@ Only a second consecutive failure is treated as a real auth problem.
--- ---
## Implemented tools (23) ## Implemented tools (24)
| Category | Tools | | Category | Tools |
|---|---| |---|---|
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` | | Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project` |
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container` | | Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container` |
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` | | 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` |
@@ -69,8 +69,9 @@ Only a second consecutive failure is treated as a real auth problem.
## Implementation rules ## Implementation rules
- Confirmation required before destructive operations: `stop_project`, - Confirmation required before destructive operations: `stop_project`,
`redeploy_project`, `exec_in_container`, `update_image_tag`, `redeploy_project`, `create_project`, `exec_in_container`,
`update_env_var`, `update_compose`, `delete_container` `update_image_tag`, `update_env_var`, `update_compose`,
`delete_container`
- After compose changes: suggest `redeploy_project` - After compose changes: suggest `redeploy_project`
- DSM errors → human-readable message, no stack traces - DSM errors → human-readable message, no stack traces
- No secrets in stderr output - No secrets in stderr output
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.3.0" version = "0.3.1"
description = "MCP server for Synology Container Manager" description = "MCP server for Synology Container Manager"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+168 -1
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 from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import json
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING: if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -205,6 +208,170 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
return "\n".join(results) 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:
await client.trigger_build_stream(project_id)
results.append(" Build request accepted by DSM.")
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)
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)
async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None: async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
"""Find a project by name from the list. """Find a project by name from the list.
+257
View File
@@ -517,3 +517,260 @@ async def test_redeploy_surfaces_build_failed_with_hint():
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"] 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. # Generous upper bound — early exit means handful of polls, not hundreds.
assert len(list_calls) <= 5 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,
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
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
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]] [[package]]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.3.0" version = "0.3.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },