docker-mcp-server 1.9.2__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.claude/commands/docker-sdk.md +1 -1
- docker_mcp_server-2.0.0/.github/actions/file-failure-issue/action.yaml +75 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/copilot-instructions.md +39 -18
- docker_mcp_server-2.0.0/.github/release.yml +13 -0
- docker_mcp_server-2.0.0/.github/workflows/canary.yaml +132 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/workflows/images.yaml +5 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/workflows/premerge.yaml +83 -9
- docker_mcp_server-2.0.0/.github/workflows/publish-homebrew.yaml +227 -0
- docker_mcp_server-2.0.0/.github/workflows/publish.yaml +552 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/CLAUDE.md +59 -18
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/DOCKERHUB.md +2 -4
- docker_mcp_server-2.0.0/MIGRATION-2.0.md +219 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/PKG-INFO +49 -35
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/PRIVACY.md +1 -1
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/README.md +48 -34
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/SECURITY.md +1 -1
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/__init__.py +13 -1
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/_env.py +4 -4
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/_hosts.py +1 -1
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/server.py +115 -128
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/__init__.py +1 -1
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_cli.py +18 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_labels.py +3 -4
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_utils.py +1 -2
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/buildx.py +23 -25
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/compose.py +12 -13
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/configs.py +14 -14
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/containers.py +161 -205
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/context.py +3 -3
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/images.py +74 -81
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/networks.py +19 -19
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/nodes.py +15 -11
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/plugins.py +25 -41
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/prompts.py +84 -84
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/registry.py +29 -28
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/resources.py +3 -3
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/secrets.py +14 -15
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/services.py +51 -52
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/stack.py +24 -27
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/swarm.py +21 -43
- docker_mcp_server-1.9.2/docker_mcp/tools/client.py → docker_mcp_server-2.0.0/docker_mcp/tools/system.py +22 -25
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/volumes.py +11 -11
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/manifest.json +2 -2
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/pyproject.toml +2 -2
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/scripts/build-mcpb.sh +2 -2
- docker_mcp_server-2.0.0/scripts/docker-mcp-server.rb.tpl +44 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/conftest.py +13 -13
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/conftest.py +2 -2
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_buildx.py +4 -4
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_compose.py +2 -2
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_containers.py +11 -11
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_context.py +3 -3
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_file_payloads.py +10 -10
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_networks.py +5 -5
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_registry.py +8 -8
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_smoke.py +11 -11
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_stack.py +5 -5
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_buildx.py +17 -17
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_cli.py +22 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_compose.py +14 -7
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_configs.py +9 -9
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_containers.py +145 -118
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_context.py +8 -8
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_images.py +54 -47
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_labels.py +8 -8
- docker_mcp_server-2.0.0/tests/test_main.py +33 -0
- docker_mcp_server-2.0.0/tests/test_naming.py +157 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_networks.py +22 -22
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_nodes.py +10 -10
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_plugins.py +24 -33
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_prompts.py +42 -41
- docker_mcp_server-2.0.0/tests/test_pyproject_pins.py +93 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_registry.py +39 -56
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_secrets.py +9 -9
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_server.py +44 -60
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_services.py +36 -30
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_stack.py +13 -13
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_swarm.py +31 -42
- docker_mcp_server-1.9.2/tests/test_client.py → docker_mcp_server-2.0.0/tests/test_system.py +143 -147
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_utils.py +0 -10
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_volumes.py +13 -13
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/uv.lock +31 -31
- docker_mcp_server-1.9.2/.github/workflows/publish-images.yaml +0 -146
- docker_mcp_server-1.9.2/.github/workflows/publish-mcpb.yaml +0 -60
- docker_mcp_server-1.9.2/.github/workflows/publish-pypi.yaml +0 -39
- docker_mcp_server-1.9.2/.github/workflows/publish-registry.yaml +0 -133
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.claude/settings.json +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.dockerignore +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/CODEOWNERS +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/dependabot.yaml +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/workflows/codeql.yaml +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.gitignore +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.mcpbignore +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.python-version +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/Dockerfile +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/LICENSE +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/assets/README.md +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/assets/icon.png +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/__main__.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_ssh_proxy.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/scout.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/glama.json +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/mcpb_run.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/server.json +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/__init__.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/__init__.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_cli.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_scout.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_env.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_hosts.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_resources.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_scout.py +0 -0
- {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_ssh_proxy.py +0 -0
|
@@ -32,7 +32,7 @@ The project structure maps SDK domains to tool files one-to-one:
|
|
|
32
32
|
|
|
33
33
|
| docker_mcp/tools/ file | tests/ file | Docker SDK domain |
|
|
34
34
|
|-------------|-------------|-------------------|
|
|
35
|
-
| `docker_mcp/tools/
|
|
35
|
+
| `docker_mcp/tools/system.py` | `tests/test_system.py` | `DockerClient` |
|
|
36
36
|
| `docker_mcp/tools/containers.py` | `tests/test_containers.py` | Containers |
|
|
37
37
|
| `docker_mcp/tools/images.py` | `tests/test_images.py` | Images |
|
|
38
38
|
| `docker_mcp/tools/networks.py` | `tests/test_networks.py` | Networks |
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: 'File failure issue'
|
|
2
|
+
description: >
|
|
3
|
+
Opens — or comments on — a deduplicated GitHub issue describing a failed automation run.
|
|
4
|
+
Dedupe key: an open issue carrying BOTH the shared `ci-failure` label and this workflow's
|
|
5
|
+
stream label (e.g. `wf:release`), so repeated failures of the same pipeline collect in one
|
|
6
|
+
thread instead of spawning duplicates. The issue body is written to be actionable by the
|
|
7
|
+
scheduled Claude responder routine that polls `ci-failure` issues (as well as by a human):
|
|
8
|
+
it names the failed jobs, links the run, and states how to re-run. The calling job needs
|
|
9
|
+
`issues: write` permission and must pass `github.token`.
|
|
10
|
+
|
|
11
|
+
inputs:
|
|
12
|
+
label:
|
|
13
|
+
description: 'Per-workflow stream label, e.g. wf:release or wf:canary'
|
|
14
|
+
required: true
|
|
15
|
+
title:
|
|
16
|
+
description: 'Issue title used when no open issue exists for this stream'
|
|
17
|
+
required: true
|
|
18
|
+
token:
|
|
19
|
+
description: 'GitHub token with issues: write (pass github.token)'
|
|
20
|
+
required: true
|
|
21
|
+
|
|
22
|
+
runs:
|
|
23
|
+
using: composite
|
|
24
|
+
steps:
|
|
25
|
+
- name: File or update the failure issue
|
|
26
|
+
shell: bash
|
|
27
|
+
env:
|
|
28
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
29
|
+
LABEL: ${{ inputs.label }}
|
|
30
|
+
TITLE: ${{ inputs.title }}
|
|
31
|
+
run: |
|
|
32
|
+
set -euo pipefail
|
|
33
|
+
|
|
34
|
+
# Ensure the labels exist (idempotent; --force updates color/description if they do).
|
|
35
|
+
gh label create ci-failure -R "$GITHUB_REPOSITORY" --force \
|
|
36
|
+
--color B60205 --description "Automated failure report from a workflow run"
|
|
37
|
+
gh label create "$LABEL" -R "$GITHUB_REPOSITORY" --force \
|
|
38
|
+
--color D93F0B --description "Failure stream: $LABEL"
|
|
39
|
+
|
|
40
|
+
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
|
41
|
+
|
|
42
|
+
# The failed jobs have already completed by the time this (if: failure()) job runs.
|
|
43
|
+
failed_jobs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" --paginate \
|
|
44
|
+
--jq '.jobs[] | select(.conclusion == "failure") | "- \(.name)"' || true)"
|
|
45
|
+
if [ -z "$failed_jobs" ]; then
|
|
46
|
+
failed_jobs="- (could not list failed jobs — see the run)"
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
body="$(cat <<EOF
|
|
50
|
+
An automated run of the **${GITHUB_WORKFLOW}** workflow failed.
|
|
51
|
+
|
|
52
|
+
- Run: ${run_url}
|
|
53
|
+
- Failed jobs:
|
|
54
|
+
${failed_jobs}
|
|
55
|
+
|
|
56
|
+
To re-run after fixing: Actions → ${GITHUB_WORKFLOW} → Run workflow (the Release
|
|
57
|
+
workflow takes the release tag as input; re-runs are idempotent).
|
|
58
|
+
|
|
59
|
+
<!-- filed by .github/actions/file-failure-issue -->
|
|
60
|
+
EOF
|
|
61
|
+
)"
|
|
62
|
+
|
|
63
|
+
# Dedupe on the open issue carrying both labels: comment there instead of opening a
|
|
64
|
+
# duplicate, keeping one thread per failure stream.
|
|
65
|
+
existing="$(gh issue list -R "$GITHUB_REPOSITORY" --state open \
|
|
66
|
+
--label ci-failure --label "$LABEL" --json number --jq '.[0].number // empty')"
|
|
67
|
+
if [ -n "$existing" ]; then
|
|
68
|
+
echo "Open issue #$existing exists for $LABEL — commenting instead of duplicating."
|
|
69
|
+
gh issue comment "$existing" -R "$GITHUB_REPOSITORY" --body "$body"
|
|
70
|
+
else
|
|
71
|
+
gh issue create -R "$GITHUB_REPOSITORY" \
|
|
72
|
+
--title "$TITLE" \
|
|
73
|
+
--label ci-failure --label "$LABEL" \
|
|
74
|
+
--body "$body"
|
|
75
|
+
fi
|
|
@@ -4,9 +4,9 @@ This file provides guidance to GitHub Copilot when working with code in this rep
|
|
|
4
4
|
|
|
5
5
|
## Project
|
|
6
6
|
|
|
7
|
-
`docker-mcp` is a Python MCP (Model Context Protocol) server that exposes the Docker SDK for Python — plus selected docker CLI features (Compose, Context, Buildx, Scout) and direct OCI-registry HTTPS access — as MCP tools. It requires Python >=3.14 and is managed with `uv`. It is published to PyPI as **`docker-mcp-server`** and as a container image to GHCR (`ghcr.io/gavinlucas/docker-mcp-server`), mirrored to Docker Hub (`gavinlucas/docker-mcp-server`) when the opt-in `DOCKERHUB_*` release secrets are configured (the import package stays `docker_mcp`, the repo stays `…/docker-mcp`); two console scripts — `docker-mcp` and `docker-mcp-server` — both target `docker_mcp:main`. A third distribution channel packages the server as a **Claude Desktop Extension (`.mcpb`)** attached to each GitHub Release (see "Desktop Extension (MCPB)" below).
|
|
7
|
+
`docker-mcp` is a Python MCP (Model Context Protocol) server that exposes the Docker SDK for Python — plus selected docker CLI features (Compose, Context, Buildx, Scout) and direct OCI-registry HTTPS access — as MCP tools. It requires Python >=3.14 and is managed with `uv`. It is published to PyPI as **`docker-mcp-server`** and as a container image to GHCR (`ghcr.io/gavinlucas/docker-mcp-server`), mirrored to Docker Hub (`gavinlucas/docker-mcp-server`) when the opt-in `DOCKERHUB_*` release secrets are configured (the import package stays `docker_mcp`, the repo stays `…/docker-mcp`); two console scripts — `docker-mcp` and `docker-mcp-server` — both target `docker_mcp:main`. A third distribution channel packages the server as a **Claude Desktop Extension (`.mcpb`)** attached to each GitHub Release (see "Desktop Extension (MCPB)" below). A fourth channel (**Homebrew tap**) exists in `gavinlucas/homebrew-tap` but is currently **paused** — see "Homebrew tap" below.
|
|
8
8
|
|
|
9
|
-
The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works via a pure-Python transport (no system `ssh` binary; works in the container images). docker-py auto-selects paramiko for `ssh://`, so there's no transport code — only the `ssh://` branch in `
|
|
9
|
+
The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works via a pure-Python transport (no system `ssh` binary; works in the container images). docker-py auto-selects paramiko for `ssh://`, so there's no transport code — only the `ssh://` branch in `system._connection_help`. CLI-backed tools (Compose, Buildx, Context, Scout) shell out to `docker`, which would otherwise need the system `ssh` client — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection and runs `docker system dial-stdio` over it, so the CLI authenticates the same way the docker-py-backed tools do, with no system `ssh` binary involved (except a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — for both tool families alike).
|
|
10
10
|
|
|
11
11
|
## Architecture
|
|
12
12
|
|
|
@@ -26,7 +26,7 @@ from docker_mcp.server import prompt # prompt modules (with domain=...)
|
|
|
26
26
|
from docker_mcp.server import mcp # resource modules only
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`server.py` also owns **`TOOL_CATEGORIES`**, the central map classifying every tool as `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`. The `@tool()` decorator uses it to attach MCP `ToolAnnotations` and to skip registration under the env switches `DOCKER_MCP_SERVER_READONLY` (register only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (register everything except destructive). It also records each tool's **domain** (its defining module's leaf, e.g. `containers`) so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop whole feature areas — including that domain's **prompts** (via the `prompt(domain=...)` helper) and its **doc-resource sections** (via `_SECTION_DOMAINS` in `resources.py`), not just its tools; the live snapshot is the `docker-mcp://tool-catalog` resource (`server.tool_catalog()`). **Every new tool needs a `TOOL_CATEGORIES` entry** — `tests/test_server.py` fails the build if the map drifts from the registered set. The decorator also runs `_slim_schema` on the advertised `inputSchema` to drop three information-free patterns (~18% of schema tokens): pydantic `title` annotations, the `{"type":"null"}` branch of a nullable `anyOf` (gated on a sibling `default`), and redundant `additionalProperties: true`; it's display-only (validation runs off `fn_metadata`), and `tests/test_server.py` asserts none survive. All server tunables are namespaced `DOCKER_MCP_SERVER_*`; read them through `docker_mcp/_env.py` (`read_env` / `env_flag`)
|
|
29
|
+
`server.py` also owns **`TOOL_CATEGORIES`**, the central map classifying every tool as `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`. The `@tool()` decorator uses it to attach MCP `ToolAnnotations` and to skip registration under the env switches `DOCKER_MCP_SERVER_READONLY` (register only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (register everything except destructive). It also records each tool's **domain** (its defining module's leaf, e.g. `containers`) so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop whole feature areas — including that domain's **prompts** (via the `prompt(domain=...)` helper) and its **doc-resource sections** (via `_SECTION_DOMAINS` in `resources.py`), not just its tools; the live snapshot is the `docker-mcp://tool-catalog` resource (`server.tool_catalog()`). **Every new tool needs a `TOOL_CATEGORIES` entry** — `tests/test_server.py` fails the build if the map drifts from the registered set. The decorator also runs `_slim_schema` on the advertised `inputSchema` to drop three information-free patterns (~18% of schema tokens): pydantic `title` annotations, the `{"type":"null"}` branch of a nullable `anyOf` (gated on a sibling `default`), and redundant `additionalProperties: true`; it's display-only (validation runs off `fn_metadata`), and `tests/test_server.py` asserts none survive. All server tunables are namespaced `DOCKER_MCP_SERVER_*`; read them through `docker_mcp/_env.py` (`read_env` / `env_flag`). The pre-rename `DOCKER_MCP_*` alias spellings were removed in 2.0; the alias-fallback mechanism remains for any future rename.
|
|
30
30
|
|
|
31
31
|
`server.py` also builds the FastMCP **`instructions`** string — pre-loaded into a client's context with the server name and tool names, *before* any per-tool schema, so for a lazy-loading client (e.g. Claude Code, which fetches tool schemas on demand) it's the main always-in-context surface. It's written as a **router** (per-domain keyword one-liners + a few tool-selection caveats), not docs, and does not enumerate tools (that's `docker-mcp://tool-catalog`). `build_instructions()` renders it from `_DOMAIN_BLURBS`, emitting a domain's line **only when that domain has a registered tool**, so `DOCKER_MCP_SERVER_DISABLE` / `_READONLY` / `_NO_DESTRUCTIVE` are honored via the one registration flag. `finalize_instructions()` (called from `docker_mcp/__init__.py` after all tools import) writes it through to `mcp._mcp_server.instructions` (FastMCP's `instructions` is a read-only property read at `run()` time, so a late write propagates; the reach-in is guarded). **A new tool domain needs a `_DOMAIN_BLURBS` entry** or the router silently omits it.
|
|
32
32
|
|
|
@@ -36,9 +36,9 @@ from docker_mcp.server import mcp # resource modules only
|
|
|
36
36
|
|
|
37
37
|
- **Grammar.** No `=` in the value → bare single-host shorthand (`ssh://ops@prod(ro)`, `auto`, `local`, empty → `auto`); with `=` → comma-separated `label=endpoint` list. `endpoint` is the keyword `auto`/`local` or a `unix://`/`tcp://`/`ssh://`/`npipe://` URL, with combinable trailing markers `(ro)` (read-only) and `(tls=<dir>)` (a tcp+TLS cert dir; **`ca.pem` is required** — the daemon is always verified against it — and `cert.pem`+`key.pem` are optional, present together for mutual TLS or absent for verify-the-daemon-only, e.g. a self-signed daemon pinned via `ca.pem`). **Fail-fast** (`HostConfigError` → stderr + non-zero exit) on duplicate/empty/invalid labels, a missing `=`, an unknown marker, `(tls=)` on a non-tcp endpoint, a missing `ca.pem` or a lone `cert.pem`/`key.pem`, or an unrecognized scheme.
|
|
38
38
|
- **`auto`/`local`/`default` are resolved to concrete URLs by us and pinned at `load()` (startup)** so the docker-py SDK and the CLI shell-out target the *same* daemon for a label (auditable) and a mid-session `docker context use` can't silently move a label. `default` = the first registry entry = the omitted-`host` fallback, and is **not** a selectable label. `load()` runs in `docker_mcp/__init__.py` before the tools import (the `@tool()` decorator and resources read `is_multi()`/`labels()` at registration) and scrubs whole-value `${...}` placeholders first.
|
|
39
|
-
- **Per-call host selection (no modal active-host state).** Every daemon-targeting tool declares `host: str | None = None` and threads it to `_get_client(host)` / `run_docker(..., host=host)`. The `@tool()` decorator does **display-only schema surgery** (`_apply_host_schema`, like `_slim_schema`, gated on `_hosts.is_multi()`) — strip `host` in single-host mode (footprint-neutral), or constrain it to an `enum` of the labels and mark it required for writes in multi-host mode — and wraps the tool with `_enforce_host_guard` (multi-host: writes require an explicit `host`, unknown labels rejected, writes to an `(ro)` host refused; read-only tools and the `_CONNECTION_CONTROL` set `
|
|
40
|
-
- **Client / CLI.** `
|
|
41
|
-
- **Surfaces.** `
|
|
39
|
+
- **Per-call host selection (no modal active-host state).** Every daemon-targeting tool declares `host: str | None = None` and threads it to `_get_client(host)` / `run_docker(..., host=host)`. The `@tool()` decorator does **display-only schema surgery** (`_apply_host_schema`, like `_slim_schema`, gated on `_hosts.is_multi()`) — strip `host` in single-host mode (footprint-neutral), or constrain it to an `enum` of the labels and mark it required for writes in multi-host mode — and wraps the tool with `_enforce_host_guard` (multi-host: writes require an explicit `host`, unknown labels rejected, writes to an `(ro)` host refused; read-only tools and the `_CONNECTION_CONTROL` set `system_close`/`system_reconnect`/`system_login`/`system_logout` may omit `host`). The guard is wrapped on when `_host_guard_needed()` — multi-host **or a single host flagged `(ro)`**: a lone `(ro)` host has its `host` param stripped (footprint-neutral) but its writes are still refused, so the per-host `(ro)` marker is honored even in single-host mode (distinct from `DOCKER_MCP_SERVER_READONLY`, which drops write tools entirely); a single *writable* host wires no guard. **Excluded** (no `host` param): `registry`/`hub_*` (HTTPS, no daemon) and `context` (manages the host's CLI contexts).
|
|
40
|
+
- **Client / CLI.** `system.py` keeps a lazy pool `_clients` keyed by label with tiered per-host TLS (`(tls=)` dir → global `DOCKER_CERT_PATH`/`DOCKER_TLS_VERIFY` → plaintext); the legacy single host still uses `_build_default_client`/`from_env` unchanged. **`system_reconnect(host=None)` is rebuild-only** — it can't retarget to an arbitrary URL (edit the registry + restart), closing a trust-expansion hole. `system_close(host=None)` closes all/one. `startup_preflight` pings the default host but detects self-id against the *self host* (first local-transport entry, which can differ from a remote default); `guard_not_self(container, host=)` only fires on the self host. `_cli.py:_apply_host_env` injects the resolved `DOCKER_HOST` + per-host TLS into the child env for an explicit host.
|
|
41
|
+
- **Surfaces.** `host_list` tool + `docker-mcp://hosts` resource expose the resolved registry; the router gains a multi-host caveat; the container observability resources go host-aware (empty-authority `docker:///…` for the default + `docker://{host}/…` variants); a `prompt(multi_host=True)` gate plus the `survey_hosts` prompt register only with 2+ hosts.
|
|
42
42
|
|
|
43
43
|
**When reviewing a PR that changes the host grammar, env precedence, or the per-tool/resource/prompt host surface, this section is the spec.**
|
|
44
44
|
|
|
@@ -51,7 +51,7 @@ Each file maps to one Docker SDK domain or one CLI/registry feature area. Unders
|
|
|
51
51
|
| `_ssh_proxy.py` | Per-call paramiko proxy letting CLI-backed tools dial `ssh://` daemons without a system `ssh` binary (private) | — |
|
|
52
52
|
| `_utils.py` | Shared helpers: `drop_none`, `join_bounded`, `stream_to_file`, `close_stream_quietly`, `MAX_PAYLOAD_BYTES`, plus the container guards `in_container` / `assert_host_writable` / `host_read_path` / `classify_host_kernel` (private) | — |
|
|
53
53
|
| `_labels.py` | Provenance labels stamped on created resources: `with_provenance` / `managed_filter` / `provenance_labels` (private) | — |
|
|
54
|
-
| `
|
|
54
|
+
| `system.py` | `DockerClient` — connection, lifecycle, `system_login`/`system_logout`, `system_reconnect` | docker-py |
|
|
55
55
|
| `containers.py` | Container lifecycle and management | docker-py |
|
|
56
56
|
| `images.py` | Image pull, build, push, inspect, save/load | docker-py |
|
|
57
57
|
| `networks.py` | Network create, connect, inspect | docker-py |
|
|
@@ -74,25 +74,46 @@ Each file maps to one Docker SDK domain or one CLI/registry feature area. Unders
|
|
|
74
74
|
`docker_mcp/tools/__init__.py` star-imports all public modules so `docker_mcp/__init__.py` only needs `from docker_mcp import tools`.
|
|
75
75
|
|
|
76
76
|
### Tests (`tests/`)
|
|
77
|
-
Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`; `tests/test_server.py` covers the classification/registration machinery. Tests use pytest with mocks. `tests/integration/` holds tests that need a real Docker daemon — excluded by default, run with `uv run pytest -m integration`. `tests/conftest.py` clears the `
|
|
77
|
+
Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`; `tests/test_server.py` covers the classification/registration machinery. Tests use pytest with mocks. `tests/integration/` holds tests that need a real Docker daemon — excluded by default, run with `uv run pytest -m integration`. `tests/conftest.py` clears the `DOCKER_MCP_SERVER_*` env switches so the suite is hermetic.
|
|
78
|
+
|
|
79
|
+
CI (`.github/workflows/premerge.yaml`) enforces `pytest`, `ruff check`, `ruff format --check`, and `pyright` on every PR and push to main — all via `uv run`, so the dev-group pins in `pyproject.toml` (bumped by Dependabot's monthly uv pass) are the single tool-version source; the pre-commit hooks are local hooks running `uv run ruff …` for the same reason. CI installs with `uv sync --locked`, which fails if `uv.lock` disagrees with `pyproject.toml` instead of silently re-locking — so when reviewing a PR that touches only `uv.lock`, check its recorded `requires-dist` specifiers still match pyproject (a Dependabot lock rewrite once raised the cryptography cap in the lock alone). A non-required `Check docs mirror` job flags a PR that edits `CLAUDE.md` or `.github/copilot-instructions.md` without the other (the MIRROR RULE) — when reviewing such a PR, check whether the one-sided edit was intentional.
|
|
80
|
+
|
|
81
|
+
A **weekly canary** (`.github/workflows/canary.yaml`, Mondays + dispatch) hunts platform/ecosystem drift premerge CI can't see: wheels-only (`--only-binary :all:`) dependency resolution for Intel macOS / ARM macOS / Windows against both the repo `pyproject.toml` and the latest published PyPI release (the check that would have caught cryptography 49 dropping its x86_64-macOS wheel), plus real install smokes of the published package on `macos-latest`, `macos-15-intel` (Intel runner label retires Aug 2027), and `windows-latest` — `import docker_mcp` and `uvx docker-mcp-server --version`. PRs into main also run the repo-pyproject resolution leg (the only part exercising PR content); the published-package legs and issue filing stay schedule/dispatch-only. Failures on unattended runs file a deduplicated `ci-failure` + `wf:canary` issue via `.github/actions/file-failure-issue`. `main()` handles `--version` (print the installed version, exit) before any daemon/network contact — the canary's entry-point smoke depends on it.
|
|
78
82
|
|
|
79
83
|
### Container image (`Dockerfile`)
|
|
80
84
|
|
|
81
|
-
An additional distribution channel alongside the uvx-from-git install (unchanged). One ARG-gated multi-stage `Dockerfile` builds `full` (docker CLI + compose + buildx + scout) and `no-scout` (sets `DOCKER_MCP_SERVER_DISABLE=scout` so absent-plugin scout tools don't register), published on each GitHub Release via `.github/workflows/publish
|
|
85
|
+
An additional distribution channel alongside the uvx-from-git install (unchanged). One ARG-gated multi-stage `Dockerfile` builds `full` (docker CLI + compose + buildx + scout) and `no-scout` (sets `DOCKER_MCP_SERVER_DISABLE=scout` so absent-plugin scout tools don't register), published on each GitHub Release via the `images` job in `.github/workflows/publish.yaml` (see "Release pipeline" below) — always to GHCR, and mirrored to Docker Hub (plus a `DOCKERHUB.md`→Hub-description sync — a slim container-focused readme, as the full `README.md` exceeds Hub's 25 KB cap) when the opt-in `DOCKERHUB_USER`/`DOCKERHUB_TOKEN` secrets are set — the Hub token must have `read/write/delete` scope (build/measure on PRs/pushes is the separate `images.yaml`); `lite` (`INSTALL_CLI=0`) is buildable but not published. Two container-aware guards live behind `_utils.in_container()` (true when `/.dockerenv` exists or `DOCKER_MCP_SERVER_IN_CONTAINER=1`) and are **inert on the host install** — keep them in mind when editing `_utils.py` or the file-path tools:
|
|
82
86
|
|
|
83
|
-
- **Filesystem guard** — `assert_host_writable` (hooked into `stream_to_file`) refuses a
|
|
84
|
-
- **Self-termination guard** — `
|
|
87
|
+
- **Filesystem guard** — `assert_host_writable` (hooked into `stream_to_file`) refuses a host-file write (`dest_path` / `container_archive_get_to_file`) to a path that isn't a host bind mount (it would be lost on `--rm`); `host_read_path` enriches the read-side "missing file" case.
|
|
88
|
+
- **Self-termination guard** — `system.startup_preflight()` (called from `main()`) pins the server's own container id (detected against the *self host* — the first local-transport entry, which can differ from a remote default) and prints OS-aware socket hints to stderr; `system.guard_not_self(container, host=)` stops the destructive container-lifecycle tools (`container_remove`/`container_kill`/`container_stop`/`container_restart`/`container_pause`) from acting on the server's own container, **only when the call targets the self host** (override `DOCKER_MCP_SERVER_ALLOW_SELF_TERMINATE=1`).
|
|
85
89
|
|
|
86
90
|
### Desktop Extension (MCPB)
|
|
87
91
|
|
|
88
|
-
A third distribution channel for one-click install in Claude Desktop. Repo-root sources: `manifest.json` (MCPB manifest, `manifest_version` 0.4, `server.type: "uv"`), `mcpb_run.py` (bundle entry point — `from docker_mcp import main; main()`, at the root so `import docker_mcp` resolves under the host's managed `uv`), `.mcpbignore` (trims the packed bundle to source + `pyproject.toml` + `uv.lock` + `README.md`/`LICENSE` + manifest/entry-point + `assets/`), and `assets/icon.png` (512×512). It's a `uv`-type bundle: the host resolves deps from `pyproject.toml` at install time (no vendored venv). `manifest.json`'s `user_config` maps install-dialog fields to env — `DOCKER_MCP_SERVER_HOSTS` (the single host field; `DOCKER_HOST` is **not** exposed — the bare-value shorthand keeps the one-daemon case a one-liner) and the `DOCKER_MCP_SERVER_READONLY` / `_NO_DESTRUCTIVE` / `_DISABLE` switches (the container-only `_ALLOW_SELF_TERMINATE` is intentionally omitted — the bundle never runs containerized). `.github/workflows/publish
|
|
92
|
+
A third distribution channel for one-click install in Claude Desktop. Repo-root sources: `manifest.json` (MCPB manifest, `manifest_version` 0.4, `server.type: "uv"`), `mcpb_run.py` (bundle entry point — `from docker_mcp import main; main()`, at the root so `import docker_mcp` resolves under the host's managed `uv`), `.mcpbignore` (trims the packed bundle to source + `pyproject.toml` + `uv.lock` + `README.md`/`LICENSE` + manifest/entry-point + `assets/`), and `assets/icon.png` (512×512). It's a `uv`-type bundle: the host resolves deps from `pyproject.toml` at install time (no vendored venv). `manifest.json`'s `user_config` maps install-dialog fields to env — `DOCKER_MCP_SERVER_HOSTS` (the single host field; `DOCKER_HOST` is **not** exposed — the bare-value shorthand keeps the one-daemon case a one-liner) and the `DOCKER_MCP_SERVER_READONLY` / `_NO_DESTRUCTIVE` / `_DISABLE` switches (the container-only `_ALLOW_SELF_TERMINATE` is intentionally omitted — the bundle never runs containerized). The `mcpb` job in `.github/workflows/publish.yaml` rewrites the manifest `version` from the release tag, packs the `.mcpb` via `npx @anthropic-ai/mcpb`, and attaches it (plus a `.sha256` recording the bare filename, so `sha256sum -c` works on downloads) to each GitHub Release. `scripts/build-mcpb.sh` is the **developer-only** local equivalent of that pack step (version from `pyproject.toml`, auto-incrementing output in `dist/`, never mutates `manifest.json`) for smoke-testing in Claude Desktop — it is deliberately **not** used by CI. `PRIVACY.md` (no-telemetry statement) is referenced by the manifest's `privacy_policies`. Keep the manifest `version` aligned with `pyproject.toml`; update this section when the manifest's tool/env surface or the bundled file set changes.
|
|
93
|
+
|
|
94
|
+
### Homebrew tap (`gavinlucas/homebrew-tap`) — PAUSED
|
|
95
|
+
|
|
96
|
+
The infrastructure exists (`scripts/docker-mcp-server.rb.tpl`, `.github/workflows/publish-homebrew.yaml`, `GavinLucas/homebrew-tap`) but the **release trigger is disabled** pending resolution of a Homebrew dylib linkage issue: pre-built PyPI wheels for `pydantic_core` lack `-headerpad_max_install_names`, so Homebrew's post-install relocation step fails to rewrite the `@rpath` ID (the binary's Mach-O header has no room for the longer absolute path). The workflow is `workflow_dispatch`-only until a fix is found. The channel is not advertised in the README. To re-enable: add `release: types: [published]` back to `publish-homebrew.yaml`'s `on:` block and re-add the Homebrew section to the README.
|
|
89
97
|
|
|
90
98
|
### MCP Registry (`server.json`)
|
|
91
99
|
|
|
92
|
-
A discovery listing in the official MCP Registry (`registry.modelcontextprotocol.io`), which stores only metadata pointing at the existing artifacts. `server.json` (repo root, name `io.github.GavinLucas/docker-mcp-server`) declares three package types — `pypi`, `oci` (GHCR image), `mcpb` (release `.mcpb`). `.github/workflows/publish
|
|
100
|
+
A discovery listing in the official MCP Registry (`registry.modelcontextprotocol.io`), which stores only metadata pointing at the existing artifacts. `server.json` (repo root, name `io.github.GavinLucas/docker-mcp-server`) declares three package types — `pypi`, `oci` (GHCR image), `mcpb` (release `.mcpb`). The `registry` job in `.github/workflows/publish.yaml` stamps the tag version + the published `.mcpb`'s `fileSha256` into `server.json`, authenticates via GitHub OIDC (`id-token: write`, no secret), and runs `mcp-publisher publish`. Ownership is verified per package against `server.json`'s `name`, so three markers must equal it: the `<!-- mcp-name: … -->` comment in `README.md` (PyPI), the `io.modelcontextprotocol.server.name` image label in the `images` job (OCI), and the `.mcpb` URL + hash (MCPB). Keep them in sync with `server.json`'s `name`.
|
|
101
|
+
|
|
102
|
+
### Release pipeline (`.github/workflows/publish.yaml`)
|
|
103
|
+
|
|
104
|
+
All publishing runs through one workflow on each **published GitHub Release**: `preflight` → `pypi` ∥ `images`(→`dockerhub-description`) ∥ `mcpb` → `registry` → `verify`, plus a `notify` job on failure. `preflight` resolves the tag once (every job checks out **the tag ref**) and fails fast if the tag disagrees with the committed `pyproject.toml` / `manifest.json` / `uv.lock` self-entry versions — preventing a split-brain release where PyPI ships the pyproject version and everything else ships the tag version. `verify` confirms all channels serve the version (PyPI JSON API, both GHCR tags, `.mcpb` asset + checksum, registry listing). `notify` files a deduplicated `ci-failure` + `wf:release` issue via `.github/actions/file-failure-issue` (one open issue per failure stream; a scheduled Claude responder routine polls `ci-failure` issues). Re-runs go through `workflow_dispatch` with the tag and are idempotent end-to-end (`skip-existing` on PyPI, re-pushed image tags, `--clobber` on the `.mcpb`, duplicate-tolerant registry publish). **PyPI Trusted Publishing pins the workflow *filename* (`publish.yaml`)** — when reviewing a PR that renames or splits this workflow, flag that the PyPI trusted publisher must be updated first. `.github/release.yml` (GitHub's release-*notes* config — a different file) groups generated notes; `publish-homebrew.yaml` (paused, dispatch-only) stays separate. When reviewing changes to the pipeline's job graph, ownership markers, or re-run semantics, check this section stays accurate.
|
|
93
105
|
|
|
94
106
|
## Conventions
|
|
95
107
|
|
|
108
|
+
- **Tool naming convention (2.0, permanent):** every tool is named `<management-command>_<verb>`, anchored
|
|
109
|
+
to the docker CLI's management-command structure (`docker container ls` → `container_list`), with
|
|
110
|
+
**long-form verbs** (`list`/`remove`/`inspect` — never `ls`/`rm`/`get`), applied uniformly even where the
|
|
111
|
+
CLI lacks the long alias. Singular prefixes (`container_`, not `containers_`); read-only fetches may be
|
|
112
|
+
noun-form (`container_logs`, `registry_tags`). Names never encode the backend (SDK vs CLI). Identifier
|
|
113
|
+
params use one rule: `id_or_name` (daemon objects addressable by either), `name`/`names` (name-only
|
|
114
|
+
resources: volumes, contexts, plugins, stacks, builders), `repository` (remote repo refs); durations are
|
|
115
|
+
`timeout_seconds`. `tests/test_naming.py` enforces all of this (approved prefixes, banned short forms and
|
|
116
|
+
1.x spellings, canonical shared-param descriptions) — a violating tool fails CI.
|
|
96
117
|
- **MIRROR RULE:** this file mirrors `CLAUDE.md` and drives Copilot review of every PR. Any change to project structure, conventions, env vars, or the tool/prompt/resource surface must update **both** files in the same PR — flag a PR that updates one but not the other.
|
|
97
118
|
- New Docker functionality goes in the matching `docker_mcp/tools/<domain>.py` — do not create new tool files without a corresponding entry in `docker_mcp/tools/__init__.py` and a matching test file.
|
|
98
119
|
- Tool functions are decorated with `@tool()` (imported from `docker_mcp.server`) and **must have a `TOOL_CATEGORIES` entry** in `docker_mcp/server.py`. A new tool module is a new domain — also add a `_DOMAIN_BLURBS` entry so the `instructions` router advertises it.
|
|
@@ -104,7 +125,7 @@ A discovery listing in the official MCP Registry (`registry.modelcontextprotocol
|
|
|
104
125
|
|
|
105
126
|
### Provenance labels
|
|
106
127
|
|
|
107
|
-
Resources this server **creates** are stamped with `docker-mcp-server.*` provenance labels (`.managed=true`, `.version`, `.tool`, `.created`) so the agent/operator can later enumerate that footprint (the `managed_only=True` arg on `
|
|
128
|
+
Resources this server **creates** are stamped with `docker-mcp-server.*` provenance labels (`.managed=true`, `.version`, `.tool`, `.created`) so the agent/operator can later enumerate that footprint (the `managed_only=True` arg on `container_list` / `network_list` / `volume_list` / `service_list`, or `--filter label=docker-mcp-server.managed=true`; the `prune_managed` prompt removes only the managed footprint). On by default; opt out with `DOCKER_MCP_SERVER_NO_LABELS=1`. When adding a new create tool that accepts a `labels` dict, route it through `docker_mcp/tools/_labels.py:with_provenance(labels, "<tool_name>")` — it merges provenance without overwriting caller keys and returns `None` (drop it via `drop_none`) when stamping is disabled and the caller passed nothing. **Image builds are intentionally not stamped** (a build label changes the image digest).
|
|
108
129
|
|
|
109
130
|
### CLI shell-out policy
|
|
110
131
|
|
|
@@ -112,7 +133,7 @@ Any tool wrapping a `docker` CLI feature MUST go through `docker_mcp/tools/_cli.
|
|
|
112
133
|
|
|
113
134
|
- Wrap every user-supplied **positional** value in `safe_positional(value, "what")` — a value starting with `-` would otherwise be parsed by the docker CLI as a flag (argument injection). The only exception is an argv that is *meant* to be arbitrary (the trailing command of `compose_exec` / `compose_run`).
|
|
114
135
|
- Always pass an explicit `timeout=` to `run_docker` (generous for build/pull, short for queries).
|
|
115
|
-
- Error convention (intentional — do not "unify"): **action tools** return the raw `{"returncode", "stdout", "stderr", "truncated"}` dict and never raise on a non-zero exit; **parsed-query tools** (`
|
|
136
|
+
- Error convention (intentional — do not "unify"): **action tools** return the raw `{"returncode", "stdout", "stderr", "truncated"}` dict and never raise on a non-zero exit; **parsed-query tools** (`context_list`, `buildx_list`/`du`, `compose_list`) raise `RuntimeError` via `raise_on_cli_failure` because they cannot return a useful partial parse; `compose_ps`/`compose_config` are the sanctioned hybrids (parsed view plus `raw`, never raise).
|
|
116
137
|
|
|
117
138
|
### Tool function format
|
|
118
139
|
|
|
@@ -139,8 +160,8 @@ def mcp_example(name: str):
|
|
|
139
160
|
|
|
140
161
|
### Bounding rules
|
|
141
162
|
|
|
142
|
-
- Tools that buffer a daemon-side byte stream in memory must cap it with `join_bounded(stream, max_bytes, what)`; the in-band default is `MAX_PAYLOAD_BYTES` (32 MiB). Large payloads belong in the
|
|
143
|
-
- Tools that iterate a potentially endless stream (events, followed logs) must have a wall-clock bound — see the `threading.Timer` + `CancellableStream.close()` watchdog pattern in `
|
|
163
|
+
- Tools that buffer a daemon-side byte stream in memory must cap it with `join_bounded(stream, max_bytes, what)`; the in-band default is `MAX_PAYLOAD_BYTES` (32 MiB). Large payloads belong in the file-path modes (`dest_path` / `from_file` params, or `container_archive_get_to_file`), which stream via `stream_to_file` / an open file handle.
|
|
164
|
+
- Tools that iterate a potentially endless stream (events, followed logs) must have a wall-clock bound — see the `threading.Timer` + `CancellableStream.close()` watchdog pattern in `system.py:system_events` and `containers.py:container_logs` (follow mode).
|
|
144
165
|
|
|
145
166
|
### MCP resources
|
|
146
167
|
|
|
@@ -164,7 +185,7 @@ Docker SDK GitHub: https://github.com/docker/docker-py
|
|
|
164
185
|
### Review Notes
|
|
165
186
|
|
|
166
187
|
There is a dependencies line in pyproject.toml: `"cryptography<49; platform_system == 'Darwin' and platform_machine == 'x86_64'"`
|
|
167
|
-
Changing this line to a later version of cryptography can break installs on Intel (x86_64) macOS, which CI does not
|
|
188
|
+
Changing this line to a later version of cryptography can break installs on Intel (x86_64) macOS, which premerge CI does not cover (the canary workflow checks wheels-only resolution for x86_64 macOS — weekly and on PRs into main — and the pin guard in tests/test_pyproject_pins.py is the merge-time enforcement).
|
|
168
189
|
From version 49, cryptography no longer ships a universal2 macOS wheel; on Intel macOS the resolver typically falls back to a source build that requires a newer Rust toolchain and may fail.
|
|
169
190
|
Changing to a later version of cryptography for other platforms is fine, but this pin should remain for macOS x86_64.
|
|
170
191
|
Do not change this line. If a PR touches this line, please add a strong comment and do not allow the change to be merged without a strong justification and approval from the repo owner.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# GitHub's auto-generated release-notes configuration ("Generate release notes" on the release
|
|
2
|
+
# page groups merged PRs by these label-based categories). NOTE: this is .github/release.yml —
|
|
3
|
+
# GitHub's release-NOTES config — distinct from .github/workflows/publish.yaml (the release
|
|
4
|
+
# pipeline). Dependabot applies the `dependencies` label automatically; everything else lands in
|
|
5
|
+
# the catch-all, and the hand-written release intro stays hand-written.
|
|
6
|
+
changelog:
|
|
7
|
+
categories:
|
|
8
|
+
- title: Dependencies
|
|
9
|
+
labels:
|
|
10
|
+
- dependencies
|
|
11
|
+
- title: Changes
|
|
12
|
+
labels:
|
|
13
|
+
- "*"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
name: Weekly canary
|
|
2
|
+
|
|
3
|
+
# Proactive platform/ecosystem drift detection — the class of breakage premerge CI (ubuntu +
|
|
4
|
+
# CPython 3.14 only) can't see. Two angles:
|
|
5
|
+
#
|
|
6
|
+
# 1. resolve-crossplatform (ubuntu, no mac/windows hardware needed): wheels-only dependency
|
|
7
|
+
# resolution for Intel macOS, Apple-silicon macOS, and Windows — against BOTH the repo's
|
|
8
|
+
# pyproject.toml (would the next release install?) and the latest published PyPI release
|
|
9
|
+
# (did an upstream dep pull its wheels out from under an already-shipped version?). This is
|
|
10
|
+
# exactly the check that would have caught cryptography 49 dropping its x86_64-macOS wheel.
|
|
11
|
+
# 2. install-smoke (real runners): install the published package with uv and import/run it on
|
|
12
|
+
# macos-latest (arm64), macos-15-intel (x86_64), and windows-latest.
|
|
13
|
+
#
|
|
14
|
+
# On failure the notify job files (or comments on) a deduplicated issue labeled
|
|
15
|
+
# ci-failure + wf:canary — one open issue per stream, so four red Mondays make one thread, which
|
|
16
|
+
# the scheduled Claude responder routine (and the humans it defers to) works from.
|
|
17
|
+
#
|
|
18
|
+
# PRs into main also run the repo-pyproject resolution leg — the only part that exercises the
|
|
19
|
+
# PR's own changes — so a dependency edit that breaks a platform is caught before merge, not the
|
|
20
|
+
# following Monday. The published-package legs (PyPI-release resolution, install smokes) and the
|
|
21
|
+
# issue filing stay schedule/dispatch-only: on a PR they would re-test the already-shipped
|
|
22
|
+
# release and could block a merge on breakage the PR didn't cause.
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
on:
|
|
28
|
+
schedule:
|
|
29
|
+
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
|
30
|
+
workflow_dispatch:
|
|
31
|
+
pull_request:
|
|
32
|
+
branches: [main]
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
|
|
36
|
+
resolve-crossplatform:
|
|
37
|
+
name: Cross-platform wheel resolution
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- name: Check out source repository
|
|
41
|
+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
42
|
+
|
|
43
|
+
- name: Set up uv
|
|
44
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
45
|
+
|
|
46
|
+
- name: Resolve runtime deps as wheels-only for each platform
|
|
47
|
+
# --only-binary :all: makes resolution fail if any dependency lacks a wheel for the
|
|
48
|
+
# target platform — a source-build fallback is exactly the breakage this hunts (users
|
|
49
|
+
# without a Rust/C toolchain). pyproject.toml resolution covers [project].dependencies
|
|
50
|
+
# only (the uvx install surface, not dev groups). If a deliberately sdist-only dep ever
|
|
51
|
+
# enters the tree, exempt it per-package rather than dropping :all:.
|
|
52
|
+
run: |
|
|
53
|
+
set -euo pipefail
|
|
54
|
+
if [ "$GITHUB_EVENT_NAME" != "pull_request" ]; then
|
|
55
|
+
latest="$(curl -fsSL https://pypi.org/pypi/docker-mcp-server/json | jq -r .info.version)"
|
|
56
|
+
echo "Latest published release: $latest"
|
|
57
|
+
fi
|
|
58
|
+
failed=0
|
|
59
|
+
for target in x86_64-apple-darwin aarch64-apple-darwin x86_64-pc-windows-msvc; do
|
|
60
|
+
echo "::group::$target (repo pyproject.toml)"
|
|
61
|
+
# stdout (the compiled lock) is discarded — only resolution success matters. Don't
|
|
62
|
+
# use `-o /dev/null`: uv writes output atomically via a sibling temp file, which
|
|
63
|
+
# fails inside /dev.
|
|
64
|
+
if ! uv pip compile pyproject.toml --python-platform "$target" --python-version 3.14 \
|
|
65
|
+
--only-binary :all: > /dev/null; then
|
|
66
|
+
echo "::error::wheels-only resolution FAILED for $target against the repo pyproject.toml — the next release would not install there"
|
|
67
|
+
failed=1
|
|
68
|
+
fi
|
|
69
|
+
echo "::endgroup::"
|
|
70
|
+
# The published-release leg tests what's already on PyPI, not this checkout — on a PR
|
|
71
|
+
# it can only fail for reasons the PR didn't cause, so it runs on schedule/dispatch only.
|
|
72
|
+
if [ "$GITHUB_EVENT_NAME" != "pull_request" ]; then
|
|
73
|
+
echo "::group::$target (published docker-mcp-server==$latest)"
|
|
74
|
+
if ! echo "docker-mcp-server==$latest" | uv pip compile - --python-platform "$target" \
|
|
75
|
+
--python-version 3.14 --only-binary :all: > /dev/null; then
|
|
76
|
+
echo "::error::wheels-only resolution FAILED for $target against published $latest — an upstream dep dropped wheels under a shipped release"
|
|
77
|
+
failed=1
|
|
78
|
+
fi
|
|
79
|
+
echo "::endgroup::"
|
|
80
|
+
fi
|
|
81
|
+
done
|
|
82
|
+
exit "$failed"
|
|
83
|
+
|
|
84
|
+
install-smoke:
|
|
85
|
+
# Real install of the *published* package on each platform's actual runner.
|
|
86
|
+
# macos-15-intel is the last Intel-macOS hosted runner label; GitHub retires it August 2027.
|
|
87
|
+
# When the label disappears, drop that matrix leg — wheel resolution for x86_64-apple-darwin
|
|
88
|
+
# continues in the resolve-crossplatform job above, which needs no Intel hardware.
|
|
89
|
+
# Skipped on PRs: it installs the published release, so it never exercises PR content.
|
|
90
|
+
if: github.event_name != 'pull_request'
|
|
91
|
+
name: Install smoke (${{ matrix.os }})
|
|
92
|
+
runs-on: ${{ matrix.os }}
|
|
93
|
+
timeout-minutes: 15
|
|
94
|
+
strategy:
|
|
95
|
+
fail-fast: false
|
|
96
|
+
matrix:
|
|
97
|
+
os: [macos-latest, macos-15-intel, windows-latest]
|
|
98
|
+
steps:
|
|
99
|
+
- name: Set up uv
|
|
100
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
101
|
+
with:
|
|
102
|
+
# This job deliberately has no checkout — it tests the *published* package, not the
|
|
103
|
+
# repo — so disable the repo-keyed cache and the empty-workdir warning it triggers.
|
|
104
|
+
enable-cache: false
|
|
105
|
+
ignore-empty-workdir: true
|
|
106
|
+
|
|
107
|
+
- name: Import smoke (full tool-registration path, no daemon needed)
|
|
108
|
+
run: uv run --no-project --python 3.14 --with docker-mcp-server python -c "import docker_mcp"
|
|
109
|
+
|
|
110
|
+
- name: Entry-point smoke (uvx console script)
|
|
111
|
+
# Requires the published package to have the --version flag (shipped in v1.9.3).
|
|
112
|
+
run: uvx --python 3.14 docker-mcp-server --version
|
|
113
|
+
|
|
114
|
+
notify:
|
|
115
|
+
name: File failure issue
|
|
116
|
+
runs-on: ubuntu-latest
|
|
117
|
+
needs: [resolve-crossplatform, install-smoke]
|
|
118
|
+
# PR failures are visible on the PR itself; the issue stream is for unattended runs.
|
|
119
|
+
if: failure() && github.event_name != 'pull_request'
|
|
120
|
+
permissions:
|
|
121
|
+
contents: read # checkout, to use the local composite action
|
|
122
|
+
issues: write
|
|
123
|
+
steps:
|
|
124
|
+
- name: Check out source repository
|
|
125
|
+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
126
|
+
|
|
127
|
+
- name: File or update the failure issue
|
|
128
|
+
uses: ./.github/actions/file-failure-issue
|
|
129
|
+
with:
|
|
130
|
+
label: 'wf:canary'
|
|
131
|
+
title: 'Weekly canary failed'
|
|
132
|
+
token: ${{ github.token }}
|
|
@@ -21,6 +21,11 @@ on:
|
|
|
21
21
|
- '.github/workflows/images.yaml'
|
|
22
22
|
workflow_dispatch:
|
|
23
23
|
|
|
24
|
+
# Cancel superseded runs on the same ref (image builds are the most expensive premerge jobs).
|
|
25
|
+
concurrency:
|
|
26
|
+
group: images-${{ github.ref }}
|
|
27
|
+
cancel-in-progress: true
|
|
28
|
+
|
|
24
29
|
jobs:
|
|
25
30
|
|
|
26
31
|
build-and-measure:
|
|
@@ -11,6 +11,12 @@ on:
|
|
|
11
11
|
pull_request:
|
|
12
12
|
workflow_dispatch:
|
|
13
13
|
|
|
14
|
+
# Cancel superseded runs on the same ref (e.g. rapid pushes to a PR branch); a push to main
|
|
15
|
+
# gets its own ref group so PR runs never cancel main's. Same shape codeql.yaml uses.
|
|
16
|
+
concurrency:
|
|
17
|
+
group: premerge-${{ github.ref }}
|
|
18
|
+
cancel-in-progress: true
|
|
19
|
+
|
|
14
20
|
# Jobs
|
|
15
21
|
jobs:
|
|
16
22
|
|
|
@@ -22,7 +28,7 @@ jobs:
|
|
|
22
28
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
23
29
|
|
|
24
30
|
- name: Set up Python environment
|
|
25
|
-
uses: actions/setup-python@
|
|
31
|
+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
26
32
|
with:
|
|
27
33
|
python-version: "3.14"
|
|
28
34
|
|
|
@@ -30,13 +36,18 @@ jobs:
|
|
|
30
36
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
31
37
|
|
|
32
38
|
- name: Install dependencies
|
|
33
|
-
|
|
39
|
+
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
40
|
+
# re-locking — catches a lockfile-only dependency change (e.g. a Dependabot lock
|
|
41
|
+
# rewrite that raises a cap pyproject still pins) that would otherwise pass CI.
|
|
42
|
+
run: uv sync --all-groups --locked
|
|
34
43
|
|
|
35
44
|
- name: Run pytest
|
|
36
45
|
run: uv run pytest -v
|
|
37
46
|
|
|
38
47
|
ruff-run:
|
|
39
|
-
# Runs ruff
|
|
48
|
+
# Runs ruff lint + format checks. `uv run` (not `uvx`) so the ruff version is the
|
|
49
|
+
# dev-group pin resolved through uv.lock — the same version pre-commit hooks run
|
|
50
|
+
# locally — rather than a floating latest that can disagree with both.
|
|
40
51
|
|
|
41
52
|
runs-on: [ubuntu-latest]
|
|
42
53
|
|
|
@@ -46,7 +57,7 @@ jobs:
|
|
|
46
57
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
47
58
|
|
|
48
59
|
- name: Set up Python environment
|
|
49
|
-
uses: actions/setup-python@
|
|
60
|
+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
50
61
|
with:
|
|
51
62
|
python-version: "3.14"
|
|
52
63
|
|
|
@@ -54,10 +65,70 @@ jobs:
|
|
|
54
65
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
55
66
|
|
|
56
67
|
- name: Install dependencies
|
|
57
|
-
|
|
68
|
+
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
69
|
+
# re-locking — catches a lockfile-only dependency change (e.g. a Dependabot lock
|
|
70
|
+
# rewrite that raises a cap pyproject still pins) that would otherwise pass CI.
|
|
71
|
+
run: uv sync --all-groups --locked
|
|
72
|
+
|
|
73
|
+
- name: Run ruff lint
|
|
74
|
+
run: uv run ruff check .
|
|
75
|
+
|
|
76
|
+
- name: Run ruff format check
|
|
77
|
+
run: uv run ruff format --check .
|
|
78
|
+
|
|
79
|
+
pyright-run:
|
|
80
|
+
# Type-checks with the pyright pin from the dev group ([tool.pyright] config in
|
|
81
|
+
# pyproject.toml). Documented as a project convention; this job enforces it.
|
|
82
|
+
runs-on: [ubuntu-latest]
|
|
83
|
+
name: Run pyright
|
|
84
|
+
steps:
|
|
85
|
+
- name: Check out source repository
|
|
86
|
+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
87
|
+
|
|
88
|
+
- name: Set up Python environment
|
|
89
|
+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
90
|
+
with:
|
|
91
|
+
python-version: "3.14"
|
|
58
92
|
|
|
59
|
-
- name:
|
|
60
|
-
|
|
93
|
+
- name: Set up uv
|
|
94
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
95
|
+
|
|
96
|
+
- name: Install dependencies
|
|
97
|
+
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
98
|
+
# re-locking — catches a lockfile-only dependency change (e.g. a Dependabot lock
|
|
99
|
+
# rewrite that raises a cap pyproject still pins) that would otherwise pass CI.
|
|
100
|
+
run: uv sync --all-groups --locked
|
|
101
|
+
|
|
102
|
+
- name: Run pyright
|
|
103
|
+
run: uv run pyright
|
|
104
|
+
|
|
105
|
+
mirror-check:
|
|
106
|
+
# CLAUDE.md and .github/copilot-instructions.md are mirrors (see the MIRROR RULE in
|
|
107
|
+
# CLAUDE.md); this flags a PR that edits one without the other. Deliberately NOT a
|
|
108
|
+
# required check — a one-sided edit can be legitimate (typo, wording) — so a red X
|
|
109
|
+
# here is a prompt to double-check, not a merge blocker. Fork-safe: read-only, no
|
|
110
|
+
# secrets, plain git diff (a PR-comment approach would need pull-requests:write,
|
|
111
|
+
# which fork PRs don't get).
|
|
112
|
+
runs-on: [ubuntu-latest]
|
|
113
|
+
name: Check docs mirror
|
|
114
|
+
if: github.event_name == 'pull_request'
|
|
115
|
+
steps:
|
|
116
|
+
- name: Check out source repository
|
|
117
|
+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
118
|
+
with:
|
|
119
|
+
fetch-depth: 0 # need the merge-base with the PR base branch
|
|
120
|
+
|
|
121
|
+
- name: Compare CLAUDE.md and copilot-instructions.md changes
|
|
122
|
+
run: |
|
|
123
|
+
base="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)"
|
|
124
|
+
changed="$(git diff --name-only "$base" HEAD)"
|
|
125
|
+
c=$(grep -cx 'CLAUDE.md' <<<"$changed" || true)
|
|
126
|
+
g=$(grep -cx '.github/copilot-instructions.md' <<<"$changed" || true)
|
|
127
|
+
if [ "$c" != "$g" ]; then
|
|
128
|
+
echo "::error::MIRROR RULE: this PR changes one of CLAUDE.md / .github/copilot-instructions.md but not the other. If the change is architectural, mirror it; if it's a legitimate one-sided edit (typo/wording), this warning can be ignored — the check is not required for merge."
|
|
129
|
+
exit 1
|
|
130
|
+
fi
|
|
131
|
+
echo "Docs mirror OK (both changed: $c)"
|
|
61
132
|
|
|
62
133
|
integration-tests:
|
|
63
134
|
# Runs the suite gated by `-m integration` against the Docker daemon that
|
|
@@ -82,7 +153,7 @@ jobs:
|
|
|
82
153
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
83
154
|
|
|
84
155
|
- name: Set up Python environment
|
|
85
|
-
uses: actions/setup-python@
|
|
156
|
+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
86
157
|
with:
|
|
87
158
|
python-version: "3.14"
|
|
88
159
|
|
|
@@ -90,7 +161,10 @@ jobs:
|
|
|
90
161
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
91
162
|
|
|
92
163
|
- name: Install dependencies
|
|
93
|
-
|
|
164
|
+
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
165
|
+
# re-locking — catches a lockfile-only dependency change (e.g. a Dependabot lock
|
|
166
|
+
# rewrite that raises a cap pyproject still pins) that would otherwise pass CI.
|
|
167
|
+
run: uv sync --all-groups --locked
|
|
94
168
|
|
|
95
169
|
- name: Verify Docker tooling on the runner
|
|
96
170
|
# ubuntu-latest ships Docker Engine + docker CLI + compose v2 + buildx.
|