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.
Files changed (115) hide show
  1. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.claude/commands/docker-sdk.md +1 -1
  2. docker_mcp_server-2.0.0/.github/actions/file-failure-issue/action.yaml +75 -0
  3. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/copilot-instructions.md +39 -18
  4. docker_mcp_server-2.0.0/.github/release.yml +13 -0
  5. docker_mcp_server-2.0.0/.github/workflows/canary.yaml +132 -0
  6. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/workflows/images.yaml +5 -0
  7. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/workflows/premerge.yaml +83 -9
  8. docker_mcp_server-2.0.0/.github/workflows/publish-homebrew.yaml +227 -0
  9. docker_mcp_server-2.0.0/.github/workflows/publish.yaml +552 -0
  10. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/CLAUDE.md +59 -18
  11. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/DOCKERHUB.md +2 -4
  12. docker_mcp_server-2.0.0/MIGRATION-2.0.md +219 -0
  13. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/PKG-INFO +49 -35
  14. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/PRIVACY.md +1 -1
  15. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/README.md +48 -34
  16. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/SECURITY.md +1 -1
  17. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/__init__.py +13 -1
  18. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/_env.py +4 -4
  19. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/_hosts.py +1 -1
  20. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/server.py +115 -128
  21. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/__init__.py +1 -1
  22. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_cli.py +18 -0
  23. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_labels.py +3 -4
  24. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_utils.py +1 -2
  25. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/buildx.py +23 -25
  26. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/compose.py +12 -13
  27. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/configs.py +14 -14
  28. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/containers.py +161 -205
  29. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/context.py +3 -3
  30. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/images.py +74 -81
  31. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/networks.py +19 -19
  32. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/nodes.py +15 -11
  33. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/plugins.py +25 -41
  34. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/prompts.py +84 -84
  35. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/registry.py +29 -28
  36. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/resources.py +3 -3
  37. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/secrets.py +14 -15
  38. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/services.py +51 -52
  39. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/stack.py +24 -27
  40. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/swarm.py +21 -43
  41. docker_mcp_server-1.9.2/docker_mcp/tools/client.py → docker_mcp_server-2.0.0/docker_mcp/tools/system.py +22 -25
  42. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/volumes.py +11 -11
  43. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/manifest.json +2 -2
  44. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/pyproject.toml +2 -2
  45. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/scripts/build-mcpb.sh +2 -2
  46. docker_mcp_server-2.0.0/scripts/docker-mcp-server.rb.tpl +44 -0
  47. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/conftest.py +13 -13
  48. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/conftest.py +2 -2
  49. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_buildx.py +4 -4
  50. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_compose.py +2 -2
  51. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_containers.py +11 -11
  52. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_context.py +3 -3
  53. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_file_payloads.py +10 -10
  54. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_networks.py +5 -5
  55. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_registry.py +8 -8
  56. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_smoke.py +11 -11
  57. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_stack.py +5 -5
  58. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_buildx.py +17 -17
  59. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_cli.py +22 -0
  60. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_compose.py +14 -7
  61. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_configs.py +9 -9
  62. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_containers.py +145 -118
  63. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_context.py +8 -8
  64. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_images.py +54 -47
  65. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_labels.py +8 -8
  66. docker_mcp_server-2.0.0/tests/test_main.py +33 -0
  67. docker_mcp_server-2.0.0/tests/test_naming.py +157 -0
  68. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_networks.py +22 -22
  69. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_nodes.py +10 -10
  70. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_plugins.py +24 -33
  71. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_prompts.py +42 -41
  72. docker_mcp_server-2.0.0/tests/test_pyproject_pins.py +93 -0
  73. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_registry.py +39 -56
  74. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_secrets.py +9 -9
  75. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_server.py +44 -60
  76. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_services.py +36 -30
  77. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_stack.py +13 -13
  78. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_swarm.py +31 -42
  79. docker_mcp_server-1.9.2/tests/test_client.py → docker_mcp_server-2.0.0/tests/test_system.py +143 -147
  80. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_utils.py +0 -10
  81. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_volumes.py +13 -13
  82. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/uv.lock +31 -31
  83. docker_mcp_server-1.9.2/.github/workflows/publish-images.yaml +0 -146
  84. docker_mcp_server-1.9.2/.github/workflows/publish-mcpb.yaml +0 -60
  85. docker_mcp_server-1.9.2/.github/workflows/publish-pypi.yaml +0 -39
  86. docker_mcp_server-1.9.2/.github/workflows/publish-registry.yaml +0 -133
  87. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.claude/settings.json +0 -0
  88. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.dockerignore +0 -0
  89. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/CODEOWNERS +0 -0
  90. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  91. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  92. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/dependabot.yaml +0 -0
  93. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.github/workflows/codeql.yaml +0 -0
  94. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.gitignore +0 -0
  95. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.mcpbignore +0 -0
  96. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/.python-version +0 -0
  97. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/Dockerfile +0 -0
  98. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/LICENSE +0 -0
  99. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/assets/README.md +0 -0
  100. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/assets/icon.png +0 -0
  101. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/__main__.py +0 -0
  102. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/_ssh_proxy.py +0 -0
  103. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/docker_mcp/tools/scout.py +0 -0
  104. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/glama.json +0 -0
  105. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/mcpb_run.py +0 -0
  106. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/server.json +0 -0
  107. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/__init__.py +0 -0
  108. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/__init__.py +0 -0
  109. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_cli.py +0 -0
  110. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/integration/test_scout.py +0 -0
  111. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_env.py +0 -0
  112. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_hosts.py +0 -0
  113. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_resources.py +0 -0
  114. {docker_mcp_server-1.9.2 → docker_mcp_server-2.0.0}/tests/test_scout.py +0 -0
  115. {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/client.py` | `tests/test_client.py` | `DockerClient` |
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 `client._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).
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`), which still honors the pre-rename `DOCKER_MCP_*` spellings as deprecated aliases and warns once to stderr.
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 `close`/`reconnect`/`login`/`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.** `client.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. **`reconnect(host=None)` is rebuild-only** — it can't retarget to an arbitrary URL (edit the registry + restart), closing a trust-expansion hole. `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.** `list_hosts` 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.
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
- | `client.py` | `DockerClient` — connection, lifecycle, `login`/`logout`, `reconnect` | docker-py |
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 `DOCKER_MCP_*` env switches so the suite is hermetic.
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-images.yaml` — 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:
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 `*_to_file` write 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.
84
- - **Self-termination guard** — `client.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; `client.guard_not_self(container, host=)` stops the destructive container-lifecycle tools (`remove`/`kill`/`stop`/`restart`/`pause_container`) from acting on the server's own container, **only when the call targets the self host** (override `DOCKER_MCP_SERVER_ALLOW_SELF_TERMINATE=1`).
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-mcpb.yaml` rewrites the manifest `version` from the release tag, packs the `.mcpb` via `npx @anthropic-ai/mcpb`, and attaches it (plus a `.sha256`) 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.
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-registry.yaml` runs on each Release: 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 `publish-images.yaml` (OCI), and the `.mcpb` URL + hash (MCPB). Keep them in sync with `server.json`'s `name`.
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 `list_containers` / `list_networks` / `list_volumes` / `list_services`, 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).
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** (`context_ls`, `buildx_ls`/`du`, `compose_ls`) raise `RuntimeError` via `raise_on_cli_failure` because they cannot return a useful partial parse.
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 `*_to_file` / `*_from_file` variants, which stream via `stream_to_file` / an open file handle.
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 `client.py:events` and `containers.py:follow_container_logs`.
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 currently cover.
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
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
- run: uv sync --all-groups
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 lints
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
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
- run: uv sync --all-groups
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: Run ruff
60
- run: uvx ruff check .
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
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
- run: uv sync --all-groups
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.