docker-mcp-server 2.2.2__tar.gz → 2.2.4__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 (149) hide show
  1. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.dockerignore +1 -0
  2. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/copilot-instructions.md +56 -18
  3. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/workflows/canary.yaml +14 -1
  4. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/workflows/codeql.yaml +3 -3
  5. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/workflows/premerge.yaml +52 -0
  6. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/workflows/publish.yaml +98 -5
  7. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.mcpbignore +4 -0
  8. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/CLAUDE.md +132 -23
  9. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/DOCKERHUB.md +15 -11
  10. docker_mcp_server-2.2.4/MCP_VS_SKILLS.md +630 -0
  11. docker_mcp_server-2.2.4/PKG-INFO +493 -0
  12. docker_mcp_server-2.2.4/PRIVACY.md +38 -0
  13. docker_mcp_server-2.2.4/README.md +466 -0
  14. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/_hosts.py +27 -11
  15. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/server.py +167 -22
  16. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/_cli.py +26 -0
  17. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/_utils.py +31 -0
  18. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/buildx.py +10 -2
  19. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/compose.py +7 -4
  20. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/containers.py +47 -13
  21. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/context.py +23 -11
  22. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/images.py +51 -3
  23. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/networks.py +4 -2
  24. docker_mcp_server-2.2.4/docker_mcp/tools/plugins.py +288 -0
  25. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/prompts.py +14 -11
  26. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/registry.py +85 -1
  27. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/resources.py +40 -4
  28. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/scout.py +45 -31
  29. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/services.py +3 -12
  30. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/stack.py +10 -4
  31. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/system.py +38 -5
  32. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/manifest.json +4 -4
  33. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/pyproject.toml +9 -12
  34. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/scripts/docker-mcp-server.rb.tpl +19 -8
  35. docker_mcp_server-2.2.4/scripts/measure-comparison-figures.py +614 -0
  36. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/server.json +4 -4
  37. docker_mcp_server-2.2.4/skills/l337-docker/LICENSE +9 -0
  38. docker_mcp_server-2.2.4/skills/l337-docker/SKILL.md +221 -0
  39. docker_mcp_server-2.2.4/skills/l337-docker/reference/buildx.md +156 -0
  40. docker_mcp_server-2.2.4/skills/l337-docker/reference/compose.md +181 -0
  41. docker_mcp_server-2.2.4/skills/l337-docker/reference/containers.md +210 -0
  42. docker_mcp_server-2.2.4/skills/l337-docker/reference/docs.md +85 -0
  43. docker_mcp_server-2.2.4/skills/l337-docker/reference/images.md +157 -0
  44. docker_mcp_server-2.2.4/skills/l337-docker/reference/networks-volumes.md +137 -0
  45. docker_mcp_server-2.2.4/skills/l337-docker/reference/observability.md +208 -0
  46. docker_mcp_server-2.2.4/skills/l337-docker/reference/registry.md +170 -0
  47. docker_mcp_server-2.2.4/skills/l337-docker/reference/scout.md +132 -0
  48. docker_mcp_server-2.2.4/skills/l337-docker/reference/swarm.md +232 -0
  49. docker_mcp_server-2.2.4/skills/l337-docker/reference/system.md +182 -0
  50. docker_mcp_server-2.2.4/skills/l337-docker/workflows/build-publish.md +129 -0
  51. docker_mcp_server-2.2.4/skills/l337-docker/workflows/deploy.md +122 -0
  52. docker_mcp_server-2.2.4/skills/l337-docker/workflows/maintenance.md +220 -0
  53. docker_mcp_server-2.2.4/skills/l337-docker/workflows/security.md +129 -0
  54. docker_mcp_server-2.2.4/skills/l337-docker/workflows/troubleshoot.md +173 -0
  55. docker_mcp_server-2.2.4/tests/integration/conftest.py +118 -0
  56. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_buildx.py +9 -4
  57. docker_mcp_server-2.2.4/tests/integration/test_cli_flag_drift.py +157 -0
  58. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_compose.py +8 -3
  59. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_containers.py +5 -4
  60. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_file_payloads.py +7 -1
  61. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_networks.py +3 -2
  62. docker_mcp_server-2.2.4/tests/integration/test_scout.py +33 -0
  63. docker_mcp_server-2.2.4/tests/integration/test_skill.py +348 -0
  64. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_stack.py +1 -1
  65. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_buildx.py +6 -2
  66. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_containers.py +130 -2
  67. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_context.py +44 -0
  68. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_hosts.py +26 -0
  69. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_images.py +20 -0
  70. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_naming.py +1 -0
  71. docker_mcp_server-2.2.4/tests/test_plugins.py +218 -0
  72. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_pyproject_pins.py +71 -43
  73. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_registry.py +136 -0
  74. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_remote_exec.py +3 -3
  75. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_resources.py +46 -2
  76. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_scout.py +38 -22
  77. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_server.py +347 -1
  78. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_services.py +9 -0
  79. docker_mcp_server-2.2.4/tests/test_skill.py +411 -0
  80. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_ssh_proxy.py +8 -4
  81. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_stack.py +5 -1
  82. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_system.py +48 -6
  83. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_utils.py +74 -0
  84. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/uv.lock +47 -52
  85. docker_mcp_server-2.2.2/PKG-INFO +0 -444
  86. docker_mcp_server-2.2.2/PRIVACY.md +0 -27
  87. docker_mcp_server-2.2.2/README.md +0 -416
  88. docker_mcp_server-2.2.2/docker_mcp/tools/plugins.py +0 -154
  89. docker_mcp_server-2.2.2/tests/integration/conftest.py +0 -39
  90. docker_mcp_server-2.2.2/tests/integration/test_scout.py +0 -27
  91. docker_mcp_server-2.2.2/tests/test_plugins.py +0 -90
  92. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.claude/commands/docker-sdk.md +0 -0
  93. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.claude/settings.json +0 -0
  94. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/CODEOWNERS +0 -0
  95. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  96. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  97. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  98. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/actions/file-failure-issue/action.yaml +0 -0
  99. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/dependabot.yaml +0 -0
  100. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/release.yml +0 -0
  101. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/workflows/images.yaml +0 -0
  102. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.github/workflows/publish-homebrew.yaml +0 -0
  103. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.gitignore +0 -0
  104. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/.python-version +0 -0
  105. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/CODE_OF_CONDUCT.md +0 -0
  106. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/CONTRIBUTING.md +0 -0
  107. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/Dockerfile +0 -0
  108. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/LICENSE +0 -0
  109. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/MIGRATION-2.0.md +0 -0
  110. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/SECURITY.md +0 -0
  111. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/assets/README.md +0 -0
  112. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/assets/icon.png +0 -0
  113. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/__init__.py +0 -0
  114. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/__main__.py +0 -0
  115. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/_env.py +0 -0
  116. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/__init__.py +0 -0
  117. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/_labels.py +0 -0
  118. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/_ssh_proxy.py +0 -0
  119. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/configs.py +0 -0
  120. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/nodes.py +0 -0
  121. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/secrets.py +0 -0
  122. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/swarm.py +0 -0
  123. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/docker_mcp/tools/volumes.py +0 -0
  124. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/glama.json +0 -0
  125. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/mcpb_run.py +0 -0
  126. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/scripts/build-mcpb.sh +0 -0
  127. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/__init__.py +0 -0
  128. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/conftest.py +0 -0
  129. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/__init__.py +0 -0
  130. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_cli.py +0 -0
  131. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_context.py +0 -0
  132. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_nodes.py +0 -0
  133. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_registry.py +0 -0
  134. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_remote_exec.py +0 -0
  135. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_services.py +0 -0
  136. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/integration/test_smoke.py +0 -0
  137. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_cli.py +0 -0
  138. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_compose.py +0 -0
  139. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_configs.py +0 -0
  140. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_docs.py +0 -0
  141. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_env.py +0 -0
  142. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_labels.py +0 -0
  143. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_main.py +0 -0
  144. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_networks.py +0 -0
  145. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_nodes.py +0 -0
  146. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_prompts.py +0 -0
  147. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_secrets.py +0 -0
  148. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_swarm.py +0 -0
  149. {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.4}/tests/test_volumes.py +0 -0
@@ -20,6 +20,7 @@ wheels
20
20
  .cursor
21
21
  .claude
22
22
  tests
23
+ skills
23
24
  .DS_Store
24
25
  *~
25
26
  *.bak
@@ -4,7 +4,7 @@ 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, Stack, Buildx, Scout, Context) 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/l337-org/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 `L337-org/homebrew-tap` but is currently **paused** see "Homebrew tap" 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, Stack, Buildx, Scout, Context) 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/l337-org/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 `L337-org/homebrew-tap` but is currently **paused** - see "Homebrew tap" below. The repo additionally ships a **Claude Code agent skill** in `skills/l337-docker/` - deliberately **not** a channel for the server but a CLI-only *alternative* to it, attached to each Release as a single `.tar.gz`; see "Agent skill" below.
8
8
 
9
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, Stack, Buildx, Scout, Context) 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). With no local `docker` binary (or plugin) at all, all of those except the `context_*` tools (which manage *this* host's own CLI contexts) fall back to running the command on the `ssh://` host itself — see "SSH remote-exec fallback" under the CLI shell-out policy. Both the docker-py-backed and CLI-backed SSH connections fall back from IPv6 to IPv4 on any connect failure (not just paramiko's own narrower retry) via `_ssh_proxy.py:connect_socket_with_family_fallback` — see "Client / CLI" below.
10
10
 
@@ -34,10 +34,10 @@ from docker_mcp.server import mcp # resource modules only
34
34
 
35
35
  `DOCKER_MCP_SERVER_HOSTS` lets one server manage several daemons in a session (e.g. local dev + remote prod). **When set, `DOCKER_HOST` is ignored** (a one-time stderr notice fires when both are set); unset = today's single-daemon behavior (`DOCKER_HOST`, else auto-discovery). The mcpb bundle exposes only this field. `_hosts.py` lives at the package root (like `_env.py`, so `server.py` can import it without pulling in `docker_mcp.tools`) and parses the var into a pinned `{label: Host}` registry — pure env + Docker-config-file reads, no docker-py/CLI calls:
36
36
 
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.
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), `(nd)` (non-destructive — blocks only DESTRUCTIVE calls; `(ro)` already implies it, so combining is harmless but redundant) 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 `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. Every `ssh://` URL is run through `_ensure_reachable_family(_ensure_ssh_port(url))` before being handed to docker-py: `_ensure_ssh_port` splices in a `~/.ssh/config` `Port` that `docker.utils.parse_host()` would otherwise hardcode to 22 before `SSHHTTPAdapter` ever sees it; `_ensure_reachable_family` works around paramiko's own connect() only retrying the next resolved address family on `ECONNREFUSED`/`EHOSTUNREACH` (not `ETIMEDOUT`, what a broken IPv6 route actually produces) by probe-connecting itself via `_ssh_proxy.connect_socket_with_family_fallback` and splicing the address that answered into the URL as a literal IP — no monkeypatching of docker-py internals. A URL that's already a literal address, or where every candidate fails, is left unchanged. When reviewing a change near ssh:// connection handling, check whether both `_ensure_ssh_port` and `_ensure_reachable_family` are still being composed at every call site that builds a docker-py client from a URL.
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, DESTRUCTIVE calls to an `(nd)` 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)` or `(nd)`**: a lone `(ro)`/`(nd)` host has its `host` param stripped (footprint-neutral) but its refusals still apply, so the per-host markers are honored even in single-host mode (distinct from `DOCKER_MCP_SERVER_READONLY`/`_NO_DESTRUCTIVE`, which drop tools entirely). A host with both markers is refused by `(ro)` first — it's strictly stronger, so `(nd)` never fires for it. A single *unrestricted* 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. **Every `from_env` call must pass `use_context=False`** — docker-py 7.2.0 (the declared floor, for this reason) made `from_env` resolve the active Docker CLI context when the environment yields no `base_url`, duplicating resolution `_hosts.py` already does and pins at `load()`; a new `from_env` call site without it is a review finding, as is relaxing the `docker[ssh]>=7.2.0` floor. **`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. Every `ssh://` URL is run through `_ensure_reachable_family(_ensure_ssh_port(url))` before being handed to docker-py: `_ensure_ssh_port` splices in a `~/.ssh/config` `Port` that `docker.utils.parse_host()` would otherwise hardcode to 22 before `SSHHTTPAdapter` ever sees it; `_ensure_reachable_family` works around paramiko's own connect() only retrying the next resolved address family on `ECONNREFUSED`/`EHOSTUNREACH` (not `ETIMEDOUT`, what a broken IPv6 route actually produces) by probe-connecting itself via `_ssh_proxy.connect_socket_with_family_fallback` and splicing the address that answered into the URL as a literal IP — no monkeypatching of docker-py internals. A URL that's already a literal address, or where every candidate fails, is left unchanged. When reviewing a change near ssh:// connection handling, check whether both `_ensure_ssh_port` and `_ensure_reachable_family` are still being composed at every call site that builds a docker-py client from a URL.
41
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.**
@@ -74,9 +74,11 @@ 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/integration/test_remote_exec.py` needs a remote *ssh://* host instead and is gated on `DOCKER_MCP_TEST_SSH_HOST` (not a server tunable, so deliberately outside the `DOCKER_MCP_SERVER_*` namespace). `tests/conftest.py` clears the `DOCKER_MCP_SERVER_*` env switches (and pins the host registry to one local daemon) so the suite is hermetic. `tests/test_docs.py` checks the repo's own prose today that any line describing the CLI-backed surface names a complete set of those domains, derived from `server.py`'s domain tuples; when reviewing a docs change, an incomplete enumeration fails there rather than needing to be spotted.
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/integration/test_remote_exec.py` needs a remote *ssh://* host instead and is gated on `DOCKER_MCP_TEST_SSH_HOST` (not a server tunable, so deliberately outside the `DOCKER_MCP_SERVER_*` namespace). `tests/conftest.py` clears the `DOCKER_MCP_SERVER_*` env switches (and pins the host registry to one local daemon) so the suite is hermetic. `tests/test_docs.py` checks the repo's own prose - today that any line describing the CLI-backed surface names a complete set of those domains, derived from `server.py`'s domain tuples; when reviewing a docs change, an incomplete enumeration fails there rather than needing to be spotted. `tests/test_skill.py` (static) and `tests/integration/test_skill.py` (daemon-backed) cover the `skills/l337-docker/` agent skill: frontmatter, cross-references, orphaned files, licence/attribution, one canonical lowercase provenance label, and a regression guard per shell/CLI trap the skill hit (`--until now` rejected, `timeout(1)` absent on macOS, `status` read-only in zsh, `compose ps` NDJSON vs `compose ls` array). Its parity checks derive from `tool_catalog()`, so **a PR adding a tool or domain fails until `MCP_VS_SKILLS.md`'s per-domain counts are updated** - when reviewing such a PR, expect that file to change too. The integration tests extract the skill's shell snippets from its markdown and execute them, so a snippet cannot drift from the CLI's actual behaviour; when reviewing a change to a snippet, check it is still the executed one rather than newly-unreferenced prose.
78
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.
79
+ **An integration test skips only for a cause it can name**, via `fail_unless_environmental` / `fail_unless_environmental_error` in `tests/integration/conftest.py`, which fail when the failure matches no entry in `_ENVIRONMENTAL_SIGNALS`. Three `scout` tools shipped broken because the tests skipped on any non-zero exit and reported a real defect as "offline or auth required". When reviewing a PR that adds an integration skip, check it names a specific tolerated condition; a blanket `except Exception` or `if returncode != 0: skip` is the defect, and the fix is to widen `_ENVIRONMENTAL_SIGNALS`, not the skip. `tests/integration/test_cli_flag_drift.py` asserts every literal CLI flag the tool modules pass still exists in the installed `--help`, which a mocked test cannot do; it has no exemption list by design, so a flag surviving as a hidden alias should be migrated to the documented spelling.
80
+
81
+ 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. An `Action pins are immutable` job fails the build when any `uses:` reference in `.github/workflows` or `.github/actions` names a tag or branch instead of a full 40-hex commit SHA: `publish.yaml` mints a PyPI Trusted Publishing OIDC token (`id-token: write`), pushes to GHCR (`packages: write`) and uploads release assets (`contents: write`), so a repointed tag would execute inside the jobs holding this project's strongest credentials - and Trusted Publishing has no stored token to steal, so executing there is the whole attack. First-party `actions/*` get no exemption (Dependabot maintains the SHA and its trailing `# vX.Y.Z` comment); local `./` actions are exempt; bare, quoted and uppercase SHAs are all accepted, since hex is case-insensitive and GitHub resolves an uppercase ref. The trailing version comment is convention, not enforced. When adding a step, look up the SHA (`gh api repos/<owner>/<action>/git/ref/tags/<tag> --jq .object.sha`) rather than writing `@vN`. The job is a port of the identically-named one in [L337-org/apt](https://github.com/L337-org/apt) (the reference implementation; `send-to-influx` has the third copy) - keep all three in step rather than letting them drift.
80
82
 
81
83
  A required `Check fresh resolve still imports` job (same workflow) covers what every `--locked` job above cannot: it resolves `pyproject.toml` with `uv pip install` (the pip-compatible interface, which never touches `uv.lock`) into a throwaway venv, then runs `import docker_mcp` and `docker-mcp-server --version` against that fresh install — the same set a `uvx`/`pip install` would actually get today, not the pinned known-good set in `uv.lock`. It reports a resolution failure (specifier unsatisfiable) separately from an import failure (resolved fine, broke on import), since a PR fixing one looks different from a PR fixing the other. When reviewing a dependency-cap change (raising or removing a cap, e.g. on `mcp` or `cryptography`), this job's result is the one that tells you whether the wider range still imports — a green `pytest-run` does not, since it stays on the locked pins.
82
84
 
@@ -95,15 +97,29 @@ A third distribution channel for one-click install in Claude Desktop. Repo-root
95
97
 
96
98
  ### Homebrew tap (`L337-org/homebrew-tap`) — PAUSED
97
99
 
98
- The infrastructure exists (`scripts/docker-mcp-server.rb.tpl`, `.github/workflows/publish-homebrew.yaml`, `L337-org/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.
100
+ The infrastructure exists (`scripts/docker-mcp-server.rb.tpl`, `.github/workflows/publish-homebrew.yaml`, `L337-org/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 this repo's README, and the tap's own README documents the pause rather than offering an install command.
101
+
102
+ **`skip_clean "libexec"` in the template is a candidate workaround, not the fix, and it is unverified.** `skip_clean` affects only Homebrew's post-install *cleanup* phase (symbol stripping, `.la` pruning, permission fixing) — verified against Homebrew 6.0.17, where `skip_clean?` is read only in `cleaner.rb`. It does **not** disable the separate keg-relocation step that rewrites dylib IDs via `install_name_tool`, which is where the headerpad failure occurs. Do not read its presence as evidence the issue is closed, and reject any claim that it suppresses relocation — that error has now been caught twice in review, both times in the Ruby formula comment rather than in Python code. The template also keeps `Formula[...].opt_bin` rather than the `formula_opt_bin` helper `brew style` recommends, deliberately: that helper only exists from Homebrew 6.0.3, so adopting it would fail the formula on older Homebrew for a style-only gain (labelled at the line).
103
+
104
+ To re-enable: (1) add `release: types: [published]` back to `publish-homebrew.yaml`'s `on:` block; (2) re-add the Homebrew section to this repo's README; (3) restore the tap's own README, which currently documents the pause — a release regenerates only `Formula/docker-mcp-server.rb`, never that file, so it will keep saying "paused" until edited by hand; (4) confirm the tap push still lands. That last one has never been exercised since the tap gained a `main` ruleset (2026-08-11) requiring signed commits, a PR, and Copilot review: the workflow does a raw `git push` of an unsigned `github-actions[bot]` commit, which only succeeds if the identity behind `secrets.TAP_GITHUB_TOKEN` holds a ruleset bypass. The tap's ruleset does grant `OrganizationAdmin` and repo-admin bypass `always`, so an org-admin-owned PAT should pass — but that is an inference, not an observation, so watch the first run.
99
105
 
100
106
  ### MCP Registry (`server.json`)
101
107
 
102
- 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.L337-org/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`.
108
+ 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.L337-org/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`. **`server.json`'s committed version is also kept in step with `pyproject.toml`** - `tests/test_pyproject_pins.py::test_server_json_versions_match_pyproject` scans every version-shaped token (the version appears in `version`, the pypi package's `version`, and inside both the oci and mcpb `identifier` strings), so a version-bump PR must touch `server.json` too. It was previously left stale on the grounds that the release job restamps it; that is true but the stale value read as drift to every reader and every scheduled audit, so consistency won. When reviewing a version bump, expect `pyproject.toml`, `manifest.json`, `server.json` and `uv.lock` to move together.
109
+
110
+ ### Agent skill (`skills/l337-docker/`)
111
+
112
+ A **Claude Code agent skill** driving Docker entirely through the `docker` CLI - the CLI-only alternative to running this server, a peer of it rather than a channel for it. Nothing in `docker_mcp/` imports or depends on it. Layout mirrors the server's three-layer discovery model: `SKILL.md` is the always-loaded router (preflight, always-apply rules, JSON-parsing contract, daemon targeting) mapping into `reference/` (11 domain files) and `workflows/` (5 files, ported from the MCP prompts); the comparison/coverage record is `MCP_VS_SKILLS.md` at the repo root (not inside the skill, which ships standalone and links to it on GitHub); `LICENSE` is a copy of the repo's MIT licence so the skill stands alone when downloaded. It follows the open [Agent Skills specification](https://agentskills.io/specification), so it is not Claude-specific: GitHub Copilot reads the same `SKILL.md` from `.github/skills`, `.agents/skills` or the same `.claude/skills` directory. `tests/test_skill.py` asserts the spec's `name`/`description` constraints, so an edit cannot quietly make it Claude-only.
113
+
114
+ **Guards are conventions here, not enforcement** - do not let a PR describe them otherwise. `DOCKER_MCP_SERVER_READONLY`, per-host `(ro)`/`(nd)` and the self-termination guard refuse at the server's call boundary; a skill has none, so its rules are instructions a model can skip. The skill declares **no tool-permission frontmatter** (`tools` / `disallowedTools`, nor the slash-command `allowed-tools` spelling) on purpose: an allow-list covering `docker` would pre-approve `docker rm -f` and defeat the skill's own confirmation rule - flag any PR adding one. Provenance labels are `l337-docker-skill.*`, a separate footprint from the server's `docker-mcp-server.managed=true`; the skill must never stamp the server's label, and the prefix stays **lowercase** because Docker label keys are case-sensitive (a mixed-case key silently matches nothing in a lowercase filter, so a teardown reports a clean daemon while resources remain).
115
+
116
+ Distribution is the `skill` job in `publish.yaml`. There is no official packaging format for a skill (unlike `.mcpb` for MCP servers) - a plain directory is the unit, so the archive is that directory rooted at `l337-docker/`, which is load-bearing for the install UX. tar.gz only: `tar` ships by default on macOS, Linux and Windows 10 1803+, while `unzip` is often absent on minimal Linux images. When reviewing a change to the skill's structure, provenance label, or the server's tool/prompt surface, check this section and `MCP_VS_SKILLS.md` stay accurate.
117
+
118
+ **`MCP_VS_SKILLS.md`'s measured figures are regenerated by `scripts/measure-comparison-figures.py`, never hand-counted** - token costs for all five configurations the document names (full, read-only, triage, core, floor), the description/parameter statistics, the skill's per-file costs and the per-task comparisons. Run it with `uv run scripts/measure-comparison-figures.py` (`--json` for structured output); a PEP 723 header pins its own `tiktoken` and installs the working tree editable, so it needs nothing in the dev group. It is developer-only and **report-only** (same footing as `scripts/build-mcpb.sh`) and **deliberately not a CI gate**: token figures move a few tokens on any docstring edit, so asserting them would fail constantly and signal nothing - **flag any PR proposing to promote it to a merge gate**, and flag a PR that edits a figure by hand instead of regenerating it. Drift is watched by the weekly "MCP vs skills figure drift" routine in `L337-org/claude-routines`, which opens a PR when a movement is worth republishing. What `tests/test_skill.py` *does* assert (binary, silent when broken): every configuration the script measures is still a document row, the published `DOCKER_MCP_SERVER_DISABLE` line matches the triage config the figures were measured with, and every per-task composition names registered tools and existing skill files. The method lives in the script's docstring: `tiktoken` `cl100k_base` over the wire form (`model_dump_json(by_alias=True, exclude_none=True)` - a client is sent `inputSchema`, not `input_schema`). Per-task compositions and the two skill prose counts are **chosen or heuristic, not measured**, and labelled so in the output; the document's eager-idle total omits resource templates, so the script reports that figure both ways.
103
119
 
104
120
  ### Release pipeline (`.github/workflows/publish.yaml`)
105
121
 
106
- 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. It also refuses to run when `github.repository_owner` isn't `L337-org`, so a release published from a fork (or mis-timed around a repo transfer) fails before anything ships rather than half-publishing. `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.
122
+ All publishing runs through one workflow on each **published GitHub Release**: `preflight` → `pypi` ∥ `images`(→`dockerhub-description`) ∥ `mcpb` → `registry` → `verify`, with `skill` running in parallel from `preflight` and joining at `verify`, plus a `notify` job on failure. `skill` is deliberately absent from `registry`'s `needs` (the registry entry doesn't reference the skill archives) but present in `verify`'s and `notify`'s - when reviewing a change to the job graph, check a new artifact job is wired into both, or verify races ahead of it and a pack failure files no issue. `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. It also refuses to run when `github.repository_owner` isn't `L337-org`, so a release published from a fork (or mis-timed around a repo transfer) fails before anything ships rather than half-publishing. `verify` confirms all channels serve the version (PyPI JSON API, both GHCR tags, `.mcpb` asset + checksum, registry listing, and the skill archive - checksum plus an extraction asserting it is rooted at `l337-docker/SKILL.md` and stamps this release's `VERSION`). `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` and the skill archive, duplicate-tolerant registry publish); the skill archive is additionally byte-reproducible (sorted entries, zeroed ownership, normalised mtimes, `gzip -n`), so a re-run republishes an identical checksum. **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.
107
123
 
108
124
  ## Conventions
109
125
 
@@ -118,16 +134,38 @@ All publishing runs through one workflow on each **published GitHub Release**: `
118
134
  1.x spellings, canonical shared-param descriptions) — a violating tool fails CI.
119
135
  - **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.
120
136
  - 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.
121
- - 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.
137
+ - 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. A PR adding a tool must also bump the domain's count in `MCP_VS_SKILLS.md` (and cover the CLI equivalent in the matching `reference/` file); `tests/test_skill.py` derives the counts from `tool_catalog()`, so an unbumped count fails CI - a genuinely uncoverable tool belongs under "Structural gaps", not omitted silently.
122
138
  - A daemon-targeting tool declares `host: str | None = None` (last parameter) and threads it to `_get_client(host)` / `run_docker(..., host=host)`; it is intentionally **not** documented in the docstring `args:` (the `@tool()` decorator generates its description and strips/enum-injects it per mode — see the host-registry section). Registry/hub and context tools omit `host`.
139
+ - **A parameter whose legal values are a genuinely closed set is typed `Literal[...]`, not `str`.**
140
+ Pydantic turns that into an `enum` in the advertised `inputSchema` (`_slim_schema` preserves it,
141
+ proven by a test) so an out-of-set value fails validation before anything executes, and the
142
+ docstring then drops the value list rather than repeating it. Verify the set against a primary
143
+ source - the subcommand's own `--help`, or docker-py's documented value list - never from
144
+ memory: `docker scout cves --only-severity CRITICAL` exits 0 reporting no vulnerabilities on an
145
+ image with three critical CVEs, and the daemon records an unrecognised `--scope` verbatim, so a
146
+ wrong value is not reliably rejected by Docker itself. Where the set is *not* provably closed
147
+ (compose validates `--protocol` not at all; buildx drivers are pluggable; docker-py documents no
148
+ values for `isolation`), leave it a `str` and record why at the parameter, so a later pass does
149
+ not re-propose it. `tests/test_server.py::test_closed_value_sets_are_advertised_as_enums` pins
150
+ the current set.
151
+ - **The `@tool()` decorator is generic (`def tool[F: Callable[..., Any]](...) -> Callable[[F], F]`)
152
+ so pyright checks arguments at every tool call site**, in tests and at internal callers alike.
153
+ Annotating it as a bare `Callable` erases the parameter list and silently disables that checking
154
+ everywhere: a wrong type, an unknown keyword and a value outside a `Literal` all passed the gate
155
+ until this was fixed. `tests/test_server.py::test_pyright_still_checks_arguments_at_tool_call_sites`
156
+ runs pyright over those three deliberate errors, so it also fails if only the return annotation is
157
+ loosened while the type parameter stays. A test that must pass a deliberately invalid value marks
158
+ that one call `# pyright: ignore[reportArgumentType]` with a reason, rather than being softened to
159
+ a legal one.
123
160
  - Line length limit: 120 characters.
124
161
  - **Bound any externally-sourced bytes before buffering/parsing them, and parse safely.** CLI output is capped in `run_docker` (`MAX_CLI_OUTPUT_BYTES`); registry HTTP bodies are streamed and capped at `_MAX_RESPONSE_BYTES` in `registry.py` (registries are agent-pointed/untrusted; the cap is on the *decoded* stream, so it also stops a decompression bomb). New code reading an untrusted file or network body must apply a similar bound. Always `json.loads` (never `eval`); if YAML is ever parsed in Python, `yaml.safe_load` only — today nothing parses YAML in Python (Compose YAML is read by the `docker` CLI). Flag a PR that buffers an untrusted body unbounded.
162
+ - **A URL taken from a response body is pinned to the origin it came from; a URL the caller named is not.** Who chose the destination is the distinction. A registry host in a tool argument is the caller's choice and the point of the `registry_*`/`hub_*` tools, so it is not restricted. A `next`/`Link` URL is the *response's* choice: `_validate_hub_next` requires Hub pagination to stay on `_HUB_API_BASE`'s scheme/host/port, and the OCI tag-list path keeps only the path from a `Link` header and re-applies the registry already being queried. A new paginating or link-following tool must do the same. **Do not flag `follow_redirects=True`, and do not suggest restricting redirects to the same host** - registries answer blob fetches with a redirect to a CDN on another host as normal operation (Docker Hub answers a config-blob GET with `307` to `production.cloudfront.docker.com`), so refusing cross-host redirects breaks `registry_image_config`; httpx strips `Authorization` on any cross-origin redirect, so a redirect cannot carry credentials, and it reaches nothing a tool argument could not reach directly. The read-only/no-destructive switches are **not** egress controls (the registry tools are read-only); `DOCKER_MCP_SERVER_DISABLE=registry` is.
125
163
  - Do not add comments that describe what the code does — only add comments for non-obvious constraints or workarounds.
126
164
  - **Target runtime is Python >=3.14** — when reviewing, assume current stable CPython grammar and stdlib are available and valid; do not flag 3.14-valid syntax as a bug. A concrete example reviewers (and older models) get wrong: [PEP 758](https://peps.python.org/pep-0758/) — **Status: Final, Python-Version: 3.14** — makes parentheses optional in `except` / `except*` clauses, so `except OSError, ValueError:` is a valid two-exception handler, **not** a `SyntaxError` (verifiable: `python3.14 -c "import ast; ast.parse('try:\n pass\nexcept OSError, ValueError:\n pass')"` parses it as a tuple handler). The `as`-binding form still requires parens (`except (OSError, ValueError) as e:`), and `ruff` (pyupgrade, 3.14 target) may rewrite to the unparenthesized form.
127
165
 
128
166
  ### Provenance labels
129
167
 
130
- 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).
168
+ 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. The seven stamped creators today are `container_run`, `container_create`, `network_create`, `volume_create`, `service_create`, `config_create`, `secret_create`. **Image builds are intentionally not stamped** (a build label changes the image digest); Compose/stack containers and `plugin_create` are also unstamped. The rule is conditional on the tool *accepting a `labels` dict* — a creator with nowhere to put a label is an expected exception, not an oversight, and should say so in its docstring rather than being flagged in review.
131
169
 
132
170
  ### CLI shell-out policy
133
171
 
@@ -251,6 +289,8 @@ clients keyword-search descriptions, so exact names double as retrieval anchors.
251
289
 
252
290
  `resources.py` also has one `@tool()`: **`docs_lookup(section=None)`** — a tool-callable mirror of `docker-docs://` for clients that can't read MCP resources (Claude Desktop, Cursor). It wraps `list_docs_sections()`/`get_docs_section()` directly (same behavior either way, including per-section domain refusal) and is one of `_NO_DOMAIN_TOOLS` — always registered regardless of `DOCKER_MCP_SERVER_DISABLE`. Several `extra_kwargs`-heavy tool docstrings and docs-reliant prompts point at it as the resource-read fallback — do the same for new ones in that shape.
253
291
 
292
+ `resources.py` carries a second `@tool()`: **`tool_list(domain=None, category=None, keyword=None)`** — a tool-callable mirror of `docker-mcp://tool-catalog` and the only way to express structured queries over the surface (destructive tools, tools taking a `host`, what actually registered). Thin pass-through to `server.py:query_catalog()`, which reads `_tool_registry` (`ToolRecord` captures each tool's docstring summary and parameter names at registration). Lists **only registered** tools — a tool dropped by a switch or disabled domain is absent, not flagged, so a disabled capability is never advertised; `hidden_by_configuration` keeps the config auditable in aggregate. In `_NO_DOMAIN_TOOLS`, so it survives `DOCKER_MCP_SERVER_DISABLE` naming every domain. A new tool appears automatically; its docstring's first line is the summary a row carries, so keep that line standalone.
293
+
254
294
  ### MCP prompts
255
295
 
256
296
  `docker_mcp/tools/prompts.py` exposes `@prompt(description=..., domain=...)` templates (the `prompt` helper from `docker_mcp.server`, not `@mcp.prompt` directly) that return prompt strings to guide multi-step docker workflows (deploy, migrate, troubleshoot, prune, audit/security, networking, volume backup/restore, doc lookup). Each declares its primary `domain` so `DOCKER_MCP_SERVER_DISABLE` drops it with that domain; `domain=None` for general/cross-domain prompts. Use the same docstring format as tools.
@@ -262,16 +302,14 @@ Always verify the exact method name, parameter names, and return type at https:/
262
302
 
263
303
  When the high-level SDK lacks a method (e.g. swarm node removal, service rollback), use the low-level `APIClient` via `_get_client().api` (`remove_node`, `update_service`, `inspect_service`, …), documented at https://docker-py.readthedocs.io/en/stable/api.html — verified the same way. Prefer the high-level object API where it exists.
264
304
 
305
+ Treat "the method exists" as separate from "the method works": the rendered docs show a docstring, not the URL a method builds. `plugin_push` is the standing example — docker-py's `Plugin.push()` / `APIClient.push_plugin()` POST to `/plugins/{name}/pull`, which the Engine does not define (push is `POST /plugins/{name}/push`), so they 404 everywhere; broken since 2017 and still in `main`, because upstream has no test for it. Where a documented method is provably broken, the ladder is: another public SDK path, then the correct endpoint via docker-py's private request helpers (`_url`/`_post`/`_raise_for_status`/`_stream_helper`) resolved through `getattr` and guarded to raise an actionable message rather than `AttributeError` (as `system_logout` does with `api._auth_configs`), then a CLI shell-out if the domain is already CLI-backed. Anything below the first rung leaves the supported surface and is **a human's call, not an agent's**: a PR that adds a *new* private-helper reach-in should be flagged as needing explicit maintainer sign-off, even when it is well built — say so rather than approving it on the strength of the guards. Do flag one that is unguarded, undocumented, or that hits an endpoint absent from the Engine API spec (no compatibility promise, no deprecation cycle — a categorically worse bet than `plugin_push`'s, which uses a spec'd endpoint the CLI itself calls and reaches it through unofficial plumbing). Do **not** re-litigate the reach-ins already blessed above (`plugin_push`, `system_logout`, `stage_build_context`): being present on `main` *is* the sign-off, so a PR that merely touches or refactors one needs no fresh approval — only a genuinely new reach-in does. If a diff both adds and blesses one, that has already been decided; say nothing. And do not treat an audit or routine that *declined* to implement something and documented why as an unfinished job — that write-up is the intended escalation path, and is how `plugin_push` reached a human decision in the first place.
306
+
307
+ **SDK audit exclusions.** A recurring audit routine looks for uncovered SDK surface and for low-level `client.api.*` calls a high-level method could replace. These were decided on the merits and must not be re-proposed (in review or by the routine): `Plugin.push()`/`APIClient.push_plugin()` — broken upstream, so `plugin_push`'s hand-built endpoint call is the working path, not debt to tidy away; `Container.attach`/`attach_socket`/`resize` — deliberately unwrapped, as an interactive bidirectional stream doesn't fit a request/response tool call (`container_exec` covers scripted exec); `service_rollback`'s `inspect_service`/`update_service` and `system_logout`'s `_auth_configs` — permanently low-level, as the high-level SDK has no `rollback` and no `logout` at all. Anything deliberately not wrapped, or wrapped unobviously, belongs on that list in `CLAUDE.md`.
308
+
265
309
  Docker SDK docs: https://docker-py.readthedocs.io/en/stable/index.html
266
310
  Docker SDK low-level API: https://docker-py.readthedocs.io/en/stable/api.html
267
311
  Docker SDK GitHub: https://github.com/docker/docker-py
268
312
 
269
313
  ### Review Notes
270
314
 
271
- There is a dependencies line in pyproject.toml: `"cryptography<49; platform_system == 'Darwin' and platform_machine == 'x86_64'"`
272
- 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).
273
- 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.
274
- Changing to a later version of cryptography for other platforms is fine, but this pin should remain for macOS x86_64.
275
- 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.
276
-
277
- There used to also be a major-version cap on `mcp` (`"mcp>=1.27.1,<2"`), a hotfix for mcp 2.0.0 (2026-07-28) removing `mcp.server.fastmcp`, which `server.py` imported `FastMCP` from — premerge CI could not see this because it installs `--locked`, so the lockfile's 1.x kept every test green while a *fresh* resolve broke at import (the published 2.2.0 shipped uncapped and `uvx docker-mcp-server` failed at import on a clean machine; 2.2.1 was the hotfix). `server.py` has since been ported to `mcp.server.mcpserver.MCPServer` and the cap removed; `mcp` is now unpinned above `2.0.0`. Rather than a version cap, `tests/test_pyproject_pins.py::test_the_declared_mcp_bound_matches_what_the_code_imports` asserts the module `server.py` imports its server class from is one the installed mcp actually provides — a permanent guard against a future mcp release removing that import path, with no cap to remember to add first. If a PR touches this import or that test, check the change is not silently narrowing that guard.
315
+ There used to be a major-version cap on `mcp` (`"mcp>=1.27.1,<2"`), a hotfix for mcp 2.0.0 (2026-07-28) removing `mcp.server.fastmcp`, which `server.py` imported `FastMCP` from — premerge CI could not see this because it installs `--locked`, so the lockfile's 1.x kept every test green while a *fresh* resolve broke at import (the published 2.2.0 shipped uncapped and `uvx docker-mcp-server` failed at import on a clean machine; 2.2.1 was the hotfix). `server.py` has since been ported to `mcp.server.mcpserver.MCPServer` and the cap removed; `mcp` is now unpinned above `2.0.0`. Rather than a version cap, `tests/test_pyproject_pins.py::test_the_declared_mcp_bound_matches_what_the_code_imports` asserts the module `server.py` imports its server class from is one the installed mcp actually provides — a permanent guard against a future mcp release removing that import path, with no cap to remember to add first. If a PR touches this import or that test, check the change is not silently narrowing that guard. The same principle applies to any new direct dependency whose import surface this project touches — check whether a cap or a guard like this belongs with it.
@@ -95,7 +95,8 @@ jobs:
95
95
  if: github.event_name != 'pull_request'
96
96
  name: Install smoke (${{ matrix.os }})
97
97
  runs-on: ${{ matrix.os }}
98
- timeout-minutes: 15
98
+ # 20, not 15: macos-15-intel now source-builds cryptography (brew install + Rust compile).
99
+ timeout-minutes: 20
99
100
  strategy:
100
101
  fail-fast: false
101
102
  matrix:
@@ -112,6 +113,18 @@ jobs:
112
113
  # live "resolve latest" fetch to raw.githubusercontent.com.
113
114
  version: "0.11.21"
114
115
 
116
+ - name: Point the build at Homebrew's OpenSSL 3.x (Intel macOS only)
117
+ # macos-15-intel ships OpenSSL 1.1.1w (see actions/runner-images); cryptography >=44
118
+ # dropped OpenSSL 1.1.x support and now requires 3.0+. No x86_64 wheel exists (see
119
+ # resolve-crossplatform above), so this install source-builds — the Rust toolchain is
120
+ # already on the runner, but it still needs a compatible OpenSSL to link against.
121
+ if: matrix.os == 'macos-15-intel'
122
+ run: |
123
+ brew install openssl@3
124
+ prefix="$(brew --prefix openssl@3)"
125
+ echo "OPENSSL_DIR=$prefix" >> "$GITHUB_ENV"
126
+ echo "PKG_CONFIG_PATH=$prefix/lib/pkgconfig" >> "$GITHUB_ENV"
127
+
115
128
  - name: Import smoke (full tool-registration path, no daemon needed)
116
129
  run: uv run --no-project --python 3.14 --with docker-mcp-server python -c "import docker_mcp"
117
130
 
@@ -31,12 +31,12 @@ jobs:
31
31
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32
32
 
33
33
  - name: Initialize CodeQL
34
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
34
+ uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
35
35
  with:
36
36
  languages: ${{ matrix.language }}
37
37
 
38
38
  - name: Autobuild
39
- uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
39
+ uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
40
40
 
41
41
  - name: Perform CodeQL Analysis
42
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
42
+ uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
@@ -20,6 +20,58 @@ concurrency:
20
20
  # Jobs
21
21
  jobs:
22
22
 
23
+ action-pins:
24
+ # Ported from L337-org/apt's job of the same name, which is the org's reference
25
+ # implementation, and kept identical in logic to it and to send-to-influx's copy - three
26
+ # repos carrying the same guard is only an advantage while they agree, and the
27
+ # uppercase-SHA defect this arrives with the fix for was found in one copy and had to be
28
+ # chased into the other two.
29
+ #
30
+ # Every ref here was already pinned when this job was added; it exists so that stays true
31
+ # without anyone having to remember.
32
+ runs-on: [ubuntu-latest]
33
+ name: Action pins are immutable
34
+ steps:
35
+ - name: Check out source repository
36
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
37
+
38
+ - name: Every `uses:` must name a 40-hex commit SHA
39
+ run: |
40
+ # A tag or branch ref can be repointed by the action's owner at any time, so
41
+ # `@v7` means CI runs whatever that owner last pushed to it. That matters most in
42
+ # publish.yaml, which mints an OIDC token for PyPI Trusted Publishing
43
+ # (`id-token: write`), pushes to GHCR (`packages: write`) and uploads release
44
+ # assets (`contents: write`) - a repointed tag would execute inside jobs holding
45
+ # the strongest credentials this project has, and Trusted Publishing needs no
46
+ # stored token for an attacker to steal in order to publish as this project.
47
+ # First-party actions/* are not exempt: the standard makes no exception for them,
48
+ # and the pin costs nothing to hold, because Dependabot bumps the SHA and rewrites
49
+ # the trailing "# vX.Y.Z" comment. Local actions (./...) are exempt - they are
50
+ # this repo's own code at this repo's own commit, so there is no third party to
51
+ # repoint them.
52
+ #
53
+ # Quoting is optional in YAML, so `uses: "owner/action@<sha>"` is exactly as
54
+ # pinned as the bare form, and hex is case-insensitive, so an uppercase SHA is
55
+ # equally pinned - GitHub resolves one (verified: both the commits and tarball
56
+ # API endpoints answer 200 for an upper-cased SHA). All are accepted: a guard
57
+ # that fails a legitimately pinned action is one someone eventually switches
58
+ # off wholesale, which costs more than it ever caught. In practice every pin
59
+ # here is lowercase, because that is what Dependabot writes.
60
+ #
61
+ # The trailing "# vX.Y.Z" comment is a convention, not something this guard
62
+ # enforces - the immutability of the ref is the security property, and the
63
+ # comment only tells a human which tag the SHA was.
64
+ bad=$(grep -rnE '^[[:space:]]*(-[[:space:]]+)?uses:' .github/workflows .github/actions 2>/dev/null \
65
+ | grep -vE "uses:[[:space:]]*[\"']?\./" \
66
+ | grep -vE "uses:[[:space:]]*[\"']?[^[:space:]\"']+@[0-9a-fA-F]{40}[\"']?([[:space:]]|\$)" \
67
+ || true)
68
+ if [ -n "$bad" ]; then
69
+ echo "::error::Every action must be pinned to a full 40-hex commit SHA." >&2
70
+ printf '%s\n' "$bad" >&2
71
+ exit 1
72
+ fi
73
+ echo "All action references are pinned to commit SHAs."
74
+
23
75
  fresh-resolve-import:
24
76
  # Answers a question `uv sync --locked` (used by every other job here) cannot: would a
25
77
  # CLEAN install of what we're about to ship actually import? `uv sync --locked` only ever
@@ -158,7 +158,7 @@ jobs:
158
158
  run: uv build
159
159
 
160
160
  - name: Publish to PyPI
161
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
161
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
162
162
  with:
163
163
  # Makes a dispatch re-run after a partial failure idempotent (PyPI files are immutable,
164
164
  # so a re-upload of the same files would otherwise 400). This can't mask publishing a
@@ -239,7 +239,7 @@ jobs:
239
239
  uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
240
240
 
241
241
  - name: Log in to GHCR
242
- uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
242
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
243
243
  with:
244
244
  registry: ghcr.io
245
245
  username: ${{ github.actor }}
@@ -247,7 +247,7 @@ jobs:
247
247
 
248
248
  - name: Log in to Docker Hub
249
249
  if: env.DOCKERHUB_USER != '' && env.DOCKERHUB_TOKEN != ''
250
- uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
250
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
251
251
  with:
252
252
  username: ${{ secrets.DOCKERHUB_USER }}
253
253
  password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -347,6 +347,68 @@ jobs:
347
347
  "${{ steps.pack.outputs.file }}.sha256" \
348
348
  --clobber
349
349
 
350
+ skill:
351
+ # Packs the `skills/l337-docker` agent skill as a .tar.gz and attaches it - with its SHA-256 -
352
+ # to the release. Skills have no official packaging format (unlike .mcpb for MCP servers): a
353
+ # plain directory is the unit, so the archive is just that directory, rooted at `l337-docker/`
354
+ # to match the skill's frontmatter `name`. That root is load-bearing for the install UX - it
355
+ # makes `tar -xzf ... -C ~/.claude/skills` land the skill exactly where Claude Code looks, with
356
+ # no --strip-components and no rename.
357
+ #
358
+ # tar.gz only, deliberately: `tar` is present by default on macOS, Linux and Windows 10 1803+,
359
+ # whereas `unzip` is routinely absent from minimal Linux images - so a single tarball is the
360
+ # more universally extractable of the two for a terminal-based audience. Zip would only win for
361
+ # Windows GUI double-click, which is not the install path.
362
+ #
363
+ # A VERSION file is stamped in at pack time because a skill directory carries no version of its
364
+ # own (the frontmatter has no `version` field), so a downloaded copy would otherwise be
365
+ # untraceable to the release it came from.
366
+ #
367
+ # The archive is byte-reproducible: entries sorted, ownership zeroed, mtimes normalised, and
368
+ # `gzip -n` to drop the embedded timestamp. Repacking the same commit yields the same checksum,
369
+ # so the published SHA-256 is verifiable by anyone rebuilding from source.
370
+ name: Pack and attach the agent skill
371
+ runs-on: ubuntu-latest
372
+ needs: [preflight]
373
+ permissions:
374
+ contents: write # upload the archives as release assets
375
+ steps:
376
+ - name: Check out the release tag
377
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
378
+ with:
379
+ ref: ${{ needs.preflight.outputs.tag }}
380
+
381
+ - name: Pack the skill
382
+ id: pack
383
+ env:
384
+ VERSION: ${{ needs.preflight.outputs.version }}
385
+ run: |
386
+ set -euo pipefail
387
+ test -f skills/l337-docker/SKILL.md || { echo "::error::skills/l337-docker/SKILL.md is missing"; exit 1; }
388
+ rm -rf dist _stage && mkdir -p dist _stage
389
+ cp -R skills/l337-docker _stage/l337-docker
390
+ printf '%s\n' "$VERSION" > _stage/l337-docker/VERSION
391
+ find _stage -exec touch -t 198001010000 {} +
392
+ base="l337-docker-skill-${VERSION}"
393
+ tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="1980-01-01 00:00:00Z" \
394
+ -cf - -C _stage l337-docker | gzip -n > "dist/${base}.tar.gz"
395
+ # Record the bare filename in the .sha256 so `sha256sum -c` works for anyone who
396
+ # downloads both assets into one directory - including the verify job below.
397
+ (cd dist && sha256sum "${base}.tar.gz" | tee "${base}.tar.gz.sha256")
398
+ echo "base=$base" >> "$GITHUB_OUTPUT"
399
+ echo "Packed \`dist/${base}.tar.gz\`" >> "$GITHUB_STEP_SUMMARY"
400
+
401
+ - name: Attach to the release
402
+ env:
403
+ GH_TOKEN: ${{ github.token }}
404
+ RELEASE_TAG: ${{ needs.preflight.outputs.tag }}
405
+ BASE: ${{ steps.pack.outputs.base }}
406
+ run: |
407
+ set -euo pipefail
408
+ gh release upload "$RELEASE_TAG" \
409
+ "dist/${BASE}.tar.gz" "dist/${BASE}.tar.gz.sha256" \
410
+ --clobber
411
+
350
412
  registry:
351
413
  # Publishes server.json to the official MCP Registry (registry.modelcontextprotocol.io). The
352
414
  # registry stores only metadata — it points at the artifacts the sibling jobs above publish —
@@ -467,7 +529,10 @@ jobs:
467
529
  # silently. Each check retries briefly (5 x 15s) to absorb CDN/index lag.
468
530
  name: Verify release convergence
469
531
  runs-on: ubuntu-latest
470
- needs: [preflight, registry]
532
+ # `skill` is listed explicitly: it hangs off `preflight` alone and is not in `registry`'s
533
+ # needs (the registry entry does not reference the skill archives), so without it here verify
534
+ # could run while the skill job is still packing and report a missing asset.
535
+ needs: [preflight, registry, skill]
471
536
  env:
472
537
  TAG: ${{ needs.preflight.outputs.tag }}
473
538
  VERSION: ${{ needs.preflight.outputs.version }}
@@ -519,6 +584,34 @@ jobs:
519
584
  (cd _verify && sha256sum -c ./*.sha256)
520
585
  echo "Release asset OK: $(basename "${mcpb[0]}")"
521
586
 
587
+ - name: Verify the skill archive, its checksum and its layout
588
+ env:
589
+ GH_TOKEN: ${{ github.token }}
590
+ run: |
591
+ set -euo pipefail
592
+ mkdir -p _skill
593
+ gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
594
+ --pattern 'l337-docker-skill-*' --dir _skill
595
+ shopt -s nullglob
596
+ tars=(_skill/*.tar.gz); sums=(_skill/*.sha256)
597
+ if [ "${#tars[@]}" -ne 1 ] || [ "${#sums[@]}" -ne 1 ]; then
598
+ echo "::error::expected one .tar.gz and one .sha256 on $TAG, found ${#tars[@]} / ${#sums[@]}"
599
+ exit 1
600
+ fi
601
+ (cd _skill && sha256sum -c ./*.sha256)
602
+ # Extracting into a skills directory must land the skill at <dir>/l337-docker/SKILL.md
603
+ # with no renaming or --strip-components, and the stamped VERSION must match this release.
604
+ mkdir -p _skill/extract
605
+ tar -xzf "${tars[0]}" -C _skill/extract
606
+ test -f _skill/extract/l337-docker/SKILL.md \
607
+ || { echo "::error::archive does not contain l337-docker/SKILL.md"; exit 1; }
608
+ packed="$(cat _skill/extract/l337-docker/VERSION)"
609
+ if [ "$packed" != "$VERSION" ]; then
610
+ echo "::error::skill archive stamps VERSION '$packed' but this release is '$VERSION'"
611
+ exit 1
612
+ fi
613
+ echo "Skill archive OK: $(basename "${tars[0]}") (VERSION $packed)"
614
+
522
615
  - name: Verify the MCP Registry lists the version
523
616
  run: |
524
617
  set -euo pipefail
@@ -540,7 +633,7 @@ jobs:
540
633
  # anything in the needs chain failed. Silent on cancellation.
541
634
  name: File failure issue
542
635
  runs-on: ubuntu-latest
543
- needs: [preflight, pypi, images, dockerhub-description, mcpb, registry, verify]
636
+ needs: [preflight, pypi, images, dockerhub-description, mcpb, skill, registry, verify]
544
637
  if: failure()
545
638
  permissions:
546
639
  contents: read # checkout, to use the local composite action
@@ -9,6 +9,9 @@ server/venv/
9
9
  # Tests, tooling, CI, and dev metadata add nothing at runtime.
10
10
  tests/
11
11
  scripts/
12
+ # The agent skill is a separate artifact (its own release archives) and an *alternative* to this
13
+ # server, not part of it — packing it here would ship ~140 KB of markdown the bundle never reads.
14
+ skills/
12
15
  .github/
13
16
  .idea/
14
17
  .vscode/
@@ -26,6 +29,7 @@ DOCKERHUB.md
26
29
 
27
30
  # Agent / project docs not needed inside the bundle.
28
31
  CLAUDE.md
32
+ MCP_VS_SKILLS.md
29
33
  SECURITY.md
30
34
  CONTRIBUTING.md
31
35
  CODE_OF_CONDUCT.md