docker-mcp-server 2.1.3__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/copilot-instructions.md +19 -8
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/workflows/canary.yaml +4 -4
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/workflows/codeql.yaml +4 -4
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/workflows/images.yaml +2 -2
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/workflows/premerge.yaml +13 -13
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/workflows/publish-homebrew.yaml +2 -2
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/workflows/publish.yaml +13 -13
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/CLAUDE.md +36 -6
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/PKG-INFO +22 -5
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/README.md +21 -4
- docker_mcp_server-2.2.0/SECURITY.md +43 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/_hosts.py +20 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/server.py +26 -4
- docker_mcp_server-2.2.0/docker_mcp/tools/_cli.py +788 -0
- docker_mcp_server-2.2.0/docker_mcp/tools/_ssh_proxy.py +1623 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/buildx.py +418 -22
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/compose.py +155 -32
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/configs.py +6 -3
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/containers.py +54 -25
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/context.py +17 -2
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/images.py +36 -15
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/networks.py +4 -2
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/nodes.py +6 -2
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/plugins.py +1 -1
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/registry.py +5 -2
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/scout.py +90 -12
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/services.py +17 -4
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/stack.py +81 -12
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/swarm.py +7 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/system.py +26 -10
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/volumes.py +9 -3
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/manifest.json +1 -1
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/pyproject.toml +1 -1
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/scripts/build-mcpb.sh +71 -9
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/conftest.py +17 -0
- docker_mcp_server-2.2.0/tests/integration/test_remote_exec.py +90 -0
- docker_mcp_server-2.2.0/tests/test_buildx.py +875 -0
- docker_mcp_server-2.2.0/tests/test_cli.py +1036 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_compose.py +127 -0
- docker_mcp_server-2.2.0/tests/test_docs.py +109 -0
- docker_mcp_server-2.2.0/tests/test_remote_exec.py +1467 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_scout.py +69 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_server.py +103 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_stack.py +58 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/uv.lock +1 -1
- docker_mcp_server-2.1.3/SECURITY.md +0 -35
- docker_mcp_server-2.1.3/docker_mcp/tools/_cli.py +0 -379
- docker_mcp_server-2.1.3/docker_mcp/tools/_ssh_proxy.py +0 -345
- docker_mcp_server-2.1.3/tests/test_buildx.py +0 -462
- docker_mcp_server-2.1.3/tests/test_cli.py +0 -532
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.claude/commands/docker-sdk.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.claude/settings.json +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.dockerignore +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/CODEOWNERS +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/actions/file-failure-issue/action.yaml +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/dependabot.yaml +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.github/release.yml +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.gitignore +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.mcpbignore +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/.python-version +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/CODE_OF_CONDUCT.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/CONTRIBUTING.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/DOCKERHUB.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/Dockerfile +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/LICENSE +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/MIGRATION-2.0.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/PRIVACY.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/assets/README.md +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/assets/icon.png +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/__init__.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/__main__.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/_env.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/__init__.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/_labels.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/_utils.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/prompts.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/resources.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/docker_mcp/tools/secrets.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/glama.json +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/mcpb_run.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/scripts/docker-mcp-server.rb.tpl +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/server.json +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/__init__.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/__init__.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/conftest.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_buildx.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_cli.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_compose.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_containers.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_context.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_file_payloads.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_networks.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_nodes.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_registry.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_scout.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_services.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_smoke.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/integration/test_stack.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_configs.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_containers.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_context.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_env.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_hosts.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_images.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_labels.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_main.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_naming.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_networks.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_nodes.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_plugins.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_prompts.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_pyproject_pins.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_registry.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_resources.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_secrets.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_services.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_ssh_proxy.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_swarm.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_system.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_utils.py +0 -0
- {docker_mcp_server-2.1.3 → docker_mcp_server-2.2.0}/tests/test_volumes.py +0 -0
|
@@ -4,9 +4,9 @@ This file provides guidance to GitHub Copilot when working with code in this rep
|
|
|
4
4
|
|
|
5
5
|
## Project
|
|
6
6
|
|
|
7
|
-
`docker-mcp` is a Python MCP (Model Context Protocol) server that exposes the Docker SDK for Python — plus selected docker CLI features (Compose,
|
|
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.
|
|
8
8
|
|
|
9
|
-
The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works via a pure-Python transport (no system `ssh` binary; works in the container images). docker-py auto-selects paramiko for `ssh://`, so there's no transport code — only the `ssh://` branch in `system._connection_help`. CLI-backed tools (Compose, Buildx,
|
|
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.
|
|
10
10
|
|
|
11
11
|
## Architecture
|
|
12
12
|
|
|
@@ -48,7 +48,7 @@ Each file maps to one Docker SDK domain or one CLI/registry feature area. Unders
|
|
|
48
48
|
| File | Domain | Backed by |
|
|
49
49
|
|------|--------|-----------|
|
|
50
50
|
| `_cli.py` | Cross-platform subprocess helper (private) | — |
|
|
51
|
-
| `_ssh_proxy.py` | Per-call paramiko proxy
|
|
51
|
+
| `_ssh_proxy.py` | Per-call paramiko proxy (dial-stdio) plus remote-exec and file-staging primitives, so CLI-backed tools reach `ssh://` daemons without a system `ssh` binary (private) | — |
|
|
52
52
|
| `_utils.py` | Shared helpers: `drop_none`, `join_bounded`, `stream_to_file`, `close_stream_quietly`, `MAX_PAYLOAD_BYTES`, plus the container guards `in_container` / `assert_host_writable` / `host_read_path` / `classify_host_kernel` (private) | — |
|
|
53
53
|
| `_labels.py` | Provenance labels stamped on created resources: `with_provenance` / `managed_filter` / `provenance_labels` (private) | — |
|
|
54
54
|
| `system.py` | `DockerClient` — connection, lifecycle, `system_login`/`system_logout`, `system_reconnect` | docker-py |
|
|
@@ -74,7 +74,7 @@ Each file maps to one Docker SDK domain or one CLI/registry feature area. Unders
|
|
|
74
74
|
`docker_mcp/tools/__init__.py` star-imports all public modules so `docker_mcp/__init__.py` only needs `from docker_mcp import tools`.
|
|
75
75
|
|
|
76
76
|
### Tests (`tests/`)
|
|
77
|
-
Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`; `tests/test_server.py` covers the classification/registration machinery. Tests use pytest with mocks. `tests/integration/` holds tests that need a real Docker daemon — excluded by default, run with `uv run pytest -m integration
|
|
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.
|
|
78
78
|
|
|
79
79
|
CI (`.github/workflows/premerge.yaml`) enforces `pytest`, `ruff check`, `ruff format --check`, and `pyright` on every PR and push to main — all via `uv run`, so the dev-group pins in `pyproject.toml` (bumped by Dependabot's monthly uv pass) are the single tool-version source; the pre-commit hooks are local hooks running `uv run ruff …` for the same reason. CI installs with `uv sync --locked`, which fails if `uv.lock` disagrees with `pyproject.toml` instead of silently re-locking — so when reviewing a PR that touches only `uv.lock`, check its recorded `requires-dist` specifiers still match pyproject (a Dependabot lock rewrite once raised the cryptography cap in the lock alone). A non-required `Check docs mirror` job flags a PR that edits `CLAUDE.md` or `.github/copilot-instructions.md` without the other (the MIRROR RULE) — when reviewing such a PR, check whether the one-sided edit was intentional.
|
|
80
80
|
|
|
@@ -89,7 +89,7 @@ An additional distribution channel alongside the uvx-from-git install (unchanged
|
|
|
89
89
|
|
|
90
90
|
### Desktop Extension (MCPB)
|
|
91
91
|
|
|
92
|
-
A third distribution channel for one-click install in Claude Desktop. Repo-root sources: `manifest.json` (MCPB manifest, `manifest_version` 0.4, `server.type: "uv"`), `mcpb_run.py` (bundle entry point — `from docker_mcp import main; main()`, at the root so `import docker_mcp` resolves under the host's managed `uv`), `.mcpbignore` (trims the packed bundle to source + `pyproject.toml` + `uv.lock` + `README.md`/`LICENSE` + manifest/entry-point + `assets/`), and `assets/icon.png` (512×512). It's a `uv`-type bundle: the host resolves deps from `pyproject.toml` at install time (no vendored venv). `manifest.json`'s `user_config` maps install-dialog fields to env — `DOCKER_MCP_SERVER_HOSTS` (the single host field; `DOCKER_HOST` is **not** exposed — the bare-value shorthand keeps the one-daemon case a one-liner) and the `DOCKER_MCP_SERVER_READONLY` / `_NO_DESTRUCTIVE` / `_DISABLE` switches (the container-only `_ALLOW_SELF_TERMINATE` is intentionally omitted — the bundle never runs containerized). The `mcpb` job in `.github/workflows/publish.yaml` rewrites the manifest `version` from the release tag, packs the `.mcpb` via `npx @anthropic-ai/mcpb`, and attaches it (plus a `.sha256` recording the bare filename, so `sha256sum -c` works on downloads) to each GitHub Release. `scripts/build-mcpb.sh` is the **developer-only** local equivalent of that pack step (
|
|
92
|
+
A third distribution channel for one-click install in Claude Desktop. Repo-root sources: `manifest.json` (MCPB manifest, `manifest_version` 0.4, `server.type: "uv"`), `mcpb_run.py` (bundle entry point — `from docker_mcp import main; main()`, at the root so `import docker_mcp` resolves under the host's managed `uv`), `.mcpbignore` (trims the packed bundle to source + `pyproject.toml` + `uv.lock` + `README.md`/`LICENSE` + manifest/entry-point + `assets/`), and `assets/icon.png` (512×512). It's a `uv`-type bundle: the host resolves deps from `pyproject.toml` at install time (no vendored venv). `manifest.json`'s `user_config` maps install-dialog fields to env — `DOCKER_MCP_SERVER_HOSTS` (the single host field; `DOCKER_HOST` is **not** exposed — the bare-value shorthand keeps the one-daemon case a one-liner) and the `DOCKER_MCP_SERVER_READONLY` / `_NO_DESTRUCTIVE` / `_DISABLE` switches (the container-only `_ALLOW_SELF_TERMINATE` is intentionally omitted — the bundle never runs containerized). The `mcpb` job in `.github/workflows/publish.yaml` rewrites the manifest `version` from the release tag, packs the `.mcpb` via `npx @anthropic-ai/mcpb`, and attaches it (plus a `.sha256` recording the bare filename, so `sha256sum -c` works on downloads) to each GitHub Release. `scripts/build-mcpb.sh` is the **developer-only** local equivalent of that pack step (auto-incrementing output in `dist/`) for smoke-testing in Claude Desktop — it is deliberately **not** used by CI. It stamps a **dev version** from `pyproject.toml` + git HEAD — `<version>-dev.<short-commit>[.dirty]` — so a local bundle can't be mistaken for a release and is traceable to its commit; the stamp goes into `manifest.json` only for the pack and is restored by an `EXIT` trap. `PRIVACY.md` (no-telemetry statement) is referenced by the manifest's `privacy_policies`. Keep the manifest `version` aligned with `pyproject.toml`; update this section when the manifest's tool/env surface or the bundled file set changes.
|
|
93
93
|
|
|
94
94
|
### Homebrew tap (`L337-org/homebrew-tap`) — PAUSED
|
|
95
95
|
|
|
@@ -135,6 +135,14 @@ Any tool wrapping a `docker` CLI feature MUST go through `docker_mcp/tools/_cli.
|
|
|
135
135
|
- Always pass an explicit `timeout=` to `run_docker` (generous for build/pull, short for queries).
|
|
136
136
|
- Error convention (intentional — do not "unify"): **action tools** return the raw `{"returncode", "stdout", "stderr", "truncated"}` dict and never raise on a non-zero exit; **parsed-query tools** (`context_list`, `buildx_list`/`du`, `compose_list`) raise `RuntimeError` via `raise_on_cli_failure` because they cannot return a useful partial parse; `compose_ps`/`compose_config` are the sanctioned hybrids (parsed view plus `raw`, never raise).
|
|
137
137
|
|
|
138
|
+
**SSH remote-exec fallback.** The dial-stdio proxy still needs a local `docker` binary to point at the remote daemon; with none, CLI-backed tools fail at `_resolve("docker")` even though the docker-py-backed tools work against that host. So when the target host resolves to `ssh://` **and** nothing local can serve the call, the command runs on that host via `_ssh_proxy.py:run_remote_exec` (paramiko, `PosixDialect` wrapping the argv in an `sh -c` script that enforces the timeout itself and reports `124`, mapped back to `subprocess.TimeoutExpired`; both streams drained concurrently). Rules: the decision lives in each module's shared `_run_*` wrapper via `_cli.py:should_remote_exec(host, plugin=...)` (`plugin=None` = core-CLI subcommand) routing to `remote_exec_cli(host, args, timeout=)`, which returns the same `CliResult` so the error conventions above need no remote branch — never probe ad hoc in a tool body; and it is a fallback, never a preference (a usable local CLI always wins, a non-`ssh://` host is never eligible, and `remote_exec_cli` raises rather than degrading if called for one). Consequences to state in the docstring of any tool that gains this path: it runs as the **remote** SSH user, so registry credentials come from *its* `~/.docker/config.json` (`system_login` never writes the remote CLI's config); `stdin`/`extra_env` are rejected rather than dropped; the remote must be POSIX (`uname -s` allow-list — sshd inside WSL is accepted, a Windows-side cmd/PowerShell sshd or MSYS/Cygwin is refused). **Wired in: every CLI-backed domain except `context`** (excluded permanently — its tools manage *this* host's CLI context registry): `scout` is exec-only (`scout_compare`'s `to` is refused when it names an existing local path); `compose` stages `project_dir` (or the server's cwd) via `remote_stage_and_exec`, except `compose_list`, which asks the daemon and takes the exec-only path, and **`compose_cp`, which is refused outright** — one side of the copy is a path on *this* host, and `container_archive_put` / `container_archive_get_to_file` already do that job against any daemon with no local CLI; `stack` gained the shared `_run_stack` wrapper, where only `stack_deploy` passes `stage_cwd=True` (explicit, *not* inferred from `cwd is None`, since a Compose-style `cwd=None` still means "stage the server's cwd"); `buildx` splits three ways — queries exec-only, `buildx_bake` stages its working directory (with `path_values=files` passed **explicitly**, because bake appends caller-supplied targets that an argv scan could match, and those targets now go through `safe_positional`), `buildx_create --config` / `buildx_imagetools_create --file` stage only the files they name via `stage_cwd=False`, and **`buildx_build` is bespoke** (see below).
|
|
139
|
+
|
|
140
|
+
**`buildx_build`** drives a session directly (`remote_cli_session` + `run_in_session`): its context needs `.dockerignore`-aware tarring and its `--build-context`/`--secret` values carry paths inside composite `key=value` tokens, rewritten flag-anchored via `_spec_component`/`_replace_spec_component`. The context is staged only when it names an **existing local directory** — the inverse test to recognising URL syntax — so a Git/HTTP context passes through. **buildx resolves `--file` against the CLI's working directory, not the context** (verified empirically; the pre-2.2.0 docstring claimed the opposite), so the remote command gets **no cwd** and every rewritten path is absolute — an in-context Dockerfile becomes `<staged context>/<relative>` via `RemoteStagingSession.join`, one outside it (or beside a URL context) is staged separately. A remote cwd would let a relative `--file` the local CLI cannot find resolve inside the copied context: the same build failing locally and succeeding remotely. It **refuses** (before connecting) a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, and any `ssh=` — each would resolve on the remote machine (losing the output, caching to the wrong disk, silently building uncached since a missing local cache import is non-fatal, or reading the remote user's SSH agent). `dest=-` is stdout and passes. The `instructions` router names the fallback for the domains in `_REMOTE_EXEC_DOMAINS` that registered, so a `context`-only surface never advertises it.
|
|
141
|
+
|
|
142
|
+
**Path tokens are reconciled with the staged copy** (`_cli.py:_reconcile_path_tokens`), since the argv still holds local paths after the tree is copied: relative in-tree is left alone (the remote cwd *is* that tree), absolute in-tree is rewritten relative (a local absolute path names nothing remotely), out-of-tree is staged separately and rewritten. The values come from `flag_values(args, "-f")` / `"-c"` rather than being threaded from each tool — one producer and one consumer, instead of twenty call sites able to forget one. A staged out-of-tree file that itself references relative paths will not find them; the remote CLI reports that.
|
|
143
|
+
|
|
144
|
+
**Staging local files** (`_ssh_proxy.py:remote_staging_session`) covers a command whose arguments name local files (Compose files, bake file, build context): one connection, one 0700 `mktemp -d` root named `docker-mcp-server.stage.*` (distinct from the watchdog's marker files, which share the prefix), teardown in a `finally` so success/exception/timeout all remove it (only a dropped transport can leave one). `stage_tree` / `stage_file` / `stage_build_context` each land in their own numbered subdirectory and return an absolute remote path for use as `cwd` or an argument; `session.exec(...)` reuses the connection. `stage_tree` is an unfiltered tar → SFTP → remote `tar -xf` (nothing can tell which files a Compose file references); `stage_build_context` reuses docker-py's `tar`/`exclude_paths` so `.dockerignore` applies as it would for an SDK build, and measures only the included set. Both refuse an oversized payload (`_MAX_STAGE_BYTES` / `_MAX_STAGE_FILES`) lazily, with a message naming a way out. Extra host requirements: a working `tar`, and an SFTP subsystem on the **same filesystem** as the exec channel — stat-checked once per session, catching a Windows sshd shelling into `wsl.exe` and a chrooted SFTP; deliberately staging-only, since exec-only tools work fine on such a host. Consumers are listed above.
|
|
145
|
+
|
|
138
146
|
### Tool function format
|
|
139
147
|
|
|
140
148
|
All `@tool()` functions must follow this exact docstring format:
|
|
@@ -205,9 +213,12 @@ touches — do not demand rewrites of untouched legacy docstrings.
|
|
|
205
213
|
("name - The volume name") — it scores 2/5 on the rubric. Canonical shared-param prefixes in
|
|
206
214
|
`tests/test_naming.py` still apply — tool-specific detail is appended after the canonical
|
|
207
215
|
prefix, not reworded.
|
|
208
|
-
4. **`returns:` names the shape, not just the type
|
|
209
|
-
must be listed (`{"Titles", "Processes"}`)
|
|
210
|
-
|
|
216
|
+
4. **`returns:` names the shape, not just the type.** For computed or partial returns the
|
|
217
|
+
load-bearing keys must be listed (`{"Titles", "Processes"}`). For a full engine inspect
|
|
218
|
+
document, key enumeration is NOT wanted — an arbitrary subset of hundreds of keys is noise; the
|
|
219
|
+
line should identify the document ("full inspect payload, as `docker inspect`"), optionally
|
|
220
|
+
plus one or two keys a caller typically wants. Flag the shapeless "dict - The X's attrs" on a
|
|
221
|
+
new/modified tool, which identifies neither form.
|
|
211
222
|
5. **Front-loaded and terse** — the description is paid for in every session's context.
|
|
212
223
|
6. **Every factual claim must be verifiable** against the docker-py docs / Engine API spec (Docker
|
|
213
224
|
SDK Policy below). Flag claims that contradict them — especially identifier semantics ("name or
|
|
@@ -38,10 +38,10 @@ jobs:
|
|
|
38
38
|
runs-on: ubuntu-latest
|
|
39
39
|
steps:
|
|
40
40
|
- name: Check out source repository
|
|
41
|
-
uses: actions/checkout@
|
|
41
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
42
42
|
|
|
43
43
|
- name: Set up uv
|
|
44
|
-
uses: astral-sh/setup-uv@
|
|
44
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
45
45
|
with:
|
|
46
46
|
# Pinned (matching the Dockerfile's uv image tag) so this step never needs the
|
|
47
47
|
# live "resolve latest" fetch to raw.githubusercontent.com — that network call
|
|
@@ -102,7 +102,7 @@ jobs:
|
|
|
102
102
|
os: [macos-latest, macos-15-intel, windows-latest]
|
|
103
103
|
steps:
|
|
104
104
|
- name: Set up uv
|
|
105
|
-
uses: astral-sh/setup-uv@
|
|
105
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
106
106
|
with:
|
|
107
107
|
# This job deliberately has no checkout — it tests the *published* package, not the
|
|
108
108
|
# repo — so disable the repo-keyed cache and the empty-workdir warning it triggers.
|
|
@@ -130,7 +130,7 @@ jobs:
|
|
|
130
130
|
issues: write
|
|
131
131
|
steps:
|
|
132
132
|
- name: Check out source repository
|
|
133
|
-
uses: actions/checkout@
|
|
133
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
134
134
|
|
|
135
135
|
- name: File or update the failure issue
|
|
136
136
|
uses: ./.github/actions/file-failure-issue
|
|
@@ -28,15 +28,15 @@ jobs:
|
|
|
28
28
|
|
|
29
29
|
steps:
|
|
30
30
|
- name: Checkout repository
|
|
31
|
-
uses: actions/checkout@
|
|
31
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
32
32
|
|
|
33
33
|
- name: Initialize CodeQL
|
|
34
|
-
uses: github/codeql-action/init@
|
|
34
|
+
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
|
35
35
|
with:
|
|
36
36
|
languages: ${{ matrix.language }}
|
|
37
37
|
|
|
38
38
|
- name: Autobuild
|
|
39
|
-
uses: github/codeql-action/autobuild@
|
|
39
|
+
uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
|
40
40
|
|
|
41
41
|
- name: Perform CodeQL Analysis
|
|
42
|
-
uses: github/codeql-action/analyze@
|
|
42
|
+
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
|
@@ -47,7 +47,7 @@ jobs:
|
|
|
47
47
|
disable_domains: 'scout'
|
|
48
48
|
steps:
|
|
49
49
|
- name: Check out source repository
|
|
50
|
-
uses: actions/checkout@
|
|
50
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
51
51
|
|
|
52
52
|
- name: Set up Docker Buildx
|
|
53
53
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
|
@@ -82,7 +82,7 @@ jobs:
|
|
|
82
82
|
name: Cross-arch build smoke (full)
|
|
83
83
|
steps:
|
|
84
84
|
- name: Check out source repository
|
|
85
|
-
uses: actions/checkout@
|
|
85
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
86
86
|
|
|
87
87
|
- name: Set up QEMU
|
|
88
88
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
|
@@ -25,15 +25,15 @@ jobs:
|
|
|
25
25
|
name: Run pytest
|
|
26
26
|
steps:
|
|
27
27
|
- name: Check out source repository
|
|
28
|
-
uses: actions/checkout@
|
|
28
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
29
29
|
|
|
30
30
|
- name: Set up Python environment
|
|
31
|
-
uses: actions/setup-python@
|
|
31
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
32
32
|
with:
|
|
33
33
|
python-version: "3.14"
|
|
34
34
|
|
|
35
35
|
- name: Set up uv
|
|
36
|
-
uses: astral-sh/setup-uv@
|
|
36
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
37
37
|
|
|
38
38
|
- name: Install dependencies
|
|
39
39
|
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
@@ -54,15 +54,15 @@ jobs:
|
|
|
54
54
|
name: Run ruff
|
|
55
55
|
steps:
|
|
56
56
|
- name: Check out source repository
|
|
57
|
-
uses: actions/checkout@
|
|
57
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
58
58
|
|
|
59
59
|
- name: Set up Python environment
|
|
60
|
-
uses: actions/setup-python@
|
|
60
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
61
61
|
with:
|
|
62
62
|
python-version: "3.14"
|
|
63
63
|
|
|
64
64
|
- name: Set up uv
|
|
65
|
-
uses: astral-sh/setup-uv@
|
|
65
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
66
66
|
|
|
67
67
|
- name: Install dependencies
|
|
68
68
|
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
@@ -83,15 +83,15 @@ jobs:
|
|
|
83
83
|
name: Run pyright
|
|
84
84
|
steps:
|
|
85
85
|
- name: Check out source repository
|
|
86
|
-
uses: actions/checkout@
|
|
86
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
87
87
|
|
|
88
88
|
- name: Set up Python environment
|
|
89
|
-
uses: actions/setup-python@
|
|
89
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
90
90
|
with:
|
|
91
91
|
python-version: "3.14"
|
|
92
92
|
|
|
93
93
|
- name: Set up uv
|
|
94
|
-
uses: astral-sh/setup-uv@
|
|
94
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
95
95
|
|
|
96
96
|
- name: Install dependencies
|
|
97
97
|
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
@@ -114,7 +114,7 @@ jobs:
|
|
|
114
114
|
if: github.event_name == 'pull_request'
|
|
115
115
|
steps:
|
|
116
116
|
- name: Check out source repository
|
|
117
|
-
uses: actions/checkout@
|
|
117
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
118
118
|
with:
|
|
119
119
|
fetch-depth: 0 # need the merge-base with the PR base branch
|
|
120
120
|
|
|
@@ -150,15 +150,15 @@ jobs:
|
|
|
150
150
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
151
151
|
steps:
|
|
152
152
|
- name: Check out source repository
|
|
153
|
-
uses: actions/checkout@
|
|
153
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
154
154
|
|
|
155
155
|
- name: Set up Python environment
|
|
156
|
-
uses: actions/setup-python@
|
|
156
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
157
157
|
with:
|
|
158
158
|
python-version: "3.14"
|
|
159
159
|
|
|
160
160
|
- name: Set up uv
|
|
161
|
-
uses: astral-sh/setup-uv@
|
|
161
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
162
162
|
|
|
163
163
|
- name: Install dependencies
|
|
164
164
|
# --locked fails if uv.lock disagrees with pyproject.toml instead of silently
|
|
@@ -56,7 +56,7 @@ jobs:
|
|
|
56
56
|
contents: write # upload wheelhouse tarballs to the release
|
|
57
57
|
steps:
|
|
58
58
|
- name: Check out source repository
|
|
59
|
-
uses: actions/checkout@
|
|
59
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
60
60
|
with:
|
|
61
61
|
ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
|
|
62
62
|
|
|
@@ -67,7 +67,7 @@ jobs:
|
|
|
67
67
|
run: git fetch --depth=1 origin main && git checkout origin/main -- scripts/docker-mcp-server.rb.tpl
|
|
68
68
|
|
|
69
69
|
- name: Install uv
|
|
70
|
-
uses: astral-sh/setup-uv@
|
|
70
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
71
71
|
|
|
72
72
|
- name: Resolve version, Python target, and macOS floor
|
|
73
73
|
run: |
|
|
@@ -82,12 +82,12 @@ jobs:
|
|
|
82
82
|
echo "Releasing \`$tag\` (version \`$version\`)" >> "$GITHUB_STEP_SUMMARY"
|
|
83
83
|
|
|
84
84
|
- name: Check out the release tag
|
|
85
|
-
uses: actions/checkout@
|
|
85
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
86
86
|
with:
|
|
87
87
|
ref: ${{ steps.resolve.outputs.tag }}
|
|
88
88
|
|
|
89
89
|
- name: Set up Python environment
|
|
90
|
-
uses: actions/setup-python@
|
|
90
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
91
91
|
with:
|
|
92
92
|
python-version: "3.14"
|
|
93
93
|
|
|
@@ -142,23 +142,23 @@ jobs:
|
|
|
142
142
|
id-token: write # required for Trusted Publishing (OIDC)
|
|
143
143
|
steps:
|
|
144
144
|
- name: Check out the release tag
|
|
145
|
-
uses: actions/checkout@
|
|
145
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
146
146
|
with:
|
|
147
147
|
ref: ${{ needs.preflight.outputs.tag }}
|
|
148
148
|
|
|
149
149
|
- name: Set up Python environment
|
|
150
|
-
uses: actions/setup-python@
|
|
150
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
151
151
|
with:
|
|
152
152
|
python-version: "3.14"
|
|
153
153
|
|
|
154
154
|
- name: Install uv
|
|
155
|
-
uses: astral-sh/setup-uv@
|
|
155
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
156
156
|
|
|
157
157
|
- name: Build sdist and wheel
|
|
158
158
|
run: uv build
|
|
159
159
|
|
|
160
160
|
- name: Publish to PyPI
|
|
161
|
-
uses: pypa/gh-action-pypi-publish@
|
|
161
|
+
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
|
162
162
|
with:
|
|
163
163
|
# Makes a dispatch re-run after a partial failure idempotent (PyPI files are immutable,
|
|
164
164
|
# so a re-upload of the same files would otherwise 400). This can't mask publishing a
|
|
@@ -202,7 +202,7 @@ jobs:
|
|
|
202
202
|
disable_domains: 'scout'
|
|
203
203
|
steps:
|
|
204
204
|
- name: Check out the release tag
|
|
205
|
-
uses: actions/checkout@
|
|
205
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
206
206
|
with:
|
|
207
207
|
ref: ${{ needs.preflight.outputs.tag }}
|
|
208
208
|
|
|
@@ -239,7 +239,7 @@ jobs:
|
|
|
239
239
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
|
240
240
|
|
|
241
241
|
- name: Log in to GHCR
|
|
242
|
-
uses: docker/login-action@
|
|
242
|
+
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
|
243
243
|
with:
|
|
244
244
|
registry: ghcr.io
|
|
245
245
|
username: ${{ github.actor }}
|
|
@@ -247,7 +247,7 @@ jobs:
|
|
|
247
247
|
|
|
248
248
|
- name: Log in to Docker Hub
|
|
249
249
|
if: env.DOCKERHUB_USER != '' && env.DOCKERHUB_TOKEN != ''
|
|
250
|
-
uses: docker/login-action@
|
|
250
|
+
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
|
251
251
|
with:
|
|
252
252
|
username: ${{ secrets.DOCKERHUB_USER }}
|
|
253
253
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
@@ -285,7 +285,7 @@ jobs:
|
|
|
285
285
|
steps:
|
|
286
286
|
- name: Check out the release tag
|
|
287
287
|
if: env.DOCKERHUB_USER != '' && env.DOCKERHUB_TOKEN != ''
|
|
288
|
-
uses: actions/checkout@
|
|
288
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
289
289
|
with:
|
|
290
290
|
ref: ${{ needs.preflight.outputs.tag }}
|
|
291
291
|
|
|
@@ -313,7 +313,7 @@ jobs:
|
|
|
313
313
|
contents: write # upload the .mcpb as a release asset
|
|
314
314
|
steps:
|
|
315
315
|
- name: Check out the release tag
|
|
316
|
-
uses: actions/checkout@
|
|
316
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
317
317
|
with:
|
|
318
318
|
ref: ${{ needs.preflight.outputs.tag }}
|
|
319
319
|
|
|
@@ -367,7 +367,7 @@ jobs:
|
|
|
367
367
|
id-token: write # required for MCP Registry GitHub OIDC authentication
|
|
368
368
|
steps:
|
|
369
369
|
- name: Check out the release tag
|
|
370
|
-
uses: actions/checkout@
|
|
370
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
371
371
|
with:
|
|
372
372
|
ref: ${{ needs.preflight.outputs.tag }}
|
|
373
373
|
|
|
@@ -552,7 +552,7 @@ jobs:
|
|
|
552
552
|
# for every tag since the pipeline landed; dispatch -> the branch dispatched from, i.e.
|
|
553
553
|
# main). Pinning it to the tag would break a dispatch re-run of a tag that predates the
|
|
554
554
|
# composite action, and notify must be the job least likely to fail.
|
|
555
|
-
uses: actions/checkout@
|
|
555
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
556
556
|
|
|
557
557
|
- name: File or update the failure issue
|
|
558
558
|
uses: ./.github/actions/file-failure-issue
|
|
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
13
13
|
|
|
14
14
|
`docker-mcp` is a Python MCP server (requires Python >=3.14) managed with `uv` that exposes the Docker SDK for Python as MCP tools. The entry point is the `docker_mcp` package, run with `python -m docker_mcp` or via the installed console script. It is **published to PyPI as `docker-mcp-server`** (the `docker-mcp` name was already taken) 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` and the repo stays `…/docker-mcp`. Two console scripts are installed — `docker-mcp` and `docker-mcp-server` — both targeting `docker_mcp:main`. A third channel packages the server as a **Claude Desktop Extension (`.mcpb`)** attached to each GitHub Release — see "Desktop Extension (MCPB bundle)" below. A fourth channel (**Homebrew tap**) exists in `L337-org/homebrew-tap` but is currently **paused** — see "Homebrew tap" below.
|
|
15
15
|
|
|
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, Buildx,
|
|
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.
|
|
17
17
|
|
|
18
18
|
## Commands
|
|
19
19
|
|
|
@@ -118,7 +118,7 @@ Each file maps to one Docker SDK domain (or, for CLI-only and registry-only feat
|
|
|
118
118
|
| File | Domain | Backed by |
|
|
119
119
|
|------|--------|-----------|
|
|
120
120
|
| `docker_mcp/tools/_cli.py` | Cross-platform subprocess helper (private) | — |
|
|
121
|
-
| `docker_mcp/tools/_ssh_proxy.py` | Per-call paramiko proxy
|
|
121
|
+
| `docker_mcp/tools/_ssh_proxy.py` | Per-call paramiko proxy (dial-stdio) plus the remote-exec and file-staging primitives, so CLI-backed tools reach `ssh://` daemons without a system `ssh` binary (private) | — |
|
|
122
122
|
| `docker_mcp/tools/_utils.py` | Shared helpers (private) | — |
|
|
123
123
|
| `docker_mcp/tools/_labels.py` | Provenance labels stamped on created resources (private) | — |
|
|
124
124
|
| `docker_mcp/tools/system.py` | `DockerClient` — connection and low-level client | docker-py |
|
|
@@ -144,6 +144,10 @@ Each file maps to one Docker SDK domain (or, for CLI-only and registry-only feat
|
|
|
144
144
|
### Tests (`tests/`)
|
|
145
145
|
Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`. Tests use pytest. The `tests/__init__.py` is intentionally empty.
|
|
146
146
|
|
|
147
|
+
`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.
|
|
148
|
+
|
|
149
|
+
`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.
|
|
150
|
+
|
|
147
151
|
`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`.
|
|
148
152
|
|
|
149
153
|
### Container image (`Dockerfile`)
|
|
@@ -155,7 +159,7 @@ An additional distribution channel alongside uvx-from-git (which is unchanged).
|
|
|
155
159
|
|
|
156
160
|
### Desktop Extension (MCPB bundle)
|
|
157
161
|
|
|
158
|
-
A third distribution channel (alongside uvx/PyPI and the container images) for one-click install in Claude Desktop. The repo root carries the bundle sources: `manifest.json` (the MCPB manifest, `manifest_version` 0.4, `server.type: "uv"`), `mcpb_run.py` (the bundle entry point — `from docker_mcp import main; main()`, kept at the root so `import docker_mcp` resolves however the host's managed `uv` lays out `sys.path`), `.mcpbignore` (trims the packed bundle to source + `pyproject.toml` + `uv.lock` + `README.md`/`LICENSE` + `manifest.json`/`mcpb_run.py` + `assets/`), and `assets/icon.png` (512×512). Because it's a `uv`-type bundle, the host resolves dependencies from `pyproject.toml` at install time — there's no vendored venv, so the bundle stays ~250 KB and cross-platform. `manifest.json`'s `user_config` block renders the install-dialog fields and maps them to env: `DOCKER_MCP_SERVER_HOSTS` (the single host field — `DOCKER_HOST` is deliberately **not** exposed; the bare-value shorthand keeps the simple one-daemon case a one-liner) plus the `DOCKER_MCP_SERVER_READONLY` / `_NO_DESTRUCTIVE` / `_DISABLE` switches (the container-only `_ALLOW_SELF_TERMINATE` is deliberately **not** exposed — the bundle never runs containerized). The `manifest.json` `version` is kept in step with `pyproject.toml` (`tests/test_pyproject_pins.py` asserts it), but the `mcpb` job in `.github/workflows/publish.yaml` rewrites it from the release tag at pack time so it can't drift; that job packs the `.mcpb` with `npx @anthropic-ai/mcpb` and attaches it (plus a `.sha256` recording the bare filename, so `sha256sum -c` works on downloads) to each GitHub Release. `scripts/build-mcpb.sh` is the **developer-only** local equivalent of that pack step (
|
|
162
|
+
A third distribution channel (alongside uvx/PyPI and the container images) for one-click install in Claude Desktop. The repo root carries the bundle sources: `manifest.json` (the MCPB manifest, `manifest_version` 0.4, `server.type: "uv"`), `mcpb_run.py` (the bundle entry point — `from docker_mcp import main; main()`, kept at the root so `import docker_mcp` resolves however the host's managed `uv` lays out `sys.path`), `.mcpbignore` (trims the packed bundle to source + `pyproject.toml` + `uv.lock` + `README.md`/`LICENSE` + `manifest.json`/`mcpb_run.py` + `assets/`), and `assets/icon.png` (512×512). Because it's a `uv`-type bundle, the host resolves dependencies from `pyproject.toml` at install time — there's no vendored venv, so the bundle stays ~250 KB and cross-platform. `manifest.json`'s `user_config` block renders the install-dialog fields and maps them to env: `DOCKER_MCP_SERVER_HOSTS` (the single host field — `DOCKER_HOST` is deliberately **not** exposed; the bare-value shorthand keeps the simple one-daemon case a one-liner) plus the `DOCKER_MCP_SERVER_READONLY` / `_NO_DESTRUCTIVE` / `_DISABLE` switches (the container-only `_ALLOW_SELF_TERMINATE` is deliberately **not** exposed — the bundle never runs containerized). The `manifest.json` `version` is kept in step with `pyproject.toml` (`tests/test_pyproject_pins.py` asserts it), but the `mcpb` job in `.github/workflows/publish.yaml` rewrites it from the release tag at pack time so it can't drift; that job packs the `.mcpb` with `npx @anthropic-ai/mcpb` and attaches it (plus a `.sha256` recording the bare filename, so `sha256sum -c` works on downloads) to each GitHub Release. `scripts/build-mcpb.sh` is the **developer-only** local equivalent of that pack step (packs to `dist/` with an auto-incrementing name) — for smoke-testing a bundle in Claude Desktop. It stamps a **dev version** derived from `pyproject.toml` plus the git HEAD — `<version>-dev.<short-commit>[.dirty]` (`.dirty` = uncommitted changes at pack time) — so a local bundle is never mistaken for a release in Claude Desktop's extension list and is traceable to the commit it was built from despite having no tag; the stamp is written into `manifest.json` only for the duration of the pack and restored by an `EXIT` trap, so the working tree is left unmodified. It is deliberately **not** wired into CI, which uses the workflow above. `PRIVACY.md` (a no-telemetry/no-backend statement) is referenced by the manifest's `privacy_policies` and summarized in the README. **When the manifest's tool/env surface or the bundled file set changes, update this section.**
|
|
159
163
|
|
|
160
164
|
### Homebrew tap (`L337-org/homebrew-tap`) — PAUSED
|
|
161
165
|
|
|
@@ -195,7 +199,7 @@ When adding a new create tool that accepts a `labels` dict, route it through `_l
|
|
|
195
199
|
|
|
196
200
|
## CLI shell-out policy
|
|
197
201
|
|
|
198
|
-
Any tool that wraps a `docker` CLI feature (Compose,
|
|
202
|
+
Any tool that wraps a `docker` CLI feature (Compose, Stack, Buildx, Scout, Context) MUST go through `docker_mcp/tools/_cli.py:run_docker` — never call `subprocess.run` directly from a tool module. The helper centralizes:
|
|
199
203
|
|
|
200
204
|
- Binary resolution via `shutil.which` (handles `docker` vs `docker.exe` on Windows).
|
|
201
205
|
- `shell=False` always; argv as a list so PowerShell/cmd/zsh quoting cannot bite us.
|
|
@@ -211,6 +215,28 @@ Multi-platform notes for new shell-out tools:
|
|
|
211
215
|
- Always pass an explicit `timeout=` to `run_docker`; pick a generous ceiling for long-running ops (build/pull at 1800s) and a short one for queries.
|
|
212
216
|
- Don't hardcode binary paths — Docker Desktop on Mac, Windows, and Linux all install `docker` differently; `shutil.which` is the only safe lookup.
|
|
213
217
|
|
|
218
|
+
### SSH remote-exec fallback (CLI-backed tools with no local `docker`)
|
|
219
|
+
|
|
220
|
+
The dial-stdio proxy above still needs a local `docker` binary to *point* at the remote daemon. With none (a machine with SSH access to real Docker hosts and no Docker installed), CLI-backed tools fail at `_resolve("docker")` before any host logic runs — while every docker-py-backed tool works fine against that same host. So when the target host resolves to `ssh://` **and** nothing local can serve the call, the command runs **on that host**, over the same paramiko machinery (`_ssh_proxy.py:run_remote_exec`, a sibling of the dial-stdio proxy): `PosixDialect` wraps the argv in an `sh -c` script that enforces the timeout itself (`sh`/`sleep`/`kill`/`mktemp` only — GNU `timeout` is Linux-only) and reports `124` when it kills, which maps back to `subprocess.TimeoutExpired` so both backends raise the same exception; stdout and stderr are drained concurrently, since an unread stream would block the remote command rather than truncate its output.
|
|
221
|
+
|
|
222
|
+
Two rules for tool modules:
|
|
223
|
+
|
|
224
|
+
- **One decision point per module.** Its shared `_run_*` wrapper calls `_cli.py:should_remote_exec(host, plugin=...)` (`plugin=None` for a core-CLI subcommand such as `docker stack`) and routes to `remote_exec_cli(host, args, timeout=)`, which returns the same `CliResult` — so the error conventions below need no remote branch — else falls through to `require_plugin` + `run_docker` unchanged. Never probe ad hoc in a tool body.
|
|
225
|
+
- **A fallback, never a preference.** A usable local CLI always wins, so the credentials, filesystem, and buildx state a call sees change only when there is no local alternative. A non-`ssh://` host is never eligible (no shell to run anything on) and `remote_exec_cli` raises rather than degrading if called for one.
|
|
226
|
+
|
|
227
|
+
Consequences to state in the docstring of any tool that gains this path: the command runs as the **remote** SSH user, so registry credentials come from *its* `~/.docker/config.json` (`system_login` talks to the daemon through the SDK and never writes the remote CLI's config); `stdin`/`extra_env` are rejected rather than dropped; and the remote must be POSIX (`uname -s` allow-list — sshd inside WSL is Linux and accepted; a Windows-side cmd/PowerShell sshd, or MSYS/Cygwin on a Windows host, is refused by name). **Wired in: every CLI-backed domain except `context`** — `scout`, `compose`, `stack`, `buildx`. `context.py` is excluded permanently, not pending: its tools manage *this* host's CLI context registry, which a remote host knows nothing about.
|
|
228
|
+
|
|
229
|
+
- **`scout`** takes image references and reads nothing locally, so it goes through `remote_exec_cli`. The one exception, `scout_compare`'s `to` (which may name a local directory or archive), is refused when the value exists locally rather than being resolved against the remote filesystem.
|
|
230
|
+
- **`compose`** reads its files from a working directory, so `_run_compose` routes through `remote_stage_and_exec`, which stages `project_dir` (or the server's cwd) and runs there. `compose_list` is the exception — it asks the daemon, so it takes the exec-only path. **`compose_cp` is refused outright** under remote-exec: one side of the copy is a path on *this* host, and `container_archive_put` / `container_archive_get_to_file` already do that job against any daemon with no local CLI, so the refusal names them instead of adding a download-shaped second mechanism.
|
|
231
|
+
- **`stack`** gained the shared `_run_stack` wrapper it lacked (5 direct `run_docker` calls before). Only `stack_deploy` reads local files, so `stage_cwd=True` is explicit there and the four query/removal tools take the exec-only path — the distinction is *not* inferred from `cwd is None`, since a Compose-style `cwd=None` still means "stage the server's cwd".
|
|
232
|
+
- **`buildx`** splits three ways. The query/lifecycle tools are exec-only; `buildx_bake` stages a working directory (`stage_cwd=True`, `path_values=files` passed **explicitly** — bake appends caller-supplied target names, so an argv scan for `-f` could match one, and those targets now go through `safe_positional` too); `buildx_create --config` and `buildx_imagetools_create --file` stage only the files they name (`stage_cwd=False`, which stages every existing `path_values` entry individually and gives the remote command no cwd). **`buildx_build` is bespoke**: it drives a session via `remote_cli_session` / `run_in_session`, because its context needs `.dockerignore`-aware tarring and its `--build-context` / `--secret` values carry paths *inside* composite `key=value` tokens (rewritten via `_spec_component` / `_replace_spec_component`, flag-anchored rather than whole-token). The context is staged only when it names an **existing local directory** — the inverse test to recognising URL syntax, which cannot be done reliably from the string — so a Git/HTTP context passes through untouched. **buildx resolves `--file` against the CLI's working directory, not the context** (verified empirically; the pre-2.2.0 docstring claimed the opposite), so the remote command gets **no working directory** and every path rewritten here is absolute — an in-context Dockerfile becomes `<staged context>/<relative>` via `RemoteStagingSession.join`, one outside it (or beside a URL context) is staged on its own. Running in the staged context instead would let a relative `--file` the local CLI cannot find resolve *there*, so the same build would fail locally and succeed remotely. `buildx_build` **refuses** (RuntimeError, before connecting) a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, and any `ssh=` — each would resolve on the remote machine, losing the output, writing cache to the wrong disk, silently building uncached (a missing local cache import is non-fatal to BuildKit), or reading the remote user's agent. `dest=-` is stdout and passes.
|
|
233
|
+
|
|
234
|
+
The `instructions` router carries the cross-cutting half of this (it changes which host a call runs on, before any schema is fetched): `build_instructions` appends the fallback sentence naming the domains in `_REMOTE_EXEC_DOMAINS` that actually registered, so a surface of only `context` never advertises it.
|
|
235
|
+
|
|
236
|
+
**Path tokens are reconciled with the staged copy** (`_cli.py:_reconcile_path_tokens`), which matters because the argv still contains *local* paths after the tree is copied: a relative in-tree path is left alone (the remote cwd is that tree), an absolute in-tree path is rewritten relative (the local absolute path names nothing remotely, even though the file was copied), and an out-of-tree path is staged separately and rewritten to where it landed. The values to reconcile are recovered from the argv with `flag_values(args, "-f")` / `"-c"` rather than threaded from each tool — one producer, one consumer, instead of twenty call sites each able to forget. A staged out-of-tree file that itself references relative paths (an override Compose file with its own `build:` context) will not find them; the remote CLI reports that, since following those references would mean parsing the file.
|
|
237
|
+
|
|
238
|
+
**Staging local files** (`_ssh_proxy.py:remote_staging_session`) is the counterpart for a command whose arguments name files that exist *here* — Compose files, a bake file, a build context. It holds one connection, one `mktemp -d` root (mode 0700, named `docker-mcp-server.stage.*` so an abandoned one is attributable and tellable apart from the watchdog's marker files, which share the project prefix), and teardown in a `finally`, so success, an exception and a timeout all remove it — only a dropped transport can leave one behind. `stage_tree` / `stage_file` / `stage_build_context` each land in their own numbered subdirectory (same-basename items cannot collide, and a staged tree never contains the archive it arrived in) and return an absolute remote path to use as a `cwd` or an argument; `session.exec(...)` then runs on that same connection. `stage_tree` is an unfiltered tar → SFTP → remote `tar -xf`, because nothing can tell which files a Compose file will reference; `stage_build_context` reuses docker-py's `tar`/`exclude_paths` (a soft dependency on undocumented-but-stable internals, flagged at the import) so `.dockerignore` applies exactly as it would for an SDK build, and measures only the *included* set. Both refuse an oversized payload (`_MAX_STAGE_BYTES` / `_MAX_STAGE_FILES`) with a message naming a way out, checked lazily so a pathological tree is refused rather than walked. Two host requirements beyond the POSIX shell: a working `tar`, and an SFTP subsystem on the **same filesystem** as the exec channel — verified once per session by stat-ing the directory exec just created, which catches a Windows sshd shelling into `wsl.exe` (exec lands in WSL, SFTP stays Windows-side) and a chrooted/jailed SFTP. That check is deliberately staging-only, since exec-only tools work fine against such a host. The consumers are listed above.
|
|
239
|
+
|
|
214
240
|
### CLI error convention (intentional, do not "unify")
|
|
215
241
|
|
|
216
242
|
CLI-backed tools follow one of two error styles depending on what they return:
|
|
@@ -305,8 +331,12 @@ legacy docstrings are cleaned opportunistically, not churned):
|
|
|
305
331
|
prefixes in `tests/test_naming.py` still apply — append tool-specific detail after the
|
|
306
332
|
canonical prefix rather than rewording it.
|
|
307
333
|
4. **`returns:` names the shape, not just the type.** There is no output schema, so this line is
|
|
308
|
-
all an agent gets
|
|
309
|
-
`{"LayersSize", "Images", "Containers", "Volumes", "BuildCache"}`)
|
|
334
|
+
all an agent gets. For computed or partial returns, name the load-bearing keys (`{"Titles",
|
|
335
|
+
"Processes"}`; `{"LayersSize", "Images", "Containers", "Volumes", "BuildCache"}`). For a full
|
|
336
|
+
engine inspect document, do NOT enumerate an arbitrary subset of its hundreds of keys — say
|
|
337
|
+
what document it is ("full inspect payload, as `docker inspect`"), optionally plus the one or
|
|
338
|
+
two keys a caller typically wants from it. What stays banned is the shapeless "dict - The X's
|
|
339
|
+
attrs", which identifies neither form.
|
|
310
340
|
5. **Front-load and stay terse** — the description is paid for in every session's context; every
|
|
311
341
|
sentence must earn its place.
|
|
312
342
|
6. **Verify every factual claim** against the live docker-py docs / Engine API spec per the Docker
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docker-mcp-server
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: MCP server for managing Docker resources via the Docker SDK for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/L337-org/docker-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/L337-org/docker-mcp
|
|
@@ -38,6 +38,8 @@ More than just a fully featured [MCP](https://modelcontextprotocol.io) server th
|
|
|
38
38
|
|
|
39
39
|
For simple cases, you can just install and go with no configuration required - once loaded it will discover your local Docker socket and expose the full command surface to your AI agent. For more advanced users it can [manage multiple Docker daemons](#managing-several-daemons), e.g. both your local dev environment and also a remote production environment [over TCP, TLS or SSH](#talking-to-a-remote-daemon) in a single session. It can also be configured to mark some daemons as read-only, so you can monitor them without the risk of making accidental changes.
|
|
40
40
|
|
|
41
|
+
It can even be run on a machine [without Docker installed](#no-local-docker) and manage remote daemons over SSH, TLS or TCP (some features require SSH). The AI itself does not require shell or SSH access.
|
|
42
|
+
|
|
41
43
|
The MCP server also exposes things like logs and stats as resources so that you can monitor and triage, enabling you to [answer questions](#example-prompts) like 'why did my container crash?', 'what is the state of my swarm?', 'am I suffering memory pressure?', 'what is the disk usage of my volumes?', 'what differences are there between my test and production systems?', and more...
|
|
42
44
|
|
|
43
45
|
docker-mcp-server is optimized to work efficiently with the new generation of MCP clients that support lazy tool loading. For clients that still eagerly load all tools, the server can optionally be configured to exclude tools from a subset of domains (e.g. exclude 'swarm' and 'scout' tools) to reduce the tool list size. It's also possible to put the MCP server into 'read-only' or 'no-destructive' modes that prevent any tools with write or destructive capabilities from being registered, which again reduces the footprint.
|
|
@@ -205,16 +207,31 @@ When `DOCKER_HOST` is set the server uses it directly (via `docker.from_env()`,
|
|
|
205
207
|
|
|
206
208
|
**Default daemon (no `DOCKER_HOST`).** With `DOCKER_HOST` unset, the server resolves the daemon the way the `docker` CLI does, rather than assuming `/var/run/docker.sock`: it follows the active Docker **context** (`DOCKER_CONTEXT`, else `currentContext` from `~/.docker/config.json`, reading the endpoint from that context's `meta.json`), and if that yields nothing it probes the well-known socket locations (`~/.docker/run/docker.sock` for Docker Desktop 4.13+, `$XDG_RUNTIME_DIR/docker.sock` for rootless, then `/var/run/docker.sock`). This matters because `docker.from_env()` alone ignores contexts and would fall back to `/var/run/docker.sock` — which **Docker Desktop 4.13+ no longer creates by default** (it uses the `desktop-linux` context unless you enable *Settings → Advanced → Allow the default Docker socket*), so a stock Desktop install reachable by your CLI would otherwise fail here. Precedence: a non-empty `DOCKER_HOST` always wins and goes straight through `docker.from_env()` (which ignores contexts); `DOCKER_CONTEXT` / `currentContext` is consulted only when `DOCKER_HOST` is unset, and the socket probe only when neither resolves. TLS material attached to a remote context is not applied automatically — a `tcp://` + TLS context still needs `DOCKER_HOST` / `DOCKER_CERT_PATH`.
|
|
207
209
|
|
|
208
|
-
|
|
210
|
+
#### Over SSH
|
|
211
|
+
|
|
212
|
+
`DOCKER_HOST=ssh://user@remote-host` is supported via a pure-Python transport (paramiko, pulled in by the `docker[ssh]` dependency) — there is **no system `ssh` binary requirement**, so it works the same on the host install and inside the container images. It authenticates with your normal SSH setup:
|
|
209
213
|
|
|
210
214
|
- **Keys / agent.** Use key-based auth; load the key into your agent (`ssh-add`) and make sure `SSH_AUTH_SOCK` is set in the server's environment (or place the key at the default `~/.ssh/id_*` path).
|
|
211
215
|
- **Known hosts.** paramiko verifies the host key against `~/.ssh/known_hosts` and **rejects an unknown host**. Add the host key only after verifying its fingerprint through a trusted channel — connect once interactively with `ssh user@remote-host` and confirm the prompt, or compare `ssh-keyscan remote-host | ssh-keygen -lf -` against a known-good fingerprint before appending it. Avoid blindly piping `ssh-keyscan` straight into `known_hosts`, which trusts whatever key is returned (including a MITM's).
|
|
212
216
|
- **In a container.** Mount your SSH material read-only — `-v $HOME/.ssh:/root/.ssh:ro` (key + `known_hosts`) — or forward your agent socket; no socket mount and no `ssh` package needed.
|
|
213
217
|
|
|
214
|
-
CLI-backed tools (Compose, Buildx,
|
|
218
|
+
CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to the `docker` CLI, which would otherwise use the *system* `ssh` binary over an `ssh://` endpoint. Instead, `run_docker()` detects `DOCKER_HOST=ssh://...` and transparently starts a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens the same paramiko connection docker-py would, runs `docker system dial-stdio` over it, and points the CLI subprocess at `tcp://127.0.0.1:<ephemeral port>` for the duration of that one call. So the CLI-backed tools authenticate with the exact same credentials and host-key policy as the docker-py-backed tools above — **no system `ssh` binary for the direct connection**, identical on the host install and inside the container images. The one exception is a `ProxyCommand` in `~/.ssh/config` (bastion/jump-host setups): paramiko runs that command as-given, and it's commonly `ssh -W %h:%p ...`, so a jump-host hop still shells out to the system `ssh` client even though the direct connection does not.
|
|
215
219
|
|
|
216
220
|
That ephemeral `127.0.0.1` listener bridges to the remote (root-equivalent) daemon with your SSH credentials for the duration of a single CLI call, so any process sharing the same loopback could reach it during that brief window. The exposure is narrow — localhost-only and torn down when the call returns — and inside a container it's narrower still, reachable only by processes within that container's network namespace. The daemon remains the trust boundary either way (see [Security considerations](#security-considerations)).
|
|
217
221
|
|
|
222
|
+
#### No local Docker
|
|
223
|
+
|
|
224
|
+
If Docker is not installed locally — or the plugin a call needs is missing — most tools will still operate on a remote host exactly as before. A few tool families (**Compose, Stack, Buildx, Scout**) will be run **on the target host itself**, over SSH. For these commands TCP and TLS connections will not work without a local Docker install.
|
|
225
|
+
|
|
226
|
+
Worth knowing:
|
|
227
|
+
- **It's a fallback, never a preference.** A local CLI that can serve the call — binary plus the plugin that call needs — is always used instead, so nothing changes for a normal install.
|
|
228
|
+
- **Local files a command reads are copied onto the remote first** — a Compose project directory, a bake file, a build context (honouring `.dockerignore`) — into a private `0700` temp directory that is removed when the call returns — best-effort, since a dropped SSH connection leaves nothing able to run the cleanup. A survivor is named `docker-mcp-server.stage.*` and the failure is logged.
|
|
229
|
+
- **The remote host's credentials apply.** Registry logins come from *its* `~/.docker/config.json`, so a private-registry `compose_pull`, a `stack deploy --with-registry-auth`, or most Scout operations may need `docker login` there. `system_login` talks to the daemon and does not write the remote CLI's config.
|
|
230
|
+
- **The whole working directory is copied**, because nothing can tell which files a Compose file references (`build:`, `env_file:`, `include:` all name arbitrary paths). Point tools at a project directory rather than a large parent, or the call is refused with a size-limit error (200 MiB / 50 000 entries).
|
|
231
|
+
- **A few things are refused rather than half-done**: `compose_cp` (its other side is a path on *this* host — use `container_archive_put` / `container_archive_get_to_file`, which need no CLI at all), and for `buildx_build` a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, or any `ssh=` — each would resolve on the remote machine.
|
|
232
|
+
- **The remote must present a POSIX shell.** Any Linux/macOS/BSD host qualifies, and **sshd running inside a WSL distro is supported** (it is a Linux target — and with Docker Desktop's WSL2 backend the daemon lives there anyway). A Windows-side `cmd`/PowerShell sshd is refused with an explanatory error, as is a `uname` from MSYS/MinGW/Cygwin; a Windows dialect is architected but not implemented. A Windows sshd whose shell is `wsl.exe` runs commands in WSL while its SFTP subsystem stays on the Windows side, so file-copying tools are refused there — the ones that only run a command still work.
|
|
233
|
+
- **`context_*` tools never fall back.** They manage *this* host's CLI context registry, which a remote host knows nothing about.
|
|
234
|
+
|
|
218
235
|
### Managing several daemons
|
|
219
236
|
|
|
220
237
|
Everything above targets one daemon. To manage **several in a single session** — e.g. local dev plus a remote production daemon — set **`DOCKER_MCP_SERVER_HOSTS`** to a comma-separated list of `name=endpoint` pairs:
|
|
@@ -398,7 +415,7 @@ Connecting this server to an AI agent grants it the same level of access as a lo
|
|
|
398
415
|
- **Container archive paths.** `container_archive_get` and `container_archive_put` forward the supplied path verbatim to the daemon. The container is the trust boundary — if you do not trust its filesystem, do not assume `..` traversal will be rejected.
|
|
399
416
|
- **File-path payload tools read and write the server host's filesystem.** `image_save`, `container_export` (with `dest_path`), and `container_archive_get_to_file` write to a `dest_path` on the host running this MCP server (refusing to overwrite an existing file unless `overwrite=True`); `image_load` and `container_archive_put` (with `from_file`) read a host path; `compose_cp` copies between a service container and a host path in either direction. These run as the server's user, so the agent can write any file that user can write and read any file it can read. Prefer the in-band byte tools (capped at 32 MiB) when you don't trust the agent with host filesystem access. `DOCKER_MCP_SERVER_READONLY` also drops the host-writing tools — but note it is not targeted at them: it registers *only* read-only tools, so `image_load` and `container_archive_put` (and every other mutating/destructive tool) go too. There is no switch that drops just the file-writers.
|
|
400
417
|
- **Destructive operations have no built-in confirmation.** `prune_*`, `remove_*`, `container_kill`, `swarm_leave`, `compose_down(volumes=True)`, `compose_kill`, `stack_remove` (tears down every service in a stack), `buildx_prune` (always runs with `--force`), and `buildx_remove` execute immediately. These tools carry the `destructiveHint` annotation, so a client like Claude Code can gate them, and the shipped `clean_environment` prompt asks the agent to confirm before pruning volumes — but tool calls themselves are not gated by the server. For a hard guarantee, run with `DOCKER_MCP_SERVER_NO_DESTRUCTIVE=1` (drops them entirely) or `DOCKER_MCP_SERVER_READONLY=1` (see [Configuration](#configuration)); for an approval step, configure it at the MCP client.
|
|
401
|
-
- **CLI shell-out attack surface.** Compose,
|
|
418
|
+
- **CLI shell-out attack surface.** Compose, Stack, Buildx, Scout, and Context tools spawn `docker` subprocesses on the host running this MCP server. Every invocation passes arguments as a list (no shell, no metacharacter interpretation), resolves the binary via `shutil.which`, and runs against a scrubbed environment (DOCKER_HOST and related vars only). Positional values (image refs, service / context / builder names, build contexts, bake targets) are additionally rejected if they start with `-`, so an argument can't be smuggled in as a CLI flag (e.g. a service named `--output=…`); the one deliberate exception is the trailing command in `compose_exec` / `compose_run`, which is meant to be an arbitrary argv. Filesystem paths supplied to `compose_*` (project_dir, files) are read by the docker CLI on the server host — passing an unfamiliar path can expose any compose file the server's user can read. **With no local docker CLI and an `ssh://` target** those subprocesses run on the remote host instead ([above](#talking-to-a-remote-daemon)), which shifts two things: the command executes as the remote SSH user with *its* registry credentials, and the files a command reads are copied to that host's temp directory first — so a `buildx_build` `--secret src=` file, or anything else in a staged directory, exists briefly on the remote disk (mode `0700`, removed when the call returns; only a dropped connection can leave it behind). Point staging-backed tools at directories you would be content to copy.
|
|
402
419
|
- **The daemon set is fixed at startup; pick it deliberately.** When `DOCKER_HOST` / `DOCKER_MCP_SERVER_HOSTS` are unset, the server's *initial* SDK connection follows your active Docker context (`DOCKER_CONTEXT` / `currentContext`) — the same daemon your `docker` CLI targets — so if that context points at a remote or production daemon, the agent connects there too. Set `DOCKER_MCP_SERVER_HOSTS` (or `DOCKER_HOST`, or select a scoped context) before starting the server to pin the target(s) deliberately; with `DOCKER_MCP_SERVER_HOSTS` the `auto`/`local` endpoints are resolved and **pinned at startup**, so they can't drift if a context changes later. After startup, `context_use` only changes the CLI default for subsequent CLI-backed tools; SDK-backed tools keep using the daemon their pooled client connected to. **There is no runtime way to introduce or retarget a daemon at an arbitrary endpoint** — `system_reconnect` only *rebuilds* an already-configured host's client (to recover a wedged connection), it can't point it elsewhere; to add or change a daemon, edit `DOCKER_MCP_SERVER_HOSTS` and restart. This deliberately closes a trust-expansion vector (an agent can't move the root-equivalent boundary to an unvetted endpoint mid-session). `context_create(skip_tls_verify=True)` disables TLS verification for a context; use only against trusted local daemons.
|
|
403
420
|
- **Per-host read-only is an accident guard, not a security boundary.** A host marked `(ro)` in `DOCKER_MCP_SERVER_HOSTS` makes mutating/destructive tools refuse to act on it at call time (and, with several hosts, writes require naming the target host explicitly — so the agent can't change the wrong daemon by omission). Like `guard_not_self`, this is in-process convenience: it constrains the agent through this server's tools, but the daemon itself is still the trust boundary, so for a host the agent must never modify, prefer pointing it at a genuinely read-only or scoped daemon over relying on the marker alone.
|
|
404
421
|
|
|
@@ -412,7 +429,7 @@ Connecting this server to an AI agent grants it the same level of access as a lo
|
|
|
412
429
|
| Desktop Extension (.mcpb) | [GitHub Releases](https://github.com/L337-org/docker-mcp/releases) |
|
|
413
430
|
| Official MCP Registry | [io.github.L337-org/docker-mcp-server](https://registry.modelcontextprotocol.io/v0.1/servers/io.github.L337-org%2Fdocker-mcp-server/versions) |
|
|
414
431
|
| Glama | [docker-mcp-server](https://glama.ai/mcp/servers/L337-org/docker-mcp) |
|
|
415
|
-
| mcp.so | [docker-mcp-server](https://mcp.so/
|
|
432
|
+
| mcp.so | [docker-mcp-server](https://mcp.so/servers/docker-1ae67d) |
|
|
416
433
|
| awesome-mcp-servers | [punkpeye/awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers#cloud-platforms) |
|
|
417
434
|
|
|
418
435
|
## Privacy Policy
|