docker-mcp-server 2.1.4__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/copilot-instructions.md +14 -4
  2. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/workflows/canary.yaml +4 -4
  3. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/workflows/codeql.yaml +4 -4
  4. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/workflows/images.yaml +2 -2
  5. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/workflows/premerge.yaml +13 -13
  6. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/workflows/publish-homebrew.yaml +2 -2
  7. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/workflows/publish.yaml +13 -13
  8. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/CLAUDE.md +38 -3
  9. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/PKG-INFO +22 -5
  10. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/README.md +20 -3
  11. docker_mcp_server-2.2.1/SECURITY.md +43 -0
  12. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/_hosts.py +20 -0
  13. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/server.py +26 -4
  14. docker_mcp_server-2.2.1/docker_mcp/tools/_cli.py +788 -0
  15. docker_mcp_server-2.2.1/docker_mcp/tools/_ssh_proxy.py +1623 -0
  16. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/buildx.py +387 -13
  17. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/compose.py +103 -22
  18. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/scout.py +70 -9
  19. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/stack.py +60 -6
  20. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/system.py +3 -2
  21. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/manifest.json +1 -1
  22. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/pyproject.toml +11 -2
  23. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/conftest.py +17 -0
  24. docker_mcp_server-2.2.1/tests/integration/test_remote_exec.py +90 -0
  25. docker_mcp_server-2.2.1/tests/test_buildx.py +875 -0
  26. docker_mcp_server-2.2.1/tests/test_cli.py +1036 -0
  27. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_compose.py +127 -0
  28. docker_mcp_server-2.2.1/tests/test_docs.py +109 -0
  29. docker_mcp_server-2.2.1/tests/test_pyproject_pins.py +178 -0
  30. docker_mcp_server-2.2.1/tests/test_remote_exec.py +1467 -0
  31. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_scout.py +69 -0
  32. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_server.py +103 -0
  33. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_stack.py +58 -0
  34. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/uv.lock +4 -2
  35. docker_mcp_server-2.1.4/SECURITY.md +0 -35
  36. docker_mcp_server-2.1.4/docker_mcp/tools/_cli.py +0 -379
  37. docker_mcp_server-2.1.4/docker_mcp/tools/_ssh_proxy.py +0 -345
  38. docker_mcp_server-2.1.4/tests/test_buildx.py +0 -462
  39. docker_mcp_server-2.1.4/tests/test_cli.py +0 -532
  40. docker_mcp_server-2.1.4/tests/test_pyproject_pins.py +0 -93
  41. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.claude/commands/docker-sdk.md +0 -0
  42. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.claude/settings.json +0 -0
  43. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.dockerignore +0 -0
  44. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/CODEOWNERS +0 -0
  45. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  46. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  47. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  48. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/actions/file-failure-issue/action.yaml +0 -0
  49. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/dependabot.yaml +0 -0
  50. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.github/release.yml +0 -0
  51. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.gitignore +0 -0
  52. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.mcpbignore +0 -0
  53. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/.python-version +0 -0
  54. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/CODE_OF_CONDUCT.md +0 -0
  55. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/CONTRIBUTING.md +0 -0
  56. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/DOCKERHUB.md +0 -0
  57. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/Dockerfile +0 -0
  58. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/LICENSE +0 -0
  59. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/MIGRATION-2.0.md +0 -0
  60. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/PRIVACY.md +0 -0
  61. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/assets/README.md +0 -0
  62. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/assets/icon.png +0 -0
  63. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/__init__.py +0 -0
  64. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/__main__.py +0 -0
  65. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/_env.py +0 -0
  66. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/__init__.py +0 -0
  67. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/_labels.py +0 -0
  68. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/_utils.py +0 -0
  69. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/configs.py +0 -0
  70. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/containers.py +0 -0
  71. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/context.py +0 -0
  72. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/images.py +0 -0
  73. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/networks.py +0 -0
  74. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/nodes.py +0 -0
  75. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/plugins.py +0 -0
  76. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/prompts.py +0 -0
  77. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/registry.py +0 -0
  78. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/resources.py +0 -0
  79. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/secrets.py +0 -0
  80. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/services.py +0 -0
  81. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/swarm.py +0 -0
  82. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/docker_mcp/tools/volumes.py +0 -0
  83. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/glama.json +0 -0
  84. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/mcpb_run.py +0 -0
  85. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/scripts/build-mcpb.sh +0 -0
  86. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/scripts/docker-mcp-server.rb.tpl +0 -0
  87. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/server.json +0 -0
  88. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/__init__.py +0 -0
  89. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/__init__.py +0 -0
  90. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/conftest.py +0 -0
  91. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_buildx.py +0 -0
  92. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_cli.py +0 -0
  93. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_compose.py +0 -0
  94. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_containers.py +0 -0
  95. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_context.py +0 -0
  96. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_file_payloads.py +0 -0
  97. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_networks.py +0 -0
  98. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_nodes.py +0 -0
  99. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_registry.py +0 -0
  100. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_scout.py +0 -0
  101. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_services.py +0 -0
  102. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_smoke.py +0 -0
  103. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/integration/test_stack.py +0 -0
  104. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_configs.py +0 -0
  105. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_containers.py +0 -0
  106. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_context.py +0 -0
  107. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_env.py +0 -0
  108. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_hosts.py +0 -0
  109. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_images.py +0 -0
  110. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_labels.py +0 -0
  111. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_main.py +0 -0
  112. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_naming.py +0 -0
  113. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_networks.py +0 -0
  114. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_nodes.py +0 -0
  115. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_plugins.py +0 -0
  116. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_prompts.py +0 -0
  117. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_registry.py +0 -0
  118. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_resources.py +0 -0
  119. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_secrets.py +0 -0
  120. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_services.py +0 -0
  121. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_ssh_proxy.py +0 -0
  122. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_swarm.py +0 -0
  123. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_system.py +0 -0
  124. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/tests/test_utils.py +0 -0
  125. {docker_mcp_server-2.1.4 → docker_mcp_server-2.2.1}/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, Context, Buildx, Scout) and direct OCI-registry HTTPS access — as MCP tools. It requires Python >=3.14 and is managed with `uv`. It is published to PyPI as **`docker-mcp-server`** and as a container image to GHCR (`ghcr.io/l337-org/docker-mcp-server`), mirrored to Docker Hub (`gavinlucas/docker-mcp-server`) when the opt-in `DOCKERHUB_*` release secrets are configured (the import package stays `docker_mcp`, the repo stays `…/docker-mcp`); two console scripts — `docker-mcp` and `docker-mcp-server` — both target `docker_mcp:main`. A third distribution channel packages the server as a **Claude Desktop Extension (`.mcpb`)** attached to each GitHub Release (see "Desktop Extension (MCPB)" below). A fourth channel (**Homebrew tap**) exists in `L337-org/homebrew-tap` but is currently **paused** — see "Homebrew tap" below.
7
+ `docker-mcp` is a Python MCP (Model Context Protocol) server that exposes the Docker SDK for Python — plus selected docker CLI features (Compose, Stack, Buildx, Scout, Context) and direct OCI-registry HTTPS access — as MCP tools. It requires Python >=3.14 and is managed with `uv`. It is published to PyPI as **`docker-mcp-server`** and as a container image to GHCR (`ghcr.io/l337-org/docker-mcp-server`), mirrored to Docker Hub (`gavinlucas/docker-mcp-server`) when the opt-in `DOCKERHUB_*` release secrets are configured (the import package stays `docker_mcp`, the repo stays `…/docker-mcp`); two console scripts — `docker-mcp` and `docker-mcp-server` — both target `docker_mcp:main`. A third distribution channel packages the server as a **Claude Desktop Extension (`.mcpb`)** attached to each GitHub Release (see "Desktop Extension (MCPB)" below). A fourth channel (**Homebrew tap**) exists in `L337-org/homebrew-tap` but is currently **paused** — see "Homebrew tap" below.
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, Context, Scout) shell out to `docker`, which would otherwise need the system `ssh` client — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection and runs `docker system dial-stdio` over it, so the CLI authenticates the same way the docker-py-backed tools do, with no system `ssh` binary involved (except a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — for both tool families alike).
9
+ The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works via a pure-Python transport (no system `ssh` binary; works in the container images). docker-py auto-selects paramiko for `ssh://`, so there's no transport code — only the `ssh://` branch in `system._connection_help`. CLI-backed tools (Compose, 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 letting CLI-backed tools dial `ssh://` daemons without a system `ssh` binary (private) | — |
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`. `tests/conftest.py` clears the `DOCKER_MCP_SERVER_*` env switches so the suite is hermetic.
77
+ Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`; `tests/test_server.py` covers the classification/registration machinery. Tests use pytest with mocks. `tests/integration/` holds tests that need a real Docker daemon — excluded by default, run with `uv run pytest -m integration`; `tests/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
 
@@ -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:
@@ -263,3 +271,5 @@ Changing this line to a later version of cryptography can break installs on Inte
263
271
  From version 49, cryptography no longer ships a universal2 macOS wheel; on Intel macOS the resolver typically falls back to a source build that requires a newer Rust toolchain and may fail.
264
272
  Changing to a later version of cryptography for other platforms is fine, but this pin should remain for macOS x86_64.
265
273
  Do not change this line. If a PR touches this line, please add a strong comment and do not allow the change to be merged without a strong justification and approval from the repo owner.
274
+
275
+ There is also a major-version cap: `"mcp>=1.27.1,<2"`. mcp 2.0.0 (2026-07-28) removed `mcp.server.fastmcp`, which `server.py` imports `FastMCP` from — that surface moved to `mcp.server.mcpserver`, so 2.x is an API migration, not a bump. Premerge CI cannot see this: it installs `--locked`, so the lockfile's 1.x keeps every test green while a *fresh* resolve breaks at import. That is not hypothetical — the published 2.2.0 shipped uncapped and `uvx docker-mcp-server` failed at import on a clean machine, which 2.2.1 superseded. `tests/test_pyproject_pins.py` enforces the cap and also asserts that the module `server.py` imports `FastMCP` from is one the installed mcp actually provides. Treat a PR lifting this cap the same way as the cryptography pin: it needs the `mcp.server.mcpserver` port, not just a version bump.
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
41
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
42
42
 
43
43
  - name: Set up uv
44
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
31
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32
32
 
33
33
  - name: Initialize CodeQL
34
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
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@7188fc363630916deb702c7fdcf4e481b751f97a # v4
39
+ uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
40
40
 
41
41
  - name: Perform CodeQL Analysis
42
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
28
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
29
 
30
30
  - name: Set up Python environment
31
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
57
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
58
58
 
59
59
  - name: Set up Python environment
60
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
86
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
87
87
 
88
88
  - name: Set up Python environment
89
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
153
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
154
154
 
155
155
  - name: Set up Python environment
156
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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, Context, Scout) 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).
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
 
@@ -55,6 +55,15 @@ non-required `Check docs mirror` job flags a PR that edits `CLAUDE.md` or
55
55
  `.github/copilot-instructions.md` without the other (see the MIRROR RULE above) — it's a prompt to
56
56
  double-check, not a merge blocker.
57
57
 
58
+ **Two dependency caps exist because a fresh resolve can break what `--locked` CI cannot see**, both
59
+ enforced by `tests/test_pyproject_pins.py`: `cryptography<49` on Intel macOS (49 dropped the
60
+ universal2 wheel), and **`mcp<2`** — mcp 2.0.0 removed `mcp.server.fastmcp`, which `server.py` imports
61
+ `FastMCP` from (it moved to `mcp.server.mcpserver`), so 2.x needs a port rather than a bump. The
62
+ published 2.2.0 shipped uncapped and `uvx docker-mcp-server` was dead on arrival at import while every
63
+ CI job stayed green, because CI installs `--locked` against a lockfile pinning mcp 1.x; 2.2.1
64
+ superseded it. When adding a direct dependency whose *import surface* we touch, consider whether a
65
+ major-version cap plus a pin guard belongs with it.
66
+
58
67
  A **weekly canary** (`.github/workflows/canary.yaml`, Mondays + dispatch) hunts platform/ecosystem
59
68
  drift premerge CI can't see: wheels-only (`--only-binary :all:`) dependency resolution for Intel
60
69
  macOS / ARM macOS / Windows against both the repo `pyproject.toml` and the latest published PyPI
@@ -118,7 +127,7 @@ Each file maps to one Docker SDK domain (or, for CLI-only and registry-only feat
118
127
  | File | Domain | Backed by |
119
128
  |------|--------|-----------|
120
129
  | `docker_mcp/tools/_cli.py` | Cross-platform subprocess helper (private) | — |
121
- | `docker_mcp/tools/_ssh_proxy.py` | Per-call paramiko proxy that lets CLI-backed tools dial `ssh://` daemons without a system `ssh` binary (private) | — |
130
+ | `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
131
  | `docker_mcp/tools/_utils.py` | Shared helpers (private) | — |
123
132
  | `docker_mcp/tools/_labels.py` | Provenance labels stamped on created resources (private) | — |
124
133
  | `docker_mcp/tools/system.py` | `DockerClient` — connection and low-level client | docker-py |
@@ -144,6 +153,10 @@ Each file maps to one Docker SDK domain (or, for CLI-only and registry-only feat
144
153
  ### Tests (`tests/`)
145
154
  Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`. Tests use pytest. The `tests/__init__.py` is intentionally empty.
146
155
 
156
+ `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.
157
+
158
+ `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.
159
+
147
160
  `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
161
 
149
162
  ### Container image (`Dockerfile`)
@@ -195,7 +208,7 @@ When adding a new create tool that accepts a `labels` dict, route it through `_l
195
208
 
196
209
  ## CLI shell-out policy
197
210
 
198
- Any tool that wraps a `docker` CLI feature (Compose, Context, Buildx, Scout, etc.) MUST go through `docker_mcp/tools/_cli.py:run_docker` — never call `subprocess.run` directly from a tool module. The helper centralizes:
211
+ 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
212
 
200
213
  - Binary resolution via `shutil.which` (handles `docker` vs `docker.exe` on Windows).
201
214
  - `shell=False` always; argv as a list so PowerShell/cmd/zsh quoting cannot bite us.
@@ -211,6 +224,28 @@ Multi-platform notes for new shell-out tools:
211
224
  - 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
225
  - Don't hardcode binary paths — Docker Desktop on Mac, Windows, and Linux all install `docker` differently; `shutil.which` is the only safe lookup.
213
226
 
227
+ ### SSH remote-exec fallback (CLI-backed tools with no local `docker`)
228
+
229
+ 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.
230
+
231
+ Two rules for tool modules:
232
+
233
+ - **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.
234
+ - **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.
235
+
236
+ 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.
237
+
238
+ - **`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.
239
+ - **`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.
240
+ - **`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".
241
+ - **`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.
242
+
243
+ 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.
244
+
245
+ **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.
246
+
247
+ **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.
248
+
214
249
  ### CLI error convention (intentional, do not "unify")
215
250
 
216
251
  CLI-backed tools follow one of two error styles depending on what they return:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-mcp-server
3
- Version: 2.1.4
3
+ Version: 2.2.1
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
@@ -22,7 +22,7 @@ Requires-Python: >=3.14
22
22
  Requires-Dist: cryptography<49; platform_system == 'Darwin' and platform_machine == 'x86_64'
23
23
  Requires-Dist: docker[ssh]>=7.1.0
24
24
  Requires-Dist: httpx>=0.28.1
25
- Requires-Dist: mcp>=1.27.1
25
+ Requires-Dist: mcp<2,>=1.27.1
26
26
  Requires-Dist: requests>=2.26.0
27
27
  Description-Content-Type: text/markdown
28
28
 
@@ -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
- **Over SSH.** `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:
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, Context, Scout) 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.
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, Context, Buildx, and Scout 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) 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.
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