docker-mcp-server 2.2.3__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.
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.dockerignore +1 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/copilot-instructions.md +47 -7
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/workflows/premerge.yaml +52 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/workflows/publish.yaml +95 -2
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.mcpbignore +4 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/CLAUDE.md +83 -5
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/DOCKERHUB.md +13 -13
- docker_mcp_server-2.2.4/MCP_VS_SKILLS.md +630 -0
- docker_mcp_server-2.2.4/PKG-INFO +493 -0
- docker_mcp_server-2.2.4/PRIVACY.md +38 -0
- docker_mcp_server-2.2.4/README.md +466 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/server.py +125 -9
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/_cli.py +26 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/_utils.py +31 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/buildx.py +10 -2
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/compose.py +7 -4
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/containers.py +47 -13
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/context.py +23 -11
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/images.py +18 -3
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/networks.py +4 -2
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/registry.py +85 -1
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/resources.py +37 -1
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/scout.py +45 -31
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/services.py +3 -12
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/stack.py +10 -4
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/manifest.json +4 -4
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/pyproject.toml +1 -1
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/scripts/docker-mcp-server.rb.tpl +19 -8
- docker_mcp_server-2.2.4/scripts/measure-comparison-figures.py +614 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/server.json +4 -4
- docker_mcp_server-2.2.4/skills/l337-docker/LICENSE +9 -0
- docker_mcp_server-2.2.4/skills/l337-docker/SKILL.md +221 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/buildx.md +156 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/compose.md +181 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/containers.md +210 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/docs.md +85 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/images.md +157 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/networks-volumes.md +137 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/observability.md +208 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/registry.md +170 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/scout.md +132 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/swarm.md +232 -0
- docker_mcp_server-2.2.4/skills/l337-docker/reference/system.md +182 -0
- docker_mcp_server-2.2.4/skills/l337-docker/workflows/build-publish.md +129 -0
- docker_mcp_server-2.2.4/skills/l337-docker/workflows/deploy.md +122 -0
- docker_mcp_server-2.2.4/skills/l337-docker/workflows/maintenance.md +220 -0
- docker_mcp_server-2.2.4/skills/l337-docker/workflows/security.md +129 -0
- docker_mcp_server-2.2.4/skills/l337-docker/workflows/troubleshoot.md +173 -0
- docker_mcp_server-2.2.4/tests/integration/conftest.py +118 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_buildx.py +9 -4
- docker_mcp_server-2.2.4/tests/integration/test_cli_flag_drift.py +157 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_compose.py +8 -3
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_containers.py +5 -4
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_file_payloads.py +7 -1
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_networks.py +3 -2
- docker_mcp_server-2.2.4/tests/integration/test_scout.py +33 -0
- docker_mcp_server-2.2.4/tests/integration/test_skill.py +348 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_stack.py +1 -1
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_buildx.py +6 -2
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_containers.py +130 -2
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_context.py +44 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_naming.py +1 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_pyproject_pins.py +39 -7
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_registry.py +136 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_remote_exec.py +3 -3
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_resources.py +45 -1
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_scout.py +38 -22
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_server.py +244 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_services.py +9 -0
- docker_mcp_server-2.2.4/tests/test_skill.py +411 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_ssh_proxy.py +8 -4
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_stack.py +5 -1
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_system.py +4 -4
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_utils.py +74 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/uv.lock +1 -1
- docker_mcp_server-2.2.3/PKG-INFO +0 -458
- docker_mcp_server-2.2.3/PRIVACY.md +0 -27
- docker_mcp_server-2.2.3/README.md +0 -431
- docker_mcp_server-2.2.3/tests/integration/conftest.py +0 -39
- docker_mcp_server-2.2.3/tests/integration/test_scout.py +0 -27
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.claude/commands/docker-sdk.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.claude/settings.json +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/CODEOWNERS +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/actions/file-failure-issue/action.yaml +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/dependabot.yaml +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/release.yml +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/workflows/canary.yaml +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/workflows/codeql.yaml +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/workflows/images.yaml +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.github/workflows/publish-homebrew.yaml +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.gitignore +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/.python-version +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/CODE_OF_CONDUCT.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/CONTRIBUTING.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/Dockerfile +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/LICENSE +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/MIGRATION-2.0.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/SECURITY.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/assets/README.md +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/assets/icon.png +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/__init__.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/__main__.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/_env.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/_hosts.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/__init__.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/_labels.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/_ssh_proxy.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/configs.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/nodes.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/plugins.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/prompts.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/secrets.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/swarm.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/system.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/docker_mcp/tools/volumes.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/glama.json +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/mcpb_run.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/scripts/build-mcpb.sh +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/__init__.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/conftest.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/__init__.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_cli.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_context.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_nodes.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_registry.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_remote_exec.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_services.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/integration/test_smoke.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_cli.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_compose.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_configs.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_docs.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_env.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_hosts.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_images.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_labels.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_main.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_networks.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_nodes.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_plugins.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_prompts.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_secrets.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_swarm.py +0 -0
- {docker_mcp_server-2.2.3 → docker_mcp_server-2.2.4}/tests/test_volumes.py +0 -0
|
@@ -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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,10 +134,32 @@ 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
|
|
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
|
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|
@@ -15,6 +15,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
15
15
|
|
|
16
16
|
The `docker` dependency is pulled with its `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works through a pure-Python transport — no system `ssh` binary, identical on the host and in the container images. docker-py auto-selects paramiko for `ssh://` when present, so there is no transport code to maintain (just the `ssh://` branch in `system._connection_help`). CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to `docker`, which would otherwise use the *system* `ssh` — 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 (mirroring docker-py's `SSHHTTPAdapter` defaults) and runs `docker system dial-stdio` over it, so the CLI authenticates identically to the docker-py-backed tools with no system `ssh` binary involved (the one exception being a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — same as it would for the docker-py-backed tools). Where there is 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 side" under the multi-daemon host registry below.
|
|
17
17
|
|
|
18
|
+
The repo additionally ships a **Claude Code agent skill** in `skills/l337-docker/`. It is deliberately **not** a fifth channel for the server - it is a CLI-only *alternative* to it, for users who want Docker capability without running a server process. It is attached to each GitHub Release as a single `.tar.gz`. See "Agent skill" below.
|
|
19
|
+
|
|
18
20
|
## Commands
|
|
19
21
|
|
|
20
22
|
```bash
|
|
@@ -42,6 +44,10 @@ uv run pyright
|
|
|
42
44
|
|
|
43
45
|
# Install pre-commit hooks (one-time)
|
|
44
46
|
uv run pre-commit install
|
|
47
|
+
|
|
48
|
+
# Regenerate the measured figures MCP_VS_SKILLS.md quotes (reports only; add --json for the
|
|
49
|
+
# structured form). Self-contained: pins its own tiktoken, installs the working tree editable.
|
|
50
|
+
uv run scripts/measure-comparison-figures.py
|
|
45
51
|
```
|
|
46
52
|
|
|
47
53
|
CI (`.github/workflows/premerge.yaml`) enforces `pytest`, `ruff check`, `ruff format --check`, and
|
|
@@ -55,6 +61,23 @@ non-required `Check docs mirror` job flags a PR that edits `CLAUDE.md` or
|
|
|
55
61
|
`.github/copilot-instructions.md` without the other (see the MIRROR RULE above) — it's a prompt to
|
|
56
62
|
double-check, not a merge blocker.
|
|
57
63
|
|
|
64
|
+
An `Action pins are immutable` job fails the build when any `uses:` reference in
|
|
65
|
+
`.github/workflows` or `.github/actions` names a tag or branch rather than a full 40-hex commit SHA.
|
|
66
|
+
A tag can be repointed by its owner at any time, so `@v7` runs whatever they last pushed to it - and
|
|
67
|
+
`publish.yaml` mints an OIDC token for PyPI Trusted Publishing (`id-token: write`), pushes to GHCR
|
|
68
|
+
(`packages: write`) and uploads release assets (`contents: write`), so a repointed tag would execute
|
|
69
|
+
inside the jobs holding this project's strongest credentials. Trusted Publishing has no stored token
|
|
70
|
+
for an attacker to steal; executing in that job is the whole attack. First-party `actions/*` get no
|
|
71
|
+
exemption, and the pin costs nothing to hold because Dependabot bumps the SHA and rewrites the
|
|
72
|
+
trailing `# vX.Y.Z` comment. Local `./` actions are exempt (this repo's own code at its own commit),
|
|
73
|
+
and bare, quoted and uppercase SHAs are all accepted - hex is case-insensitive and GitHub resolves an
|
|
74
|
+
uppercase ref, so rejecting one would fail a legitimately pinned action. The trailing version comment
|
|
75
|
+
is convention, not enforced. The job is a port of the identically-named one in
|
|
76
|
+
[L337-org/apt](https://github.com/L337-org/apt), which is the reference implementation;
|
|
77
|
+
`send-to-influx` carries the third copy. **Keep the three in step** - the logic is deliberately
|
|
78
|
+
identical, and the uppercase-SHA fix had to be chased across all three because it was written once
|
|
79
|
+
and copied twice.
|
|
80
|
+
|
|
58
81
|
An `mcp<2` cap existed briefly: mcp 2.0.0 removed `mcp.server.fastmcp`, which `server.py` imported
|
|
59
82
|
`FastMCP` from, and an uncapped 2.2.0 shipped dead on arrival at import while every CI job stayed
|
|
60
83
|
green, because CI installs `--locked` against a lockfile pinning mcp 1.x; 2.2.1 hotfixed the cap.
|
|
@@ -112,7 +135,7 @@ After registering each tool the decorator also calls `_slim_schema` on the tool'
|
|
|
112
135
|
|
|
113
136
|
The decorator also records each tool's **domain** — the leaf of its defining module (`docker_mcp.tools.containers` → `containers`) — so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop a whole feature area (e.g. `swarm,plugins`) from the registered surface regardless of category. A tool registers only if its category survives the read-only switches *and* its domain is not disabled. `DOCKER_MCP_SERVER_DISABLE` reaches beyond tools: the `prompt(domain=...)` helper skips a disabled domain's prompts, and `resources.py` hides a disabled domain's doc sections — so disabling e.g. `scout` drops its tools, its prompts, and its `docker-docs://scout` sections together. The full picture (every tool's domain/category, plus the `prompts` list and `disabled_doc_sections`) is exposed via `tool_catalog()` and the `docker-mcp://tool-catalog` resource, so the classification is auditable at runtime, not just in the source map.
|
|
114
137
|
|
|
115
|
-
A handful of tools have **no domain at all** — `_NO_DOMAIN_TOOLS` (today
|
|
138
|
+
A handful of tools have **no domain at all** — `_NO_DOMAIN_TOOLS` (today `docs_lookup` and `tool_list`) — because their value isn't tied to any single Docker feature area being enabled or disabled. `_domain_for` returns `None` for these, and `None` short-circuits the `_domain_enabled` check entirely, so `DOCKER_MCP_SERVER_DISABLE` can never drop them (not even by their own name). This mirrors `@prompt(domain=None)`'s identical "cross-cutting, always available" semantics for prompts. They still register/deregister normally under `DOCKER_MCP_SERVER_READONLY`/`_NO_DESTRUCTIVE` based on their own category (a domain-less tool should still be `READ_ONLY` for this to matter in practice).
|
|
116
139
|
|
|
117
140
|
**Server `instructions` router.** `server.py` also builds the MCPServer `instructions` string — the text a client pre-loads into context alongside the server name and tool names, *before* any per-tool schema. For a lazy-loading client (e.g. Claude Code, which fetches tool schemas on demand) that's the main always-in-context surface we control, so it's written as a **router**, not docs: a per-domain one-liner mapping user vocabulary onto the domain keyword a tool search will hit, plus a few tool-selection caveats. It deliberately does not enumerate tools (that's the `docker-mcp://tool-catalog` resource). It's built dynamically by `build_instructions()` 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 all honored through the one registration flag, and the router never advertises a domain whose tools didn't register. `finalize_instructions()` (called from `docker_mcp/__init__.py` *after* every tool module imports) writes the result through to `mcp._lowlevel_server.instructions` — MCPServer's `instructions` is a read-only property whose value is read at `run()` time, so a late write propagates to the MCP initialize handshake; the `_lowlevel_server` reach-in is guarded like `_slim_schema`. **A new tool *domain* needs a `_DOMAIN_BLURBS` entry** or the router silently omits it (`tests/test_server.py` checks the router tracks the registered domain set).
|
|
118
141
|
|
|
@@ -172,8 +195,14 @@ Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`
|
|
|
172
195
|
|
|
173
196
|
`tests/test_docs.py` is not tied to a module: it checks the repo's own prose, today that any line describing the CLI-backed surface enumerates a complete set of those domains (derived from `server.py`'s `_CLI_DOMAINS` / `_REMOTE_EXEC_DOMAINS`, so adding a domain fails until the docs name it). It exists because that one list was wrong in six places across a single feature branch; a line that legitimately carries a marker phrase without enumerating gets a recorded exemption there rather than a looser rule.
|
|
174
197
|
|
|
198
|
+
`tests/test_skill.py` is likewise not tied to a module: it checks the `skills/l337-docker/` agent skill - frontmatter, cross-references, no orphaned files, licence/attribution, one canonical lowercase provenance label - plus a regression guard per shell/CLI trap the skill hit while being written (`--until now` being rejected, `timeout(1)` absent on macOS, `status` read-only in zsh, `compose ps` being NDJSON while `compose ls` is a JSON array). Its parity checks derive from `tool_catalog()`, so adding a tool or domain fails until `MCP_VS_SKILLS.md` is updated. Its negative guards deliberately police **fenced code blocks only** - prose has to be able to name an invalid form in order to warn about it.
|
|
199
|
+
|
|
175
200
|
`tests/integration/test_remote_exec.py` is the exception to needing a daemon: it exercises the SSH remote-exec fallback against a real remote host and is gated on `DOCKER_MCP_TEST_SSH_HOST=ssh://user@host` (deliberately *not* in the `DOCKER_MCP_SERVER_*` namespace — it configures the test, not the server), overriding the autouse daemon fixture and skipping cleanly when unset. Everything it runs remotely is read-only.
|
|
176
201
|
|
|
202
|
+
**An integration test skips only for a cause it can name.** `tests/integration/conftest.py` provides `fail_unless_environmental` (for a CLI result) and `fail_unless_environmental_error` (for a raised exception): each matches the failure against `_ENVIRONMENTAL_SIGNALS` and **fails** when nothing matches, on the basis that an unrecognised failure is a product defect until shown otherwise. Both search stdout *and* stderr, because the CLI and its plugins are inconsistent about which stream an error lands on. This exists because three `scout` tools shipped passing a `--format` flag that `docker scout quickview`/`recommendations` do not define: the tests skipped on *any* non-zero exit, so on a fully working machine the defect was reported as "unreachable (offline or auth required?)" and nobody looked. A precondition check (plugin absent, daemon not a swarm manager) is a legitimate skip and stays; a blanket `except Exception` around a fixture is not. Widen `_ENVIRONMENTAL_SIGNALS` rather than the skip.
|
|
203
|
+
|
|
204
|
+
`tests/integration/test_cli_flag_drift.py` is the mechanical counterpart: it reads the argv each CLI-backed tool builds and asserts every literal flag still appears in the installed CLI's `--help`. A mocked unit test cannot catch this class of drift, because what changed is outside the mock. It carries a `_MIN_FUNCTIONS_CHECKED` floor so an extractor that stops matching fails loudly rather than passing while verifying nothing, and it deliberately has no exemption list: a flag surviving only as a hidden alias (as `buildx create --config` did before becoming `--buildkitd-config`) gets migrated, not exempted.
|
|
205
|
+
|
|
177
206
|
`tests/integration/` holds tests that hit a real Docker daemon. `tests/integration/conftest.py` auto-marks every test in the directory with `@pytest.mark.integration` (excluded by default via `addopts = "-m 'not integration'"` in `pyproject.toml`) and provides an autouse `skip_if_no_daemon` fixture so the suite skips cleanly when no daemon is reachable. Run with `uv run pytest -m integration`.
|
|
178
207
|
|
|
179
208
|
### Container image (`Dockerfile`)
|
|
@@ -189,15 +218,39 @@ A third distribution channel (alongside uvx/PyPI and the container images) for o
|
|
|
189
218
|
|
|
190
219
|
### Homebrew tap (`L337-org/homebrew-tap`) — PAUSED
|
|
191
220
|
|
|
192
|
-
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
|
|
221
|
+
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.
|
|
222
|
+
|
|
223
|
+
**`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; review has now twice caught a code comment claiming otherwise (this is a Ruby formula comment, not a Python docstring). 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).
|
|
224
|
+
|
|
225
|
+
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.
|
|
193
226
|
|
|
194
227
|
### MCP Registry (`server.json`)
|
|
195
228
|
|
|
196
|
-
A discovery listing in the official MCP Registry (`registry.modelcontextprotocol.io`), which stores only **metadata** pointing at the artifacts the other channels already publish — so it's not a fourth artifact, just an index entry covering all of them. `server.json` (repo root, server name `io.github.L337-org/docker-mcp-server`) declares three package types in one entry: `pypi` (`docker-mcp-server`), `oci` (the GHCR image), and `mcpb` (the release `.mcpb`). The `registry` job in `.github/workflows/publish.yaml` stamps the tag version and the published `.mcpb`'s `fileSha256` into `server.json`, authenticates via **GitHub OIDC** (`id-token: write`, no stored secret), and runs `mcp-publisher publish`. The registry verifies we own each listed package by matching a marker against `server.json`'s `name`, so three markers must stay equal to it: the `<!-- mcp-name: … -->` comment in `README.md` (PyPI long-description), the `io.modelcontextprotocol.server.name` image label in the `images` job (OCI), and the `.mcpb` URL (must contain "mcp") + its hash (MCPB). The job runs `needs: [pypi, images, mcpb]`, so all three markers are live before it starts — its short retries only absorb registry-side read lag. **If `server.json`'s name/markers or the package set change, update this section.**
|
|
229
|
+
A discovery listing in the official MCP Registry (`registry.modelcontextprotocol.io`), which stores only **metadata** pointing at the artifacts the other channels already publish — so it's not a fourth artifact, just an index entry covering all of them. `server.json` (repo root, server name `io.github.L337-org/docker-mcp-server`) declares three package types in one entry: `pypi` (`docker-mcp-server`), `oci` (the GHCR image), and `mcpb` (the release `.mcpb`). The `registry` job in `.github/workflows/publish.yaml` stamps the tag version and the published `.mcpb`'s `fileSha256` into `server.json`, authenticates via **GitHub OIDC** (`id-token: write`, no stored secret), and runs `mcp-publisher publish`. The registry verifies we own each listed package by matching a marker against `server.json`'s `name`, so three markers must stay equal to it: the `<!-- mcp-name: … -->` comment in `README.md` (PyPI long-description), the `io.modelcontextprotocol.server.name` image label in the `images` job (OCI), and the `.mcpb` URL (must contain "mcp") + its hash (MCPB). The job runs `needs: [pypi, images, mcpb]`, so all three markers are live before it starts — its short retries only absorb registry-side read lag. **The committed version in `server.json` is nonetheless kept in step with `pyproject.toml`** (asserted by `tests/test_pyproject_pins.py::test_server_json_versions_match_pyproject`, which scans every version-shaped token rather than named fields, since the version appears in four places). Restamping at release means a stale value could never reach the registry, so this is not protecting the listing - it was left stale for exactly that reason and the stale value turned out to have a cost: at 1.9.0 against a tree at 2.2.4 it read as drift to every reader and every scheduled audit, each of which had to re-derive "not drift, just stamped at release". A recurring false positive is worse than one more line in the bump checklist. **If `server.json`'s name/markers or the package set change, update this section.**
|
|
230
|
+
|
|
231
|
+
### Agent skill (`skills/l337-docker/`)
|
|
232
|
+
|
|
233
|
+
A **Claude Code agent skill** that drives Docker entirely through the `docker` CLI - the CLI-only alternative to running this MCP server, for simple cases where a `docker` binary is all the user wants. It is a peer of the server, not a channel for it, and nothing in `docker_mcp/` imports or depends on it.
|
|
234
|
+
|
|
235
|
+
**Layout** mirrors the server's own three-layer discovery model, for the same reason (a client pre-loads only the top layer): `SKILL.md` is the always-loaded **router** - preflight, seven always-apply rules, the JSON-parsing contract, daemon targeting - and it maps into `reference/` (11 files, one per domain group: how to do a thing) and `workflows/` (5 files: multi-step procedures ported from the server's MCP prompts). The comparison/coverage record lives at the repo root as `MCP_VS_SKILLS.md`, not inside the skill - it documents both systems, and the skill ships standalone (its `SKILL.md` 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.
|
|
236
|
+
|
|
237
|
+
**Guards are conventions here, not enforcement** - this is the structural difference from the server and must not be papered over when describing it. `DOCKER_MCP_SERVER_READONLY`, per-host `(ro)`/`(nd)` markers and the self-termination guard refuse at the server's call boundary; a skill has no call boundary, so its rules are instructions a model can skip or lose to a prompt injection in a container log. The skill says so in `MCP_VS_SKILLS.md` ("Structural gaps") and points at the server for cases needing a hard guarantee. It also declares **no tool-permission frontmatter** (`tools` / `disallowedTools`, nor the `allowed-tools` spelling slash commands use), deliberately: an allow-list covering `docker` would pre-approve `docker rm -f` and defeat the skill's own confirmation rule, leaving the host's permission prompts as the only thing that actually refuses.
|
|
238
|
+
|
|
239
|
+
**Provenance labels** are `l337-docker-skill.managed=true` / `.created`, a separate footprint from the server's `docker-mcp-server.managed=true` - the skill must never stamp the server's label, since that would attribute its resources to the server. The prefix is **lowercase deliberately**: Docker label keys are case-sensitive, so a mixed-case key published in one place and filtered in lowercase in another matches nothing silently, and a teardown would report a clean daemon while resources remained.
|
|
240
|
+
|
|
241
|
+
**Tests**: `tests/test_skill.py` (static, in the default CI gate) and `tests/integration/test_skill.py` (daemon-backed, in the `integration-tests` job). Two of the static checks derive from `tool_catalog()`, so **adding a tool or a domain fails CI until `MCP_VS_SKILLS.md`'s per-domain counts are updated** - the same "derive, don't copy" shape as `tests/test_docs.py`. The integration tests **extract the shell snippets out of the skill's own markdown and execute them**, so a documented snippet cannot drift from what the CLI actually does; a pasted copy in the test would have kept passing when the real one broke, which is exactly how a zsh-fatal `local status=…` survived a round of manual verification.
|
|
242
|
+
|
|
243
|
+
**Distribution**: the `skill` job in `.github/workflows/publish.yaml` packs it (see "Release pipeline"). There is no official packaging format for a skill - unlike `.mcpb` for MCP servers, a plain directory is the unit - so the archives are just that directory. Native alternative not currently used: a Claude Code **plugin marketplace** (`.claude-plugin/marketplace.json` + `/plugin marketplace add`), which would give `/plugin install` and auto-updates.
|
|
244
|
+
|
|
245
|
+
**The comparison document's measured figures are regenerated, never hand-counted.** `scripts/measure-comparison-figures.py` emits every figure `MCP_VS_SKILLS.md` quotes - token costs for all five configurations it names (full, read-only, triage, core, floor), the description/parameter statistics, the skill's per-file costs, and the per-task comparisons - as a table or, with `--json`, structured output. Run it with `uv run scripts/measure-comparison-figures.py`; a PEP 723 header pins its own `tiktoken` and installs the working tree editable, so it needs nothing in the dev group and always measures the checked-out code. It is **developer-only and report-only** (the same footing as `scripts/build-mcpb.sh`): it never edits the document, so correcting a figure stays a reviewed edit. **It is deliberately not a CI gate** - token figures move a few tokens on any docstring edit, so asserting them would fail constantly and signal nothing; drift is watched instead by the weekly "MCP vs skills figure drift" routine in `L337-org/claude-routines`, which judges whether a movement is worth republishing and opens a PR when it is. Please do not propose promoting it to a merge gate. Three things about it *are* asserted in `tests/test_skill.py`, because they are binary and silent when they break: every configuration the script measures is still a row in the document, the triage `DOCKER_MCP_SERVER_DISABLE` line the document publishes is the one the triage figures were measured with, and every per-task composition names registered tools and existing skill files.
|
|
246
|
+
|
|
247
|
+
The **method** is recorded in the script's docstring rather than being rediscovered each time (it had to be reverse-engineered once already): `tiktoken` `cl100k_base` over each item in the wire form a client receives it in - `model_dump_json(by_alias=True, exclude_none=True)`, which matters because a client is sent `inputSchema`, never the `input_schema` spelling of the Python attribute. Two figures are judgement, not measurement, and are labelled as such in the output: the per-task compositions (which tools an agent fetches, which skill files load) are chosen and named in the script, and the two prose counts over the skill are heuristics. One known gap in the document's own definitions: its eager-idle total omits resource templates, which a client also receives, so the script reports `eager_idle` both ways rather than silently picking one.
|
|
248
|
+
|
|
249
|
+
**When changing the skill's structure, its provenance label, or the server's tool/prompt surface, update this section and `MCP_VS_SKILLS.md` together** - and where a change moves the figures, regenerate them with the script rather than editing a number by hand.
|
|
197
250
|
|
|
198
251
|
### Release pipeline (`.github/workflows/publish.yaml`)
|
|
199
252
|
|
|
200
|
-
All publishing runs through one workflow on each **published GitHub Release** (nothing publishes without that human step): `preflight` → `pypi` ∥ `images`(→`dockerhub-description`) ∥ `mcpb` → `registry` → `verify`, with a `notify` job on failure. `preflight` resolves the tag once for every job (each checks out **the tag ref**, not the event SHA) and fails fast if the tag disagrees with the committed `pyproject.toml` / `manifest.json` / `uv.lock` self-entry versions
|
|
253
|
+
All publishing runs through one workflow on each **published GitHub Release** (nothing publishes without that human step): `preflight` → `pypi` ∥ `images`(→`dockerhub-description`) ∥ `mcpb` → `registry` → `verify`, with `skill` running in parallel from `preflight` and joining at `verify`, and a `notify` job on failure. The `skill` job is deliberately **not** in `registry`'s `needs` (the registry entry doesn't reference the skill archives) but **is** in `verify`'s and `notify`'s, or verify could race ahead of it and a pack failure would file no issue. `preflight` resolves the tag once for every job (each checks out **the tag ref**, not the event SHA) and fails fast if the tag disagrees with the committed `pyproject.toml` / `manifest.json` / `uv.lock` self-entry versions - preventing a split-brain release (PyPI ships the pyproject version; every other channel 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 every channel actually serves the version (PyPI JSON API, both GHCR tags, the `.mcpb` asset + checksum, the registry listing, and the skill archive - checksum plus an extraction asserting it really is rooted at `l337-docker/SKILL.md` and stamps this release's `VERSION`) so a partial release is loud. `notify` files (or comments on) a deduplicated issue labeled `ci-failure` + `wf:release` via the composite action `.github/actions/file-failure-issue` - one open issue per failure stream; a scheduled Claude responder routine polls `ci-failure` issues. Re-run a failed release via `workflow_dispatch` with the tag: every job is idempotent (`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 rather than a new one. **PyPI Trusted Publishing pins the workflow *filename*** - the trusted publisher on PyPI must name `publish.yaml`; if the `pypi` job fails OIDC, that registration is missing. Version-bump checklist for a release: edit `pyproject.toml` + `manifest.json` + `server.json` (its `version`, the pypi package's `version`, and the version inside both the oci and mcpb `identifier` strings - `tests/test_pyproject_pins.py` fails until they all agree), run `uv lock`, merge, then publish a GitHub Release whose tag matches (`.github/release.yml` - GitHub's release-*notes* config, a different file - groups the generated notes, Dependabot PRs under "Dependencies"). `publish-homebrew.yaml` (paused, dispatch-only) stays separate. **When the pipeline's job graph, markers, or re-run semantics change, update this section.**
|
|
201
254
|
|
|
202
255
|
## Conventions
|
|
203
256
|
|
|
@@ -215,6 +268,28 @@ All publishing runs through one workflow on each **published GitHub Release** (n
|
|
|
215
268
|
- Every new `docker_mcp/tools/<module>.py` must have a matching `tests/test_<module>.py`.
|
|
216
269
|
- Tool functions are decorated with `@tool()` (imported from `docker_mcp.server`) and must have a `TOOL_CATEGORIES` entry in `docker_mcp/server.py`.
|
|
217
270
|
- **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` (`registry.py`) since registries are agent-pointed/untrusted (the cap is on the *decoded* stream, so it also stops a decompression bomb). New code that reads an untrusted file or network body must apply a similar bound. Always `json.loads` (never `eval`); if YAML is ever parsed in Python use `yaml.safe_load` only — today no module parses YAML (Compose YAML is read by the `docker` CLI, not us).
|
|
271
|
+
- **A URL taken from a response body is pinned to the origin it came from; a URL the caller named is not.** The distinction is who chose the destination. A registry host in a tool argument is the caller's choice and the whole point of the `registry_*`/`hub_*` tools, so it is not restricted. A `next`/`Link` URL is the *response's* choice, so `_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 does the same. **Cross-host redirects are deliberately followed** and must not be "hardened" away: 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 them breaks `registry_image_config`; httpx strips `Authorization` on any cross-origin redirect, and a redirect reaches nothing a tool argument could not reach directly. Note the read-only/no-destructive switches are **not** egress controls (the registry tools are read-only); `DOCKER_MCP_SERVER_DISABLE=registry` is.
|
|
272
|
+
- **A parameter whose legal values are a genuinely closed set is typed `Literal[...]`, not `str`.**
|
|
273
|
+
Pydantic turns that into an `enum` in the advertised `inputSchema` (`_slim_schema` preserves it,
|
|
274
|
+
proven by a test) so an out-of-set value fails validation before anything executes, and the
|
|
275
|
+
docstring then drops the value list rather than repeating it. Verify the set against a primary
|
|
276
|
+
source - the subcommand's own `--help`, or docker-py's documented value list - never from
|
|
277
|
+
memory: `docker scout cves --only-severity CRITICAL` exits 0 reporting no vulnerabilities on an
|
|
278
|
+
image with three critical CVEs, and the daemon records an unrecognised `--scope` verbatim, so a
|
|
279
|
+
wrong value is not reliably rejected by Docker itself. Where the set is *not* provably closed
|
|
280
|
+
(compose validates `--protocol` not at all; buildx drivers are pluggable; docker-py documents no
|
|
281
|
+
values for `isolation`), leave it a `str` and record why at the parameter, so a later pass does
|
|
282
|
+
not re-propose it. `tests/test_server.py::test_closed_value_sets_are_advertised_as_enums` pins
|
|
283
|
+
the current set.
|
|
284
|
+
- **The `@tool()` decorator is generic (`def tool[F: Callable[..., Any]](...) -> Callable[[F], F]`)
|
|
285
|
+
so pyright checks arguments at every tool call site**, in tests and at internal callers alike.
|
|
286
|
+
Annotating it as a bare `Callable` erases the parameter list and silently disables that checking
|
|
287
|
+
everywhere: a wrong type, an unknown keyword and a value outside a `Literal` all passed the gate
|
|
288
|
+
until this was fixed. `tests/test_server.py::test_pyright_still_checks_arguments_at_tool_call_sites`
|
|
289
|
+
runs pyright over those three deliberate errors, so it also fails if only the return annotation is
|
|
290
|
+
loosened while the type parameter stays. A test that must pass a deliberately invalid value marks
|
|
291
|
+
that one call `# pyright: ignore[reportArgumentType]` with a reason, rather than being softened to
|
|
292
|
+
a legal one.
|
|
218
293
|
- Line length limit: 120 characters (enforced by ruff and flake8).
|
|
219
294
|
|
|
220
295
|
## Provenance labels
|
|
@@ -284,7 +359,8 @@ When you add a new `docker_mcp/tools/<domain>.py` (especially for CLI features o
|
|
|
284
359
|
6. `docker_mcp/tools/resources.py` — add a section under `SDK_SECTIONS` or `EXTERNAL_SECTIONS` pointing at the authoritative docs.
|
|
285
360
|
7. `README.md` — append to "What the agent can do" and "Security considerations" (the latter only if a new class of risk is introduced).
|
|
286
361
|
8. `SECURITY.md` — only if a new class of risk is introduced beyond what's already documented.
|
|
287
|
-
9.
|
|
362
|
+
9. `MCP_VS_SKILLS.md` - bump the domain's tool count (and add a `### <domain> (n)` heading for a new domain), plus a `reference/` entry covering the CLI equivalent. `tests/test_skill.py` derives the expected counts from `tool_catalog()`, so this fails CI until done; a genuinely uncoverable tool is recorded under "Structural gaps" rather than left out silently.
|
|
363
|
+
10. `.github/copilot-instructions.md` - **mirror the architecture/convention change here too** (see the MIRROR RULE at the top of this file); it drives Copilot's review of every PR.
|
|
288
364
|
|
|
289
365
|
### Tool function format
|
|
290
366
|
|
|
@@ -395,6 +471,8 @@ future Glama pass to catch it.
|
|
|
395
471
|
|
|
396
472
|
`resources.py` also has one `@tool()`: **`docs_lookup(section=None)`** — a tool-callable mirror of the `docker-docs://` family for clients that can't read MCP resources (e.g. Claude Desktop, Cursor). It calls `list_docs_sections()`/`get_docs_section()` directly rather than duplicating their logic, so behavior (including the per-section domain refusal above) is identical either way. It's one of `_NO_DOMAIN_TOOLS` (see "Server singleton" above) — always registered regardless of `DOCKER_MCP_SERVER_DISABLE` — since looking something up isn't tied to any one feature area. Several tool docstrings (`container_run`/`container_create`/`service_create`'s `extra_kwargs`) and prompts (`lookup_docker_docs`, `verify_docker_method`, `review_dockerfile`, `audit_container_security`) point at it as the fallback when a client can't read the equivalent resource — a new passthrough-heavy tool or docs-reliant prompt should do the same.
|
|
397
473
|
|
|
474
|
+
`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 ask questions no per-tool description search can express (which tools are destructive, which accept a `host`, what this server actually registered). It is a thin pass-through to `server.py:query_catalog()`, which reads `_tool_registry` directly — no reach-in to MCPServer's tool manager, because `ToolRecord` captures each tool's docstring summary and parameter names at registration. **Only registered tools are listed**: one dropped by a read-only switch or a disabled domain is absent rather than present-and-flagged, since advertising a capability the server will refuse leaks its existence; `hidden_by_configuration` reports the per-domain counts so the configuration stays auditable without naming them. Like `docs_lookup` it is in `_NO_DOMAIN_TOOLS`, so it still answers when `DOCKER_MCP_SERVER_DISABLE` has dropped every domain — a catalog that vanished exactly when the surface was most reduced would be useless. **A new tool automatically appears in it**; what needs maintaining is the docstring's first line, which is the summary a catalog row carries.
|
|
475
|
+
|
|
398
476
|
### MCP prompts
|
|
399
477
|
|
|
400
478
|
`docker_mcp/tools/prompts.py` exposes `@prompt(description=..., domain=...)` templates (the `prompt` helper imported from `docker_mcp.server`, **not** `@mcp.prompt` directly) that return rendered prompt strings to guide multi-step docker workflows (deploy, migrate, troubleshoot, prune, audit/security, networking, volume backup/restore, doc lookup). Each prompt declares its primary `domain` so `DOCKER_MCP_SERVER_DISABLE` skips it when that domain is off; use `domain=None` for general / cross-domain prompts (doc lookup, prune, disk usage) that should always register. Prompts follow the same docstring format as tools and are star-imported via `docker_mcp/tools/__init__.py`.
|