docker-mcp-server 2.2.1__tar.gz → 2.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/copilot-instructions.md +17 -17
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/workflows/canary.yaml +14 -1
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/workflows/codeql.yaml +3 -3
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/workflows/premerge.yaml +47 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/workflows/publish.yaml +3 -3
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/CLAUDE.md +70 -17
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/DOCKERHUB.md +4 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/PKG-INFO +21 -6
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/README.md +18 -2
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/_hosts.py +27 -11
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/server.py +72 -27
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/_cli.py +15 -5
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/_ssh_proxy.py +224 -5
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/compose.py +138 -22
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/images.py +33 -0
- docker_mcp_server-2.2.3/docker_mcp/tools/plugins.py +288 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/prompts.py +14 -11
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/resources.py +3 -3
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/system.py +95 -8
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/manifest.json +3 -3
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/pyproject.toml +15 -19
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_cli.py +9 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_compose.py +235 -26
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_hosts.py +26 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_images.py +20 -0
- docker_mcp_server-2.2.3/tests/test_plugins.py +218 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_pyproject_pins.py +41 -70
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_remote_exec.py +242 -2
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_resources.py +2 -2
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_server.py +136 -12
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_ssh_proxy.py +117 -1
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_system.py +112 -2
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/uv.lock +114 -89
- docker_mcp_server-2.2.1/docker_mcp/tools/plugins.py +0 -154
- docker_mcp_server-2.2.1/tests/test_plugins.py +0 -90
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.claude/commands/docker-sdk.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.claude/settings.json +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.dockerignore +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/CODEOWNERS +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/actions/file-failure-issue/action.yaml +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/dependabot.yaml +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/release.yml +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/workflows/images.yaml +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.github/workflows/publish-homebrew.yaml +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.gitignore +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.mcpbignore +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/.python-version +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/CODE_OF_CONDUCT.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/CONTRIBUTING.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/Dockerfile +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/LICENSE +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/MIGRATION-2.0.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/PRIVACY.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/SECURITY.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/assets/README.md +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/assets/icon.png +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/__init__.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/__main__.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/_env.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/__init__.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/_labels.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/_utils.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/buildx.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/configs.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/containers.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/context.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/networks.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/nodes.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/registry.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/scout.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/secrets.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/services.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/stack.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/swarm.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/docker_mcp/tools/volumes.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/glama.json +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/mcpb_run.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/scripts/build-mcpb.sh +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/scripts/docker-mcp-server.rb.tpl +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/server.json +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/__init__.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/conftest.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/__init__.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/conftest.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_buildx.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_cli.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_compose.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_containers.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_context.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_file_payloads.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_networks.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_nodes.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_registry.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_remote_exec.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_scout.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_services.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_smoke.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/integration/test_stack.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_buildx.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_configs.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_containers.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_context.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_docs.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_env.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_labels.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_main.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_naming.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_networks.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_nodes.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_prompts.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_registry.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_scout.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_secrets.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_services.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_stack.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_swarm.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_utils.py +0 -0
- {docker_mcp_server-2.2.1 → docker_mcp_server-2.2.3}/tests/test_volumes.py +0 -0
|
@@ -6,7 +6,7 @@ This file provides guidance to GitHub Copilot when working with code in this rep
|
|
|
6
6
|
|
|
7
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, 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.
|
|
9
|
+
The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works via a pure-Python transport (no system `ssh` binary; works in the container images). docker-py auto-selects paramiko for `ssh://`, so there's no transport code — only the `ssh://` branch in `system._connection_help`. CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to `docker`, which would otherwise need the system `ssh` client — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection and runs `docker system dial-stdio` over it, so the CLI authenticates the same way the docker-py-backed tools do, with no system `ssh` binary involved (except a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — for both tool families alike). With no local `docker` binary (or plugin) at all, all of those except the `context_*` tools (which manage *this* host's own CLI contexts) fall back to running the command on the `ssh://` host itself — see "SSH remote-exec fallback" under the CLI shell-out policy. Both the docker-py-backed and CLI-backed SSH connections fall back from IPv6 to IPv4 on any connect failure (not just paramiko's own narrower retry) via `_ssh_proxy.py:connect_socket_with_family_fallback` — see "Client / CLI" below.
|
|
10
10
|
|
|
11
11
|
## Architecture
|
|
12
12
|
|
|
@@ -14,11 +14,11 @@ The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` w
|
|
|
14
14
|
The `docker_mcp` package is the entry point. `docker_mcp/__init__.py` defines `main()` and side-effect-imports the `server` and `tools` submodules (which registers all `@tool()` decorators); `docker_mcp/__main__.py` calls `main()` so `python -m docker_mcp` works.
|
|
15
15
|
|
|
16
16
|
### Server singleton (`docker_mcp/server.py`)
|
|
17
|
-
`docker_mcp/server.py` instantiates `
|
|
17
|
+
`docker_mcp/server.py` instantiates `MCPServer` (from `mcp.server.mcpserver`) and exports three things:
|
|
18
18
|
|
|
19
19
|
- **`tool`** — the registration decorator every tool module uses. **Always import `tool` from `docker_mcp.server`** and decorate with `@tool()`; never import from the `mcp` package directly in tool files (circular import) and never use `@mcp.tool()` in tool modules.
|
|
20
20
|
- **`prompt`** — the prompt registration decorator `prompts.py` uses (`@prompt(description=..., domain=...)`), analogous to `tool` and gating on `DOCKER_MCP_SERVER_DISABLE`; never use `@mcp.prompt()` directly in `prompts.py`.
|
|
21
|
-
- **`mcp`** — the
|
|
21
|
+
- **`mcp`** — the MCPServer singleton, imported by `resources.py` for `@mcp.resource()`.
|
|
22
22
|
|
|
23
23
|
```python
|
|
24
24
|
from docker_mcp.server import tool # tool modules
|
|
@@ -26,18 +26,18 @@ from docker_mcp.server import prompt # prompt modules (with domain=...)
|
|
|
26
26
|
from docker_mcp.server import mcp # resource modules only
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`server.py` also owns **`TOOL_CATEGORIES`**, the central map classifying every tool as `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`. The `@tool()` decorator uses it to attach MCP `ToolAnnotations` and to skip registration under the env switches `DOCKER_MCP_SERVER_READONLY` (register only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (register everything except destructive). It also records each tool's **domain** (its defining module's leaf, e.g. `containers`) so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop whole feature areas — including that domain's **prompts** (via the `prompt(domain=...)` helper) and its **doc-resource sections** (via `_SECTION_DOMAINS` in `resources.py`), not just its tools; the live snapshot is the `docker-mcp://tool-catalog` resource (`server.tool_catalog()`). A handful of tools (`_NO_DOMAIN_TOOLS`, e.g. `docs_lookup`) have **no domain at all** — `_domain_for` returns `None`, which short-circuits `DOCKER_MCP_SERVER_DISABLE` entirely (mirrors `@prompt(domain=None)`'s same always-available semantics) — for tools whose value isn't tied to one feature area. **Every new tool needs a `TOOL_CATEGORIES` entry** — `tests/test_server.py` fails the build if the map drifts from the registered set. The decorator also runs `_slim_schema` on the advertised `inputSchema` to drop three information-free patterns (~18% of schema tokens): pydantic `title` annotations, the `{"type":"null"}` branch of a nullable `anyOf` (gated on a sibling `default`), and redundant `additionalProperties: true`; it's display-only (validation runs off `fn_metadata`), and `tests/test_server.py` asserts none survive. All server tunables are namespaced `DOCKER_MCP_SERVER_*`; read them through `docker_mcp/_env.py` (`read_env` / `env_flag`). The pre-rename `DOCKER_MCP_*` alias spellings were removed in 2.0; the alias-fallback mechanism remains for any future rename.
|
|
29
|
+
`server.py` also owns **`TOOL_CATEGORIES`**, the central map classifying every tool as `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`. The `@tool()` decorator uses it to attach MCP `ToolAnnotations` — including `title`, mechanically derived from the tool name by `_title_for` (e.g. `container_list` → "Container List"), with a small `_TITLE_ACRONYMS` fixup list so `scout_cves`/`scout_sbom` title-case to "Scout CVEs"/"Scout SBOM" rather than "Cves"/"Sbom" — and to skip registration under the env switches `DOCKER_MCP_SERVER_READONLY` (register only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (register everything except destructive). It also records each tool's **domain** (its defining module's leaf, e.g. `containers`) so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop whole feature areas — including that domain's **prompts** (via the `prompt(domain=...)` helper) and its **doc-resource sections** (via `_SECTION_DOMAINS` in `resources.py`), not just its tools; the live snapshot is the `docker-mcp://tool-catalog` resource (`server.tool_catalog()`). A handful of tools (`_NO_DOMAIN_TOOLS`, e.g. `docs_lookup`) have **no domain at all** — `_domain_for` returns `None`, which short-circuits `DOCKER_MCP_SERVER_DISABLE` entirely (mirrors `@prompt(domain=None)`'s same always-available semantics) — for tools whose value isn't tied to one feature area. **Every new tool needs a `TOOL_CATEGORIES` entry** — `tests/test_server.py` fails the build if the map drifts from the registered set. The decorator also runs `_slim_schema` on the advertised `inputSchema` to drop three information-free patterns (~18% of schema tokens): pydantic `title` annotations, the `{"type":"null"}` branch of a nullable `anyOf` (gated on a sibling `default`), and redundant `additionalProperties: true`; it's display-only (validation runs off `fn_metadata`), and `tests/test_server.py` asserts none survive. All server tunables are namespaced `DOCKER_MCP_SERVER_*`; read them through `docker_mcp/_env.py` (`read_env` / `env_flag`). The pre-rename `DOCKER_MCP_*` alias spellings were removed in 2.0; the alias-fallback mechanism remains for any future rename.
|
|
30
30
|
|
|
31
|
-
`server.py` also builds the
|
|
31
|
+
`server.py` also builds the MCPServer **`instructions`** string — pre-loaded into a client's context with the server name and tool names, *before* any per-tool schema, so for a lazy-loading client (e.g. Claude Code, which fetches tool schemas on demand) it's the main always-in-context surface. It's written as a **router** (per-domain keyword one-liners + a few tool-selection caveats), not docs, and does not enumerate tools (that's `docker-mcp://tool-catalog`). `build_instructions()` renders it from `_DOMAIN_BLURBS`, emitting a domain's line **only when that domain has a registered tool**, so `DOCKER_MCP_SERVER_DISABLE` / `_READONLY` / `_NO_DESTRUCTIVE` are honored via the one registration flag. `finalize_instructions()` (called from `docker_mcp/__init__.py` after all tools import) writes it through to `mcp._lowlevel_server.instructions` (MCPServer's `instructions` is a read-only property read at `run()` time, so a late write propagates; the reach-in is guarded). **A new tool domain needs a `_DOMAIN_BLURBS` entry** or the router silently omits it.
|
|
32
32
|
|
|
33
33
|
### Multi-daemon host registry (`docker_mcp/_hosts.py`)
|
|
34
34
|
|
|
35
35
|
`DOCKER_MCP_SERVER_HOSTS` lets one server manage several daemons in a session (e.g. local dev + remote prod). **When set, `DOCKER_HOST` is ignored** (a one-time stderr notice fires when both are set); unset = today's single-daemon behavior (`DOCKER_HOST`, else auto-discovery). The mcpb bundle exposes only this field. `_hosts.py` lives at the package root (like `_env.py`, so `server.py` can import it without pulling in `docker_mcp.tools`) and parses the var into a pinned `{label: Host}` registry — pure env + Docker-config-file reads, no docker-py/CLI calls:
|
|
36
36
|
|
|
37
|
-
- **Grammar.** No `=` in the value → bare single-host shorthand (`ssh://ops@prod(ro)`, `auto`, `local`, empty → `auto`); with `=` → comma-separated `label=endpoint` list. `endpoint` is the keyword `auto`/`local` or a `unix://`/`tcp://`/`ssh://`/`npipe://` URL, with combinable trailing markers `(ro)` (read-only) and `(tls=<dir>)` (a tcp+TLS cert dir; **`ca.pem` is required** — the daemon is always verified against it — and `cert.pem`+`key.pem` are optional, present together for mutual TLS or absent for verify-the-daemon-only, e.g. a self-signed daemon pinned via `ca.pem`). **Fail-fast** (`HostConfigError` → stderr + non-zero exit) on duplicate/empty/invalid labels, a missing `=`, an unknown marker, `(tls=)` on a non-tcp endpoint, a missing `ca.pem` or a lone `cert.pem`/`key.pem`, or an unrecognized scheme.
|
|
37
|
+
- **Grammar.** No `=` in the value → bare single-host shorthand (`ssh://ops@prod(ro)`, `auto`, `local`, empty → `auto`); with `=` → comma-separated `label=endpoint` list. `endpoint` is the keyword `auto`/`local` or a `unix://`/`tcp://`/`ssh://`/`npipe://` URL, with combinable trailing markers `(ro)` (read-only), `(nd)` (non-destructive — blocks only DESTRUCTIVE calls; `(ro)` already implies it, so combining is harmless but redundant) and `(tls=<dir>)` (a tcp+TLS cert dir; **`ca.pem` is required** — the daemon is always verified against it — and `cert.pem`+`key.pem` are optional, present together for mutual TLS or absent for verify-the-daemon-only, e.g. a self-signed daemon pinned via `ca.pem`). **Fail-fast** (`HostConfigError` → stderr + non-zero exit) on duplicate/empty/invalid labels, a missing `=`, an unknown marker, `(tls=)` on a non-tcp endpoint, a missing `ca.pem` or a lone `cert.pem`/`key.pem`, or an unrecognized scheme.
|
|
38
38
|
- **`auto`/`local`/`default` are resolved to concrete URLs by us and pinned at `load()` (startup)** so the docker-py SDK and the CLI shell-out target the *same* daemon for a label (auditable) and a mid-session `docker context use` can't silently move a label. `default` = the first registry entry = the omitted-`host` fallback, and is **not** a selectable label. `load()` runs in `docker_mcp/__init__.py` before the tools import (the `@tool()` decorator and resources read `is_multi()`/`labels()` at registration) and scrubs whole-value `${...}` placeholders first.
|
|
39
|
-
- **Per-call host selection (no modal active-host state).** Every daemon-targeting tool declares `host: str | None = None` and threads it to `_get_client(host)` / `run_docker(..., host=host)`. The `@tool()` decorator does **display-only schema surgery** (`_apply_host_schema`, like `_slim_schema`, gated on `_hosts.is_multi()`) — strip `host` in single-host mode (footprint-neutral), or constrain it to an `enum` of the labels and mark it required for writes in multi-host mode — and wraps the tool with `_enforce_host_guard` (multi-host: writes require an explicit `host`, unknown labels rejected, writes to an `(ro)` host refused; read-only tools and the `_CONNECTION_CONTROL` set `system_close`/`system_reconnect`/`system_login`/`system_logout` may omit `host`). The guard is wrapped on when `_host_guard_needed()` — multi-host **or a single host flagged `(ro)`**: a lone `(ro)` host has its `host` param stripped (footprint-neutral) but its
|
|
40
|
-
- **Client / CLI.** `system.py` keeps a lazy pool `_clients` keyed by label with tiered per-host TLS (`(tls=)` dir → global `DOCKER_CERT_PATH`/`DOCKER_TLS_VERIFY` → plaintext); the legacy single host still uses `_build_default_client`/`from_env` unchanged. **`system_reconnect(host=None)` is rebuild-only** — it can't retarget to an arbitrary URL (edit the registry + restart), closing a trust-expansion hole. `system_close(host=None)` closes all/one. `startup_preflight` pings the default host but detects self-id against the *self host* (first local-transport entry, which can differ from a remote default); `guard_not_self(container, host=)` only fires on the self host. `_cli.py:_apply_host_env` injects the resolved `DOCKER_HOST` + per-host TLS into the child env for an explicit host.
|
|
39
|
+
- **Per-call host selection (no modal active-host state).** Every daemon-targeting tool declares `host: str | None = None` and threads it to `_get_client(host)` / `run_docker(..., host=host)`. The `@tool()` decorator does **display-only schema surgery** (`_apply_host_schema`, like `_slim_schema`, gated on `_hosts.is_multi()`) — strip `host` in single-host mode (footprint-neutral), or constrain it to an `enum` of the labels and mark it required for writes in multi-host mode — and wraps the tool with `_enforce_host_guard` (multi-host: writes require an explicit `host`, unknown labels rejected, writes to an `(ro)` host refused, DESTRUCTIVE calls to an `(nd)` host refused; read-only tools and the `_CONNECTION_CONTROL` set `system_close`/`system_reconnect`/`system_login`/`system_logout` may omit `host`). The guard is wrapped on when `_host_guard_needed()` — multi-host **or a single host flagged `(ro)` or `(nd)`**: a lone `(ro)`/`(nd)` host has its `host` param stripped (footprint-neutral) but its refusals still apply, so the per-host markers are honored even in single-host mode (distinct from `DOCKER_MCP_SERVER_READONLY`/`_NO_DESTRUCTIVE`, which drop tools entirely). A host with both markers is refused by `(ro)` first — it's strictly stronger, so `(nd)` never fires for it. A single *unrestricted* host wires no guard. **Excluded** (no `host` param): `registry`/`hub_*` (HTTPS, no daemon) and `context` (manages the host's CLI contexts).
|
|
40
|
+
- **Client / CLI.** `system.py` keeps a lazy pool `_clients` keyed by label with tiered per-host TLS (`(tls=)` dir → global `DOCKER_CERT_PATH`/`DOCKER_TLS_VERIFY` → plaintext); the legacy single host still uses `_build_default_client`/`from_env` unchanged. **Every `from_env` call must pass `use_context=False`** — docker-py 7.2.0 (the declared floor, for this reason) made `from_env` resolve the active Docker CLI context when the environment yields no `base_url`, duplicating resolution `_hosts.py` already does and pins at `load()`; a new `from_env` call site without it is a review finding, as is relaxing the `docker[ssh]>=7.2.0` floor. **`system_reconnect(host=None)` is rebuild-only** — it can't retarget to an arbitrary URL (edit the registry + restart), closing a trust-expansion hole. `system_close(host=None)` closes all/one. `startup_preflight` pings the default host but detects self-id against the *self host* (first local-transport entry, which can differ from a remote default); `guard_not_self(container, host=)` only fires on the self host. `_cli.py:_apply_host_env` injects the resolved `DOCKER_HOST` + per-host TLS into the child env for an explicit host. Every `ssh://` URL is run through `_ensure_reachable_family(_ensure_ssh_port(url))` before being handed to docker-py: `_ensure_ssh_port` splices in a `~/.ssh/config` `Port` that `docker.utils.parse_host()` would otherwise hardcode to 22 before `SSHHTTPAdapter` ever sees it; `_ensure_reachable_family` works around paramiko's own connect() only retrying the next resolved address family on `ECONNREFUSED`/`EHOSTUNREACH` (not `ETIMEDOUT`, what a broken IPv6 route actually produces) by probe-connecting itself via `_ssh_proxy.connect_socket_with_family_fallback` and splicing the address that answered into the URL as a literal IP — no monkeypatching of docker-py internals. A URL that's already a literal address, or where every candidate fails, is left unchanged. When reviewing a change near ssh:// connection handling, check whether both `_ensure_ssh_port` and `_ensure_reachable_family` are still being composed at every call site that builds a docker-py client from a URL.
|
|
41
41
|
- **Surfaces.** `host_list` tool + `docker-mcp://hosts` resource expose the resolved registry; the router gains a multi-host caveat; the container observability resources go host-aware (empty-authority `docker:///…` for the default + `docker://{host}/…` variants); a `prompt(multi_host=True)` gate plus the `survey_hosts` prompt register only with 2+ hosts.
|
|
42
42
|
|
|
43
43
|
**When reviewing a PR that changes the host grammar, env precedence, or the per-tool/resource/prompt host surface, this section is the spec.**
|
|
@@ -78,6 +78,8 @@ Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`
|
|
|
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
|
|
|
81
|
+
A required `Check fresh resolve still imports` job (same workflow) covers what every `--locked` job above cannot: it resolves `pyproject.toml` with `uv pip install` (the pip-compatible interface, which never touches `uv.lock`) into a throwaway venv, then runs `import docker_mcp` and `docker-mcp-server --version` against that fresh install — the same set a `uvx`/`pip install` would actually get today, not the pinned known-good set in `uv.lock`. It reports a resolution failure (specifier unsatisfiable) separately from an import failure (resolved fine, broke on import), since a PR fixing one looks different from a PR fixing the other. When reviewing a dependency-cap change (raising or removing a cap, e.g. on `mcp` or `cryptography`), this job's result is the one that tells you whether the wider range still imports — a green `pytest-run` does not, since it stays on the locked pins.
|
|
82
|
+
|
|
81
83
|
A **weekly canary** (`.github/workflows/canary.yaml`, Mondays + dispatch) hunts platform/ecosystem drift premerge CI can't see: wheels-only (`--only-binary :all:`) dependency resolution for Intel macOS / ARM macOS / Windows against both the repo `pyproject.toml` and the latest published PyPI release (the check that would have caught cryptography 49 dropping its x86_64-macOS wheel), plus real install smokes of the published package on `macos-latest`, `macos-15-intel` (Intel runner label retires Aug 2027), and `windows-latest` — `import docker_mcp` and `uvx docker-mcp-server --version`. PRs into main also run the repo-pyproject resolution leg (the only part exercising PR content); the published-package legs and issue filing stay schedule/dispatch-only. Failures on unattended runs file a deduplicated `ci-failure` + `wf:canary` issue via `.github/actions/file-failure-issue`. `main()` handles `--version` (print the installed version, exit) before any daemon/network contact — the canary's entry-point smoke depends on it.
|
|
82
84
|
|
|
83
85
|
### Container image (`Dockerfile`)
|
|
@@ -125,7 +127,7 @@ All publishing runs through one workflow on each **published GitHub Release**: `
|
|
|
125
127
|
|
|
126
128
|
### Provenance labels
|
|
127
129
|
|
|
128
|
-
Resources this server **creates** are stamped with `docker-mcp-server.*` provenance labels (`.managed=true`, `.version`, `.tool`, `.created`) so the agent/operator can later enumerate that footprint (the `managed_only=True` arg on `container_list` / `network_list` / `volume_list` / `service_list`, or `--filter label=docker-mcp-server.managed=true`; the `prune_managed` prompt removes only the managed footprint). On by default; opt out with `DOCKER_MCP_SERVER_NO_LABELS=1`. When adding a new create tool that accepts a `labels` dict, route it through `docker_mcp/tools/_labels.py:with_provenance(labels, "<tool_name>")` — it merges provenance without overwriting caller keys and returns `None` (drop it via `drop_none`) when stamping is disabled and the caller passed nothing. **Image builds are intentionally not stamped** (a build label changes the image digest).
|
|
130
|
+
Resources this server **creates** are stamped with `docker-mcp-server.*` provenance labels (`.managed=true`, `.version`, `.tool`, `.created`) so the agent/operator can later enumerate that footprint (the `managed_only=True` arg on `container_list` / `network_list` / `volume_list` / `service_list`, or `--filter label=docker-mcp-server.managed=true`; the `prune_managed` prompt removes only the managed footprint). On by default; opt out with `DOCKER_MCP_SERVER_NO_LABELS=1`. When adding a new create tool that accepts a `labels` dict, route it through `docker_mcp/tools/_labels.py:with_provenance(labels, "<tool_name>")` — it merges provenance without overwriting caller keys and returns `None` (drop it via `drop_none`) when stamping is disabled and the caller passed nothing. The seven stamped creators today are `container_run`, `container_create`, `network_create`, `volume_create`, `service_create`, `config_create`, `secret_create`. **Image builds are intentionally not stamped** (a build label changes the image digest); Compose/stack containers and `plugin_create` are also unstamped. The rule is conditional on the tool *accepting a `labels` dict* — a creator with nowhere to put a label is an expected exception, not an oversight, and should say so in its docstring rather than being flagged in review.
|
|
129
131
|
|
|
130
132
|
### CLI shell-out policy
|
|
131
133
|
|
|
@@ -135,7 +137,7 @@ Any tool wrapping a `docker` CLI feature MUST go through `docker_mcp/tools/_cli.
|
|
|
135
137
|
- Always pass an explicit `timeout=` to `run_docker` (generous for build/pull, short for queries).
|
|
136
138
|
- 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
139
|
|
|
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
|
|
140
|
+
**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 bespoke** — one side of the copy is a local path outside the compose file's working directory, which `remote_stage_and_exec` has no concept of relaying, so it stages `project_dir` the same way and then uses `_split_cp_arg` (mirroring `docker compose cp`'s own `splitCpArg`, verified against `docker/compose`'s `pkg/compose/cp.go`) to identify which of `source`/`dest` is the container reference: a local source is staged like `project_dir`; a local destination gets a fresh path from `RemoteStagingSession.reserve_path()` for the remote `docker compose cp` to write into, fetched back via `RemoteStagingSession.fetch_path()` once the copy succeeds (`test -d` decides file vs. directory; a directory is packed remotely with `tar`, downloaded, and extracted locally with `tarfile`'s `filter="data"` — what actually stops an escaping member — under the same size/count bounds `_enforce_stage_limits` applies to uploads). Every parameter carries over unchanged since the real CLI always executes the copy; the one gap is a container→host copy whose local destination already exists, refused rather than merged into or overwritten. When neither or both sides look like `SERVICE:PATH`, the call passes through unchanged and the real remote CLI's own validation error surfaces. `unix://`/`tcp://`+TLS with no local plugin still raise via `require_plugin`, whose message now also names pointing the host at an `ssh://` endpoint via `DOCKER_MCP_SERVER_HOSTS` as an alternative to installing the plugin (shared by `compose`/`buildx`/`scout`); `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
141
|
|
|
140
142
|
**`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
143
|
|
|
@@ -260,16 +262,14 @@ Always verify the exact method name, parameter names, and return type at https:/
|
|
|
260
262
|
|
|
261
263
|
When the high-level SDK lacks a method (e.g. swarm node removal, service rollback), use the low-level `APIClient` via `_get_client().api` (`remove_node`, `update_service`, `inspect_service`, …), documented at https://docker-py.readthedocs.io/en/stable/api.html — verified the same way. Prefer the high-level object API where it exists.
|
|
262
264
|
|
|
265
|
+
Treat "the method exists" as separate from "the method works": the rendered docs show a docstring, not the URL a method builds. `plugin_push` is the standing example — docker-py's `Plugin.push()` / `APIClient.push_plugin()` POST to `/plugins/{name}/pull`, which the Engine does not define (push is `POST /plugins/{name}/push`), so they 404 everywhere; broken since 2017 and still in `main`, because upstream has no test for it. Where a documented method is provably broken, the ladder is: another public SDK path, then the correct endpoint via docker-py's private request helpers (`_url`/`_post`/`_raise_for_status`/`_stream_helper`) resolved through `getattr` and guarded to raise an actionable message rather than `AttributeError` (as `system_logout` does with `api._auth_configs`), then a CLI shell-out if the domain is already CLI-backed. Anything below the first rung leaves the supported surface and is **a human's call, not an agent's**: a PR that adds a *new* private-helper reach-in should be flagged as needing explicit maintainer sign-off, even when it is well built — say so rather than approving it on the strength of the guards. Do flag one that is unguarded, undocumented, or that hits an endpoint absent from the Engine API spec (no compatibility promise, no deprecation cycle — a categorically worse bet than `plugin_push`'s, which uses a spec'd endpoint the CLI itself calls and reaches it through unofficial plumbing). Do **not** re-litigate the reach-ins already blessed above (`plugin_push`, `system_logout`, `stage_build_context`): being present on `main` *is* the sign-off, so a PR that merely touches or refactors one needs no fresh approval — only a genuinely new reach-in does. If a diff both adds and blesses one, that has already been decided; say nothing. And do not treat an audit or routine that *declined* to implement something and documented why as an unfinished job — that write-up is the intended escalation path, and is how `plugin_push` reached a human decision in the first place.
|
|
266
|
+
|
|
267
|
+
**SDK audit exclusions.** A recurring audit routine looks for uncovered SDK surface and for low-level `client.api.*` calls a high-level method could replace. These were decided on the merits and must not be re-proposed (in review or by the routine): `Plugin.push()`/`APIClient.push_plugin()` — broken upstream, so `plugin_push`'s hand-built endpoint call is the working path, not debt to tidy away; `Container.attach`/`attach_socket`/`resize` — deliberately unwrapped, as an interactive bidirectional stream doesn't fit a request/response tool call (`container_exec` covers scripted exec); `service_rollback`'s `inspect_service`/`update_service` and `system_logout`'s `_auth_configs` — permanently low-level, as the high-level SDK has no `rollback` and no `logout` at all. Anything deliberately not wrapped, or wrapped unobviously, belongs on that list in `CLAUDE.md`.
|
|
268
|
+
|
|
263
269
|
Docker SDK docs: https://docker-py.readthedocs.io/en/stable/index.html
|
|
264
270
|
Docker SDK low-level API: https://docker-py.readthedocs.io/en/stable/api.html
|
|
265
271
|
Docker SDK GitHub: https://github.com/docker/docker-py
|
|
266
272
|
|
|
267
273
|
### Review Notes
|
|
268
274
|
|
|
269
|
-
There
|
|
270
|
-
Changing this line to a later version of cryptography can break installs on Intel (x86_64) macOS, which premerge CI does not cover (the canary workflow checks wheels-only resolution for x86_64 macOS — weekly and on PRs into main — and the pin guard in tests/test_pyproject_pins.py is the merge-time enforcement).
|
|
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.
|
|
272
|
-
Changing to a later version of cryptography for other platforms is fine, but this pin should remain for macOS x86_64.
|
|
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.
|
|
275
|
+
There used to be a major-version cap on `mcp` (`"mcp>=1.27.1,<2"`), a hotfix for mcp 2.0.0 (2026-07-28) removing `mcp.server.fastmcp`, which `server.py` imported `FastMCP` from — premerge CI could not see this because it installs `--locked`, so the lockfile's 1.x kept every test green while a *fresh* resolve broke at import (the published 2.2.0 shipped uncapped and `uvx docker-mcp-server` failed at import on a clean machine; 2.2.1 was the hotfix). `server.py` has since been ported to `mcp.server.mcpserver.MCPServer` and the cap removed; `mcp` is now unpinned above `2.0.0`. Rather than a version cap, `tests/test_pyproject_pins.py::test_the_declared_mcp_bound_matches_what_the_code_imports` asserts the module `server.py` imports its server class from is one the installed mcp actually provides — a permanent guard against a future mcp release removing that import path, with no cap to remember to add first. If a PR touches this import or that test, check the change is not silently narrowing that guard. The same principle applies to any new direct dependency whose import surface this project touches — check whether a cap or a guard like this belongs with it.
|
|
@@ -95,7 +95,8 @@ jobs:
|
|
|
95
95
|
if: github.event_name != 'pull_request'
|
|
96
96
|
name: Install smoke (${{ matrix.os }})
|
|
97
97
|
runs-on: ${{ matrix.os }}
|
|
98
|
-
|
|
98
|
+
# 20, not 15: macos-15-intel now source-builds cryptography (brew install + Rust compile).
|
|
99
|
+
timeout-minutes: 20
|
|
99
100
|
strategy:
|
|
100
101
|
fail-fast: false
|
|
101
102
|
matrix:
|
|
@@ -112,6 +113,18 @@ jobs:
|
|
|
112
113
|
# live "resolve latest" fetch to raw.githubusercontent.com.
|
|
113
114
|
version: "0.11.21"
|
|
114
115
|
|
|
116
|
+
- name: Point the build at Homebrew's OpenSSL 3.x (Intel macOS only)
|
|
117
|
+
# macos-15-intel ships OpenSSL 1.1.1w (see actions/runner-images); cryptography >=44
|
|
118
|
+
# dropped OpenSSL 1.1.x support and now requires 3.0+. No x86_64 wheel exists (see
|
|
119
|
+
# resolve-crossplatform above), so this install source-builds — the Rust toolchain is
|
|
120
|
+
# already on the runner, but it still needs a compatible OpenSSL to link against.
|
|
121
|
+
if: matrix.os == 'macos-15-intel'
|
|
122
|
+
run: |
|
|
123
|
+
brew install openssl@3
|
|
124
|
+
prefix="$(brew --prefix openssl@3)"
|
|
125
|
+
echo "OPENSSL_DIR=$prefix" >> "$GITHUB_ENV"
|
|
126
|
+
echo "PKG_CONFIG_PATH=$prefix/lib/pkgconfig" >> "$GITHUB_ENV"
|
|
127
|
+
|
|
115
128
|
- name: Import smoke (full tool-registration path, no daemon needed)
|
|
116
129
|
run: uv run --no-project --python 3.14 --with docker-mcp-server python -c "import docker_mcp"
|
|
117
130
|
|
|
@@ -31,12 +31,12 @@ jobs:
|
|
|
31
31
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
32
32
|
|
|
33
33
|
- name: Initialize CodeQL
|
|
34
|
-
uses: github/codeql-action/init@
|
|
34
|
+
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
|
35
35
|
with:
|
|
36
36
|
languages: ${{ matrix.language }}
|
|
37
37
|
|
|
38
38
|
- name: Autobuild
|
|
39
|
-
uses: github/codeql-action/autobuild@
|
|
39
|
+
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
|
40
40
|
|
|
41
41
|
- name: Perform CodeQL Analysis
|
|
42
|
-
uses: github/codeql-action/analyze@
|
|
42
|
+
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
|
@@ -20,6 +20,53 @@ concurrency:
|
|
|
20
20
|
# Jobs
|
|
21
21
|
jobs:
|
|
22
22
|
|
|
23
|
+
fresh-resolve-import:
|
|
24
|
+
# Answers a question `uv sync --locked` (used by every other job here) cannot: would a
|
|
25
|
+
# CLEAN install of what we're about to ship actually import? `uv sync --locked` only ever
|
|
26
|
+
# sees the pinned, known-good set recorded in uv.lock, never the set a fresh `uvx`/`pip
|
|
27
|
+
# install` would resolve from the bare pyproject.toml specifiers today. mcp 2.0.0
|
|
28
|
+
# (2026-07-28) proved the gap: it resolved cleanly against our then-uncapped `mcp>=1.27.1`
|
|
29
|
+
# and removed `mcp.server.fastmcp`, so `import docker_mcp` died on every fresh install while
|
|
30
|
+
# every uv.lock-pinned CI job here stayed green (see the mcp cap comment in pyproject.toml).
|
|
31
|
+
# `uv pip install` is the pip-compatible interface — unlike `uv sync`, it never
|
|
32
|
+
# reads or writes uv.lock, so it resolves purely from [project.dependencies].
|
|
33
|
+
runs-on: [ubuntu-latest]
|
|
34
|
+
name: Check fresh resolve still imports
|
|
35
|
+
steps:
|
|
36
|
+
- name: Check out source repository
|
|
37
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
38
|
+
|
|
39
|
+
- name: Set up Python environment
|
|
40
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.14"
|
|
43
|
+
|
|
44
|
+
- name: Set up uv
|
|
45
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
46
|
+
|
|
47
|
+
- name: Resolve and install from pyproject.toml, ignoring uv.lock
|
|
48
|
+
# A failure here is a resolve/build/install failure (an unsatisfiable specifier, a
|
|
49
|
+
# sdist needing a toolchain we don't have, a transient fetch error) — distinct from
|
|
50
|
+
# the import failure checked in the next step. The log above this line has the cause.
|
|
51
|
+
run: |
|
|
52
|
+
uv venv .venv-fresh
|
|
53
|
+
if ! uv pip install --python .venv-fresh/bin/python . ; then
|
|
54
|
+
echo "::error::Fresh resolve/install of pyproject.toml FAILED (see the uv output above for the cause) — this is a resolve/install failure, not an import failure."
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Import the freshly-resolved install
|
|
59
|
+
run: |
|
|
60
|
+
if ! .venv-fresh/bin/python -c "import docker_mcp"; then
|
|
61
|
+
echo "::error::pyproject.toml resolved fine, but 'import docker_mcp' FAILED against the freshly-resolved dependency set. An admitted dependency release broke our import surface (see the mcp 2.0.0 incident in CLAUDE.md) — tighten the offending dependency's cap."
|
|
62
|
+
exit 1
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
- name: Run the entry point against the freshly-resolved install
|
|
66
|
+
# main() handles --version (print version, exit) before any daemon/network contact,
|
|
67
|
+
# so this needs no Docker and catches a failure in main() itself, not just the import.
|
|
68
|
+
run: .venv-fresh/bin/docker-mcp-server --version
|
|
69
|
+
|
|
23
70
|
pytest-run:
|
|
24
71
|
runs-on: [ubuntu-latest]
|
|
25
72
|
name: Run pytest
|
|
@@ -158,7 +158,7 @@ jobs:
|
|
|
158
158
|
run: uv build
|
|
159
159
|
|
|
160
160
|
- name: Publish to PyPI
|
|
161
|
-
uses: pypa/gh-action-pypi-publish@
|
|
161
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
162
162
|
with:
|
|
163
163
|
# Makes a dispatch re-run after a partial failure idempotent (PyPI files are immutable,
|
|
164
164
|
# so a re-upload of the same files would otherwise 400). This can't mask publishing a
|
|
@@ -239,7 +239,7 @@ jobs:
|
|
|
239
239
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
|
240
240
|
|
|
241
241
|
- name: Log in to GHCR
|
|
242
|
-
uses: docker/login-action@
|
|
242
|
+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
243
243
|
with:
|
|
244
244
|
registry: ghcr.io
|
|
245
245
|
username: ${{ github.actor }}
|
|
@@ -247,7 +247,7 @@ jobs:
|
|
|
247
247
|
|
|
248
248
|
- name: Log in to Docker Hub
|
|
249
249
|
if: env.DOCKERHUB_USER != '' && env.DOCKERHUB_TOKEN != ''
|
|
250
|
-
uses: docker/login-action@
|
|
250
|
+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
251
251
|
with:
|
|
252
252
|
username: ${{ secrets.DOCKERHUB_USER }}
|
|
253
253
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
@@ -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, 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.
|
|
16
|
+
The `docker` dependency is pulled with its `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works through a pure-Python transport — no system `ssh` binary, identical on the host and in the container images. docker-py auto-selects paramiko for `ssh://` when present, so there is no transport code to maintain (just the `ssh://` branch in `system._connection_help`). CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to `docker`, which would otherwise use the *system* `ssh` — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection (mirroring docker-py's `SSHHTTPAdapter` defaults) and runs `docker system dial-stdio` over it, so the CLI authenticates identically to the docker-py-backed tools with no system `ssh` binary involved (the one exception being a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — same as it would for the docker-py-backed tools). Where there is no local `docker` binary (or plugin) at all, all of those except the `context_*` tools (which manage *this* host's own CLI contexts) fall back to running the command on the `ssh://` host itself — see "SSH remote-exec fallback" under the CLI shell-out policy. Both the docker-py-backed and CLI-backed SSH connections fall back from IPv6 to IPv4 on any connect failure (not just paramiko's own narrower retry) via `_ssh_proxy.py:connect_socket_with_family_fallback` — see "Client side" under the multi-daemon host registry below.
|
|
17
17
|
|
|
18
18
|
## Commands
|
|
19
19
|
|
|
@@ -55,14 +55,27 @@ 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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
An `mcp<2` cap existed briefly: mcp 2.0.0 removed `mcp.server.fastmcp`, which `server.py` imported
|
|
59
|
+
`FastMCP` from, and an uncapped 2.2.0 shipped dead on arrival at import while every CI job stayed
|
|
60
|
+
green, because CI installs `--locked` against a lockfile pinning mcp 1.x; 2.2.1 hotfixed the cap.
|
|
61
|
+
`server.py` has since been ported to `mcp.server.mcpserver.MCPServer` and the cap lifted. Rather
|
|
62
|
+
than re-adding a cap for the next major (there is no known 3.x incompatibility to guard against),
|
|
63
|
+
`tests/test_pyproject_pins.py::test_the_declared_mcp_bound_matches_what_the_code_imports` is a
|
|
64
|
+
living guard: it fails whenever the installed mcp stops providing the import path `server.py`
|
|
65
|
+
actually uses, with no reliance on remembering to add a cap first. When adding a direct dependency
|
|
66
|
+
whose *import surface* we touch, consider whether a cap or a guard like this belongs with it.
|
|
67
|
+
|
|
68
|
+
**A required `Check fresh resolve still imports` job** (`premerge.yaml`) closes the blind spot that
|
|
69
|
+
let the 2.2.0 incident through: every job above installs with `uv sync --locked`, so none of them
|
|
70
|
+
ever resolve what a fresh `uvx`/`pip install` actually gets from the bare `pyproject.toml`
|
|
71
|
+
specifiers — only the pinned, known-good set in `uv.lock`. This job does, via `uv pip install`
|
|
72
|
+
(the pip-compatible interface, which never reads or writes `uv.lock`) into a throwaway venv, then
|
|
73
|
+
runs `import docker_mcp` and `docker-mcp-server --version` against that install. It reports a
|
|
74
|
+
resolution failure (a specifier no longer satisfiable) and an import failure (resolved fine, but
|
|
75
|
+
broke on import) as distinct errors, since they call for different fixes. This is a PR/push gate,
|
|
76
|
+
not a schedule — it complements rather than replaces the weekly canary's published-package install
|
|
77
|
+
smoke below, which exercises the actual shipped artefact rather than a hypothetical resolve of the
|
|
78
|
+
current tree.
|
|
66
79
|
|
|
67
80
|
A **weekly canary** (`.github/workflows/canary.yaml`, Mondays + dispatch) hunts platform/ecosystem
|
|
68
81
|
drift premerge CI can't see: wheels-only (`--only-binary :all:`) dependency resolution for Intel
|
|
@@ -83,7 +96,7 @@ contact — the canary's entry-point smoke depends on it.
|
|
|
83
96
|
The `docker_mcp` package is the entry point. `docker_mcp/__init__.py` defines `main()` and side-effect-imports the `server` and `tools` submodules (which registers all `@tool()` decorators). `docker_mcp/__main__.py` calls `main()` so `python -m docker_mcp` works; the installed `docker-mcp` console script also targets `docker_mcp:main`.
|
|
84
97
|
|
|
85
98
|
### Server singleton (`docker_mcp/server.py`)
|
|
86
|
-
Instantiates `
|
|
99
|
+
Instantiates `MCPServer` (from `mcp.server.mcpserver`), exports the `mcp` object, and exports the `tool` and `prompt` registration helpers. **Tool modules import `tool`; prompt modules import `prompt`** — both gate on `DOCKER_MCP_SERVER_DISABLE` (never import from `mcp` directly in those modules — that would create circular imports). `@mcp.resource()` modules still import `mcp` (plus `is_domain_disabled` / `register_resource_domains` for section gating).
|
|
87
100
|
|
|
88
101
|
```python
|
|
89
102
|
from docker_mcp.server import tool # tool modules
|
|
@@ -91,7 +104,7 @@ from docker_mcp.server import prompt # prompt modules (with domain=...)
|
|
|
91
104
|
from docker_mcp.server import mcp # resource modules
|
|
92
105
|
```
|
|
93
106
|
|
|
94
|
-
`server.py` also owns the central **`TOOL_CATEGORIES`** map (every tool name → `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`). The `@tool()` decorator uses it to (a) attach `ToolAnnotations` (`readOnlyHint` / `destructiveHint`,
|
|
107
|
+
`server.py` also owns the central **`TOOL_CATEGORIES`** map (every tool name → `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`). The `@tool()` decorator uses it to (a) attach `ToolAnnotations` (`title` — mechanically derived from the tool name by `_title_for`, e.g. `container_list` → "Container List", with a small `_TITLE_ACRONYMS` fixup list so names like `scout_cves`/`scout_sbom` title-case to "Scout CVEs"/"Scout SBOM" rather than "Cves"/"Sbom"; plus `readOnlyHint` / `destructiveHint`, and `idempotentHint` for the prune family) and (b) skip registration entirely under the read-only env switches `DOCKER_MCP_SERVER_READONLY` (only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (everything except destructive). Every registered tool must have a `TOOL_CATEGORIES` entry — `tests/test_server.py` fails if the map and the registered set drift. The `title` annotation exists because some external directories (e.g. the Claude Connectors Directory) mechanically require one on every tool, independent of description quality — see the Docstring quality standard below, point 2's "annotations don't substitute for prose" is the opposite failure mode, not a contradiction.
|
|
95
108
|
|
|
96
109
|
**Env-var naming.** All server tunables are namespaced `DOCKER_MCP_SERVER_*` (matching the published package/image name `docker-mcp-server`); the pre-rename `DOCKER_MCP_*` alias spellings were removed in 2.0. Read env vars through `docker_mcp/_env.py` — `read_env("DOCKER_MCP_SERVER_NAME")` or `env_flag(...)`. The helper still supports alias fallbacks (`read_env(canonical, *aliases)` with a one-time stderr deprecation notice) for any future rename; no alias is currently registered. `_env.py` lives at the package root (not under `tools/`) so `server.py` can import it without pulling in `docker_mcp.tools`, which would be a circular import at registration time; `_utils.py` re-exports `env_flag` / `read_env` for tool modules. A new tunable adds a canonical `DOCKER_MCP_SERVER_*` name.
|
|
97
110
|
|
|
@@ -101,7 +114,7 @@ The decorator also records each tool's **domain** — the leaf of its defining m
|
|
|
101
114
|
|
|
102
115
|
A handful of tools have **no domain at all** — `_NO_DOMAIN_TOOLS` (today just `docs_lookup`) — because their value isn't tied to any single Docker feature area being enabled or disabled. `_domain_for` returns `None` for these, and `None` short-circuits the `_domain_enabled` check entirely, so `DOCKER_MCP_SERVER_DISABLE` can never drop them (not even by their own name). This mirrors `@prompt(domain=None)`'s identical "cross-cutting, always available" semantics for prompts. They still register/deregister normally under `DOCKER_MCP_SERVER_READONLY`/`_NO_DESTRUCTIVE` based on their own category (a domain-less tool should still be `READ_ONLY` for this to matter in practice).
|
|
103
116
|
|
|
104
|
-
**Server `instructions` router.** `server.py` also builds the
|
|
117
|
+
**Server `instructions` router.** `server.py` also builds the MCPServer `instructions` string — the text a client pre-loads into context alongside the server name and tool names, *before* any per-tool schema. For a lazy-loading client (e.g. Claude Code, which fetches tool schemas on demand) that's the main always-in-context surface we control, so it's written as a **router**, not docs: a per-domain one-liner mapping user vocabulary onto the domain keyword a tool search will hit, plus a few tool-selection caveats. It deliberately does not enumerate tools (that's the `docker-mcp://tool-catalog` resource). It's built dynamically by `build_instructions()` from `_DOMAIN_BLURBS`, emitting a domain's line **only when that domain has a registered tool** — so `DOCKER_MCP_SERVER_DISABLE` / `_READONLY` / `_NO_DESTRUCTIVE` are all honored through the one registration flag, and the router never advertises a domain whose tools didn't register. `finalize_instructions()` (called from `docker_mcp/__init__.py` *after* every tool module imports) writes the result through to `mcp._lowlevel_server.instructions` — MCPServer's `instructions` is a read-only property whose value is read at `run()` time, so a late write propagates to the MCP initialize handshake; the `_lowlevel_server` reach-in is guarded like `_slim_schema`. **A new tool *domain* needs a `_DOMAIN_BLURBS` entry** or the router silently omits it (`tests/test_server.py` checks the router tracks the registered domain set).
|
|
105
118
|
|
|
106
119
|
### Multi-daemon host registry (`docker_mcp/_hosts.py`)
|
|
107
120
|
|
|
@@ -109,15 +122,19 @@ A handful of tools have **no domain at all** — `_NO_DOMAIN_TOOLS` (today just
|
|
|
109
122
|
|
|
110
123
|
`_hosts.py` lives at the package root (like `_env.py`, so `server.py` can import it without pulling in `docker_mcp.tools`). It parses the var into a pinned `{label: Host}` registry and owns all host resolution — no docker-py/CLI calls, just env + Docker config-file reads:
|
|
111
124
|
|
|
112
|
-
- **Grammar.** No `=` in the value → bare single-host shorthand (`ssh://ops@prod(ro)`, `auto`, `local`, or empty → `auto`). With `=` → comma-separated `label=endpoint` list. `endpoint` is the keyword `auto`/`local` or a `unix://`/`tcp://`/`ssh://`/`npipe://` URL, with combinable trailing markers `(ro)` (read-only) and `(tls=<dir>)` (a tcp+TLS cert dir; **`ca.pem` is required** — the daemon is always verified against it — and `cert.pem`+`key.pem` are optional, present together for mutual TLS or absent for verify-the-daemon-only, e.g. a self-signed daemon pinned via `ca.pem`). **Fail-fast** (`HostConfigError` → stderr + exit non-zero) on duplicate/empty/invalid labels, a missing `=`, an unknown marker, `(tls=)` on a non-tcp endpoint, a missing `ca.pem` or a lone `cert.pem`/`key.pem`, or an unrecognized scheme.
|
|
125
|
+
- **Grammar.** No `=` in the value → bare single-host shorthand (`ssh://ops@prod(ro)`, `auto`, `local`, or empty → `auto`). With `=` → comma-separated `label=endpoint` list. `endpoint` is the keyword `auto`/`local` or a `unix://`/`tcp://`/`ssh://`/`npipe://` URL, with combinable trailing markers `(ro)` (read-only), `(nd)` (non-destructive — blocks only DESTRUCTIVE calls; `(ro)` already implies it, so combining the two is harmless but redundant) and `(tls=<dir>)` (a tcp+TLS cert dir; **`ca.pem` is required** — the daemon is always verified against it — and `cert.pem`+`key.pem` are optional, present together for mutual TLS or absent for verify-the-daemon-only, e.g. a self-signed daemon pinned via `ca.pem`). **Fail-fast** (`HostConfigError` → stderr + exit non-zero) on duplicate/empty/invalid labels, a missing `=`, an unknown marker, `(tls=)` on a non-tcp endpoint, a missing `ca.pem` or a lone `cert.pem`/`key.pem`, or an unrecognized scheme.
|
|
113
126
|
- **`auto`/`local`/`default` are OUR concepts, resolved to concrete URLs by us and pinned at `load()` (startup)** — so the docker-py SDK and the docker-CLI shell-out provably target the *same* daemon for a given label (auditable), and a mid-session `docker context use` can't silently move a label (restart to re-resolve). `auto` = the active CLI context's endpoint (`DOCKER_CONTEXT` / config.json `currentContext` → its `meta.json` Host) else the `local` socket probe; `local` = the platform-local socket (the `_probe_default_socket` candidate list); `default` = the omitted-`host` fallback = the first registry entry, and is **not** a selectable label. These resolution helpers were relocated *from* `system.py` (then `client.py`) into `_hosts.py`.
|
|
114
127
|
- **`load()` runs in `docker_mcp/__init__.py` before the tools import** (the `@tool()` decorator and resources read `is_multi()`/`labels()` at registration time) and scrubs whole-value `${...}` placeholders first (`_env.scrub_unresolved_env`, so an mcpb blank field resolves to the default host instead of fail-fasting).
|
|
115
128
|
|
|
116
129
|
**Per-call host selection (no modal active-host state).** Every daemon-targeting tool declares `host: str | None = None` and threads it to `_get_client(host)` / `run_docker(..., host=host)`; the `@tool()` decorator does the rest (the schema surgery is gated on `_hosts.is_multi()`; the call-time guard on the broader `_host_guard_needed()`):
|
|
117
130
|
- **Schema surgery** (`_apply_host_schema`, display-only like `_slim_schema` — call-time validation runs off `fn_metadata`, gated on `_hosts.is_multi()`): single-host → strip the `host` property entirely (footprint-neutral, schema byte-identical to today); multi-host → constrain `host` to an `enum` of the labels and mark it required for writes.
|
|
118
|
-
- **Call-time guard** (`_enforce_host_guard`, wrapped onto the tool via `_wrap_with_host_guard`, which preserves the signature and sync/async-ness): in multi-host mode writes require an explicit `host`, unknown labels are rejected,
|
|
131
|
+
- **Call-time guard** (`_enforce_host_guard`, wrapped onto the tool via `_wrap_with_host_guard`, which preserves the signature and sync/async-ness): in multi-host mode writes require an explicit `host`, unknown labels are rejected, writes to an `(ro)` host are refused, and DESTRUCTIVE calls to an `(nd)` host are refused. Read-only tools and the `_CONNECTION_CONTROL` set (`system_close`/`system_reconnect`/`system_login`/`system_logout`) may omit `host`. The guard is wrapped on whenever there is something to enforce — `_host_guard_needed()` = multi-host **or a single host flagged `(ro)` or `(nd)`**: a lone `(ro)`/`(nd)` host carries no `host` param (schema is still stripped, footprint-neutral) but its refusals still apply, so the per-host markers are honored even in single-host mode (distinct from the `DOCKER_MCP_SERVER_READONLY`/`_NO_DESTRUCTIVE` switches, which drop tools from the surface entirely). A host with both markers is refused by the `(ro)` check first — `(ro)` is strictly stronger, so `(nd)` never fires for it. A single *unrestricted* host wires no guard (today's path). **Excluded** (no `host` param at all): `registry`/`hub_*` (HTTPS, no daemon) and `context` (manages the host's CLI contexts).
|
|
119
132
|
|
|
120
|
-
**Client side** (`system.py`): a lazy pool `_clients` keyed by label; `_get_client(host)` builds per host with tiered TLS (`(tls=)` cert dir → global `DOCKER_CERT_PATH`/`DOCKER_TLS_VERIFY` → plaintext); the legacy single host (unset var) still goes through `_build_default_client`/`from_env` unchanged, and an explicit host that resolves to the platform default (url `None`) is built without a `base_url` so it never re-reads the ignored ambient `DOCKER_HOST`. `system_close(host=None)` closes all/one; **`system_reconnect(host=None)` is rebuild-only** — it cannot retarget to an arbitrary URL (to change a daemon, edit the registry and restart), which closes a trust-expansion hole. `_cli.py:_apply_host_env` injects the resolved `DOCKER_HOST` + per-host TLS into the child env for an explicit host (the ssh:// proxy keys off it). `startup_preflight` pings the *default* host but detects self-id against the *self host* (first local-transport entry, which may differ from a remote default), and `guard_not_self(container, host=)` only fires on the self host.
|
|
133
|
+
**Client side** (`system.py`): a lazy pool `_clients` keyed by label; `_get_client(host)` builds per host with tiered TLS (`(tls=)` cert dir → global `DOCKER_CERT_PATH`/`DOCKER_TLS_VERIFY` → plaintext); the legacy single host (unset var) still goes through `_build_default_client`/`from_env` unchanged, and an explicit host that resolves to the platform default (url `None`) is built without a `base_url` so it never re-reads the ignored ambient `DOCKER_HOST`. **Every `from_env` call passes `use_context=False`** — the reason `docker[ssh]>=7.2.0` is a hard floor rather than a preference. 7.2.0 made `from_env` resolve the active Docker CLI context whenever the environment yields no `base_url`, which is *our* job: `resolve_auto()` reads `DOCKER_CONTEXT` / config.json `currentContext` itself and pins the result at `load()`, so letting docker-py resolve independently would reintroduce the mid-session `docker context use` drift that pinning exists to prevent, and could disagree with the endpoint the CLI shell-out targets for the same label. `tests/test_pyproject_pins.py::test_the_declared_docker_floor_supports_the_kwarg_the_code_passes` fails if the floor stops excluding 7.1.0 or an installed docker-py drops the kwarg. `system_close(host=None)` closes all/one; **`system_reconnect(host=None)` is rebuild-only** — it cannot retarget to an arbitrary URL (to change a daemon, edit the registry and restart), which closes a trust-expansion hole. `_cli.py:_apply_host_env` injects the resolved `DOCKER_HOST` + per-host TLS into the child env for an explicit host (the ssh:// proxy keys off it). `startup_preflight` pings the *default* host but detects self-id against the *self host* (first local-transport entry, which may differ from a remote default), and `guard_not_self(container, host=)` only fires on the self host.
|
|
134
|
+
|
|
135
|
+
Both `_build_default_client` and `_build_client` run every `ssh://` URL through two docker-py workarounds before handing it to `docker.from_env()`/`docker.DockerClient()`, composed as `_ensure_reachable_family(_ensure_ssh_port(url))`:
|
|
136
|
+
- **`_ensure_ssh_port`**: `docker.utils.parse_host()` hardcodes port 22 into the URL *before* `SSHHTTPAdapter._create_paramiko_client` ever runs, so that adapter's own `~/.ssh/config` `Port` fallback (which only fires while the port is still unset) never triggers — a non-22 `Port` in `~/.ssh/config` would otherwise be silently ignored. Splices in the configured port first, reusing the same `~/.ssh/config` lookup `_ssh_proxy.parse_ssh_url` does for the CLI-backed tools.
|
|
137
|
+
- **`_ensure_reachable_family`**: `paramiko.SSHClient.connect()` (which `SSHHTTPAdapter` calls internally) resolves both address families but only retries the next one on `ECONNREFUSED`/`EHOSTUNREACH` — a timed-out or black-holed IPv6 route (`ETIMEDOUT`, what a broken IPv6 path actually produces) is never retried, so a host that's perfectly reachable over IPv4 fails outright (`tcp://` doesn't have this problem — `urllib3`'s `create_connection` catches any `OSError` per attempt). Rather than reaching into `SSHHTTPAdapter` internals, this probes every resolved address itself with `_ssh_proxy.connect_socket_with_family_fallback` (the same broad-`OSError`-per-attempt helper `connect_ssh_client` passes to paramiko as `sock=` for the CLI-backed tools) and splices whichever address actually answered into the URL as a literal IP, so docker-py's own connect resolves trivially with nothing left to get wrong. One extra short-lived probe connection per client build, absorbed by the pool (once per host, not per call). A URL that's already a literal address, or where every candidate fails, is left unchanged.
|
|
121
138
|
|
|
122
139
|
**Surfaces.** `host_list` (READ_ONLY) tool + the `docker-mcp://hosts` resource expose the resolved registry (the default is observable but not selectable). The router (`build_instructions`) adds a multi-host caveat, the container observability resources switch to empty-authority / host-qualified URIs (see MCP resources below), and a `prompt(multi_host=True)` gate plus the `survey_hosts` prompt register only when 2+ hosts are configured. **When changing the host grammar, the env-var precedence, the per-tool/resource/prompt host surface, or the resolution semantics, update this section.**
|
|
123
140
|
|
|
@@ -204,7 +221,7 @@ All publishing runs through one workflow on each **published GitHub Release** (n
|
|
|
204
221
|
|
|
205
222
|
Resources this server **creates** are stamped with `docker-mcp-server.*` provenance labels (`.managed=true`, `.version`, `.tool`, `.created`) via `docker_mcp/tools/_labels.py`, so the agent/operator can later enumerate that footprint — the `managed_only=True` arg on `container_list` / `network_list` / `volume_list` / `service_list`, or `--filter label=docker-mcp-server.managed=true`. The `prune_managed` prompt tears down only the managed footprint. Stamping is **on by default** and additive (a caller-supplied label always wins on a key collision); `DOCKER_MCP_SERVER_NO_LABELS=1` turns it off. The prefix is the bare project name (deliberately not reverse-DNS) and is a single constant in `_labels.py`.
|
|
206
223
|
|
|
207
|
-
When adding a new create tool that accepts a `labels` dict, route it through `_labels.py:with_provenance(labels, "<tool_name>")` (it accepts the dict/list/None shapes the SDK accepts and returns `None` — feed it through `drop_none` — when stamping is off and the caller passed nothing). The
|
|
224
|
+
When adding a new create tool that accepts a `labels` dict, route it through `_labels.py:with_provenance(labels, "<tool_name>")` (it accepts the dict/list/None shapes the SDK accepts and returns `None` — feed it through `drop_none` — when stamping is off and the caller passed nothing). The seven stamped creators today are `container_run`, `container_create`, `network_create`, `volume_create`, `service_create` (service-level `labels` only, not `container_labels`), `config_create`, `secret_create`. **Image builds are intentionally NOT stamped** — a build label changes the resulting image digest. Compose/stack containers (created via CLI shell-out) are also unstamped, as is `plugin_create` — the Engine's plugin-create call accepts no labels field, so there is nothing to stamp. The rule is conditional on the tool *accepting a `labels` dict*: a creator with nowhere to put a label is an expected exception, not an oversight, and should say so in its docstring. New `managed_only`-style label filters go through `_labels.py:managed_filter`.
|
|
208
225
|
|
|
209
226
|
## CLI shell-out policy
|
|
210
227
|
|
|
@@ -236,7 +253,7 @@ Two rules for tool modules:
|
|
|
236
253
|
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
254
|
|
|
238
255
|
- **`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
|
|
256
|
+
- **`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 bespoke** rather than going through `remote_stage_and_exec` (one side of the copy is a local path outside the compose file's working directory, which that helper has no concept of relaying): it stages `project_dir` the same way, then uses `_split_cp_arg` (mirroring `docker compose cp`'s own `splitCpArg`, verified against `docker/compose`'s `pkg/compose/cp.go`) to identify which of `source`/`dest` is the container reference. A local source is staged like `project_dir` (`stage_file`/`stage_tree`); a local destination gets a fresh path from `RemoteStagingSession.reserve_path()` for the remote `docker compose cp` to write into, fetched back via `RemoteStagingSession.fetch_path()` once the copy succeeds (`test -d` decides file vs. directory; a directory is packed remotely with `tar`, downloaded, and extracted locally with `tarfile`'s `filter="data"`, which is what actually stops an escaping member — the same size/count bounds `_enforce_stage_limits` applies to uploads apply to a fetch, checked against the packed archive's `stat` size before download and its member count after). Because the real CLI always executes the copy, every parameter (`--all`, `--index`, `files`) carries over unchanged; the one gap with no remote equivalent is a container→host copy whose local destination already exists, which is refused rather than merged into or overwritten, since only this host knows that state. When neither or both sides look like `SERVICE:PATH`, nothing beyond `project_dir` is staged and the call passes through unchanged, so the real remote CLI's own validation error surfaces exactly as it would locally. `unix://`/`tcp://`+TLS with no local plugin are not covered (no shell to run the CLI on) and still raise via `require_plugin`, whose message now also names pointing the host at an `ssh://` endpoint via `DOCKER_MCP_SERVER_HOSTS` as an alternative to installing the plugin — a message shared by `compose`/`buildx`/`scout`, all of which support this fallback.
|
|
240
257
|
- **`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
258
|
- **`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
259
|
|
|
@@ -393,6 +410,42 @@ Do not assume any method exists because it sounds plausible. If you cannot confi
|
|
|
393
410
|
|
|
394
411
|
When the high-level SDK has no method for an operation (e.g. swarm node removal, service rollback), drop to the low-level **`APIClient` via `_get_client().api`** — its methods (`remove_node`, `update_service`, `inspect_service`, …) are documented at https://docker-py.readthedocs.io/en/stable/api.html and must be verified the same way. Prefer the high-level object API when it exists; reach for `client.api` only for the gaps.
|
|
395
412
|
|
|
413
|
+
**Verify against the source, not just the rendered docs, and treat "the method exists" as separate from "the method works."** The rendered docs show a method's docstring, not the URL it builds, so a method can be documented, importable, and still non-functional. `plugin_push` is the standing example: docker-py's `Plugin.push()` / `APIClient.push_plugin()` both POST to `/plugins/{name}/pull`, a route the Engine does not define (push is `POST /plugins/{name}/push`), so they 404 against every daemon — a copy-paste from the pull method present since 2017 and still in `main`, surviving because upstream has no test covering it. Where a *documented* method is provably broken, the fallback ladder is: (1) another public SDK path, (2) the correct endpoint through docker-py's private request helpers (`_url`/`_post`/`_raise_for_status`/`_stream_helper`), resolved via `getattr` and guarded so a missing helper raises an actionable message rather than an `AttributeError` — the same treatment as `system_logout`'s `api._auth_configs` reach-in and `stage_build_context`'s use of docker-py's `tar`/`exclude_paths`, (3) a CLI shell-out, if the domain is already CLI-backed. Each such reach-in must name the bug and the escape hatch in the tool's docstring, so it can be removed when upstream fixes it.
|
|
414
|
+
|
|
415
|
+
**Rungs (2) and (3) are not an agent's call to make.** Rung (1) is ordinary work; anything below it leaves the supported surface, so an agent — a routine, or anyone implementing from an audit issue — must **stop, write up what it found and why the public path fails, and escalate for a human decision** rather than implementing it. This is not a formality: it is how `plugin_push` was actually settled. The draft-PR routine hit the broken method, declined to reach past the public SDK, shipped the rest, and *documented the omission*; that write-up is what prompted the investigation that found the real endpoint and the human judgement to take it. A routine that had "helpfully" hand-rolled the call instead would have made a trust decision nobody asked it to make, and one that silently dropped the candidate would have buried it. **Document and escalate is the correct behavior, not a failure to finish the job** — a parked candidate with a clear rationale is a better outcome than an autonomous workaround. Sign-off is needed once, when the reach-in is introduced: the ones listed here are already blessed, so touching or refactoring them later needs no fresh approval.
|
|
416
|
+
|
|
417
|
+
**Confirm the real route from the Engine API spec (`moby/moby`'s `api/swagger.yaml`) before writing a hand-built path, and note which kind of bet it is** — the two are not equivalent risks:
|
|
418
|
+
|
|
419
|
+
- **A published endpoint reached through private client plumbing** (what `plugin_push` does): `POST /plugins/{name}/push` is in the Engine API spec and is what `docker plugin push` itself calls, so the *contract* is stable and unlikely to move; only docker-py's `_url`/`_post` internals are unofficial, which is what the `getattr` guard covers. This is the acceptable shape.
|
|
420
|
+
- **An endpoint that is not in the spec at all** is a different proposition — no compatibility promise, no deprecation cycle, nothing to pin the behavior. Do not use one, even guarded, without explicit human sign-off recorded in the PR; never on an agent's own initiative.
|
|
421
|
+
|
|
422
|
+
### SDK audit exclusions (deliberate non-candidates)
|
|
423
|
+
|
|
424
|
+
A recurring cloud routine audits the docker-py surface for coverage gaps and for low-level
|
|
425
|
+
`client.api.*` calls a high-level method could replace. The decisions below were made once, on the
|
|
426
|
+
merits, and are **not** to be re-proposed — a periodic audit has no memory of last time, so without
|
|
427
|
+
this list it re-files the same rejected candidates forever. Removing an entry is a real decision;
|
|
428
|
+
say why. **Anything deliberately not wrapped, or wrapped in an unobvious way, belongs here.**
|
|
429
|
+
|
|
430
|
+
- **`Plugin.push()` / `APIClient.push_plugin()`** — never migrate `plugin_push` onto these. They are
|
|
431
|
+
broken upstream (wrong URL, see above); our hand-built endpoint call is the working path, not
|
|
432
|
+
technical debt to be tidied away. Revisit only if upstream fixes the URL, at which point the
|
|
433
|
+
reach-in should be replaced by the public method.
|
|
434
|
+
- **`Container.attach` / `attach_socket` / `resize`** — real methods, deliberately unwrapped: they
|
|
435
|
+
open an interactive bidirectional stream/TTY, which does not fit a request/response tool call.
|
|
436
|
+
`container_exec` covers scripted one-shot execution.
|
|
437
|
+
- **`service_rollback`'s `api.inspect_service` + `api.update_service`** — stays low-level
|
|
438
|
+
permanently. The high-level `Service`/`ServiceCollection` expose no `rollback`.
|
|
439
|
+
- **`system_logout`'s `api._auth_configs`** — stays low-level permanently. There is no `logout`
|
|
440
|
+
anywhere in the SDK and no server-side session to end, so there is nothing to migrate to.
|
|
441
|
+
|
|
442
|
+
The audit must also **check the latest published docker-py, not the pinned one**: `uv.lock` is
|
|
443
|
+
routinely behind what `pyproject.toml`'s floor lets a fresh `uvx`/`pip install` resolve, so auditing
|
|
444
|
+
the installed tree alone misses whatever published users are already running. And it should flag
|
|
445
|
+
**deprecated** surface we still depend on, not only missing coverage — e.g. `image_prune_builds`'s
|
|
446
|
+
`keep_storage`, which the Engine renamed `reserved-space` at API v1.48 — so a migration happens on
|
|
447
|
+
our schedule rather than when removal breaks us.
|
|
448
|
+
|
|
396
449
|
Docker SDK docs: https://docker-py.readthedocs.io/en/stable/index.html
|
|
397
450
|
Docker SDK low-level API: https://docker-py.readthedocs.io/en/stable/api.html
|
|
398
451
|
Docker SDK GitHub: https://github.com/docker/docker-py
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
More than just a fully featured [MCP](https://modelcontextprotocol.io) server that lets AI agents manage Docker — containers, images, networks, volumes, swarm services, secrets, configs, nodes, plugins, etc., it helps you create workflows to easily manage your Docker environments.
|
|
4
4
|
|
|
5
|
+
It gives you much more control and flexibility than calling the Docker CLI directly: each operation is exposed as its own typed tool, marked read-only or not, with destructive actions separately flagged. This means a client can auto-approve reads while always confirming anything destructive, and the whole server can also be switched into a read-only or no-destructive mode as a blanket safeguard. Output is bounded rather than left to grow unboundedly — capped with a `truncated` flag instead of silently overflowing the agent's context.
|
|
6
|
+
|
|
5
7
|
It can manage multiple Docker daemons, e.g. both your local dev environment and also a remote production environment over TCP, TLS or SSH in a single session. It can also be configured to mark some daemons as read-only, so that you can monitor them without the risk of making accidental changes. It also exposes things like logs and stats as resources so that you can easily monitor and triage your environments with a few prompts.
|
|
6
8
|
|
|
9
|
+
Documentation is built for the agent, not just the person configuring it: an MCP resource exposes the Docker SDK reference in-session (with a tool-callable fallback for clients that can't read resources), and a live tool-catalog resource reports exactly what's registered under the current configuration. Each tool's own description names its nearest siblings and when to prefer each, states preconditions and side effects in plain language, and is honest about when it can still fail — so an agent can pick the right tool on the first try among 150+ options, not guess.
|
|
10
|
+
|
|
7
11
|
The server runs entirely on your machine and sends no telemetry. You are entirely in control — see the [Privacy Policy](https://github.com/L337-org/docker-mcp#privacy-policy).
|
|
8
12
|
|
|
9
13
|
This image is the container distribution of the project. **Full documentation, configuration, and source are on GitHub: <https://github.com/L337-org/docker-mcp>.**
|