docker-mcp-server 2.2.2__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.2 → docker_mcp_server-2.2.3}/.github/copilot-instructions.md +9 -11
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/workflows/canary.yaml +14 -1
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/workflows/codeql.yaml +3 -3
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/workflows/publish.yaml +3 -3
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/CLAUDE.md +49 -18
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/DOCKERHUB.md +4 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/PKG-INFO +18 -4
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/README.md +16 -1
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/_hosts.py +27 -11
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/server.py +42 -13
- {docker_mcp_server-2.2.2 → 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.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/prompts.py +14 -11
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/resources.py +3 -3
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/system.py +38 -5
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/manifest.json +3 -3
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/pyproject.toml +9 -12
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_hosts.py +26 -0
- {docker_mcp_server-2.2.2 → 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.2 → docker_mcp_server-2.2.3}/tests/test_pyproject_pins.py +33 -37
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_resources.py +1 -1
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_server.py +103 -1
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_system.py +44 -2
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/uv.lock +47 -52
- docker_mcp_server-2.2.2/docker_mcp/tools/plugins.py +0 -154
- docker_mcp_server-2.2.2/tests/test_plugins.py +0 -90
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.claude/commands/docker-sdk.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.claude/settings.json +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.dockerignore +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/CODEOWNERS +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/actions/file-failure-issue/action.yaml +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/dependabot.yaml +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/release.yml +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/workflows/images.yaml +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/workflows/premerge.yaml +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.github/workflows/publish-homebrew.yaml +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.gitignore +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.mcpbignore +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/.python-version +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/CODE_OF_CONDUCT.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/CONTRIBUTING.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/Dockerfile +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/LICENSE +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/MIGRATION-2.0.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/PRIVACY.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/SECURITY.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/assets/README.md +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/assets/icon.png +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/__init__.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/__main__.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/_env.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/__init__.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/_cli.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/_labels.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/_ssh_proxy.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/_utils.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/buildx.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/compose.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/configs.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/containers.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/context.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/networks.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/nodes.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/registry.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/scout.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/secrets.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/services.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/stack.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/swarm.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/docker_mcp/tools/volumes.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/glama.json +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/mcpb_run.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/scripts/build-mcpb.sh +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/scripts/docker-mcp-server.rb.tpl +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/server.json +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/__init__.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/conftest.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/__init__.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/conftest.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_buildx.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_cli.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_compose.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_containers.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_context.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_file_payloads.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_networks.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_nodes.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_registry.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_remote_exec.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_scout.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_services.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_smoke.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/integration/test_stack.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_buildx.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_cli.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_compose.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_configs.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_containers.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_context.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_docs.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_env.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_labels.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_main.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_naming.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_networks.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_nodes.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_prompts.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_registry.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_remote_exec.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_scout.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_secrets.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_services.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_ssh_proxy.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_stack.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_swarm.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_utils.py +0 -0
- {docker_mcp_server-2.2.2 → docker_mcp_server-2.2.3}/tests/test_volumes.py +0 -0
|
@@ -34,10 +34,10 @@ from docker_mcp.server import mcp # resource modules only
|
|
|
34
34
|
|
|
35
35
|
`DOCKER_MCP_SERVER_HOSTS` lets one server manage several daemons in a session (e.g. local dev + remote prod). **When set, `DOCKER_HOST` is ignored** (a one-time stderr notice fires when both are set); unset = today's single-daemon behavior (`DOCKER_HOST`, else auto-discovery). The mcpb bundle exposes only this field. `_hosts.py` lives at the package root (like `_env.py`, so `server.py` can import it without pulling in `docker_mcp.tools`) and parses the var into a pinned `{label: Host}` registry — pure env + Docker-config-file reads, no docker-py/CLI calls:
|
|
36
36
|
|
|
37
|
-
- **Grammar.** No `=` in the value → bare single-host shorthand (`ssh://ops@prod(ro)`, `auto`, `local`, empty → `auto`); with `=` → comma-separated `label=endpoint` list. `endpoint` is the keyword `auto`/`local` or a `unix://`/`tcp://`/`ssh://`/`npipe://` URL, with combinable trailing markers `(ro)` (read-only) and `(tls=<dir>)` (a tcp+TLS cert dir; **`ca.pem` is required** — the daemon is always verified against it — and `cert.pem`+`key.pem` are optional, present together for mutual TLS or absent for verify-the-daemon-only, e.g. a self-signed daemon pinned via `ca.pem`). **Fail-fast** (`HostConfigError` → stderr + non-zero exit) on duplicate/empty/invalid labels, a missing `=`, an unknown marker, `(tls=)` on a non-tcp endpoint, a missing `ca.pem` or a lone `cert.pem`/`key.pem`, or an unrecognized scheme.
|
|
37
|
+
- **Grammar.** No `=` in the value → bare single-host shorthand (`ssh://ops@prod(ro)`, `auto`, `local`, empty → `auto`); with `=` → comma-separated `label=endpoint` list. `endpoint` is the keyword `auto`/`local` or a `unix://`/`tcp://`/`ssh://`/`npipe://` URL, with combinable trailing markers `(ro)` (read-only), `(nd)` (non-destructive — blocks only DESTRUCTIVE calls; `(ro)` already implies it, so combining is harmless but redundant) and `(tls=<dir>)` (a tcp+TLS cert dir; **`ca.pem` is required** — the daemon is always verified against it — and `cert.pem`+`key.pem` are optional, present together for mutual TLS or absent for verify-the-daemon-only, e.g. a self-signed daemon pinned via `ca.pem`). **Fail-fast** (`HostConfigError` → stderr + non-zero exit) on duplicate/empty/invalid labels, a missing `=`, an unknown marker, `(tls=)` on a non-tcp endpoint, a missing `ca.pem` or a lone `cert.pem`/`key.pem`, or an unrecognized scheme.
|
|
38
38
|
- **`auto`/`local`/`default` are resolved to concrete URLs by us and pinned at `load()` (startup)** so the docker-py SDK and the CLI shell-out target the *same* daemon for a label (auditable) and a mid-session `docker context use` can't silently move a label. `default` = the first registry entry = the omitted-`host` fallback, and is **not** a selectable label. `load()` runs in `docker_mcp/__init__.py` before the tools import (the `@tool()` decorator and resources read `is_multi()`/`labels()` at registration) and scrubs whole-value `${...}` placeholders first.
|
|
39
|
-
- **Per-call host selection (no modal active-host state).** Every daemon-targeting tool declares `host: str | None = None` and threads it to `_get_client(host)` / `run_docker(..., host=host)`. The `@tool()` decorator does **display-only schema surgery** (`_apply_host_schema`, like `_slim_schema`, gated on `_hosts.is_multi()`) — strip `host` in single-host mode (footprint-neutral), or constrain it to an `enum` of the labels and mark it required for writes in multi-host mode — and wraps the tool with `_enforce_host_guard` (multi-host: writes require an explicit `host`, unknown labels rejected, writes to an `(ro)` host refused; read-only tools and the `_CONNECTION_CONTROL` set `system_close`/`system_reconnect`/`system_login`/`system_logout` may omit `host`). The guard is wrapped on when `_host_guard_needed()` — multi-host **or a single host flagged `(ro)`**: a lone `(ro)` host has its `host` param stripped (footprint-neutral) but its
|
|
40
|
-
- **Client / CLI.** `system.py` keeps a lazy pool `_clients` keyed by label with tiered per-host TLS (`(tls=)` dir → global `DOCKER_CERT_PATH`/`DOCKER_TLS_VERIFY` → plaintext); the legacy single host still uses `_build_default_client`/`from_env` unchanged. **`system_reconnect(host=None)` is rebuild-only** — it can't retarget to an arbitrary URL (edit the registry + restart), closing a trust-expansion hole. `system_close(host=None)` closes all/one. `startup_preflight` pings the default host but detects self-id against the *self host* (first local-transport entry, which can differ from a remote default); `guard_not_self(container, host=)` only fires on the self host. `_cli.py:_apply_host_env` injects the resolved `DOCKER_HOST` + per-host TLS into the child env for an explicit host. Every `ssh://` URL is run through `_ensure_reachable_family(_ensure_ssh_port(url))` before being handed to docker-py: `_ensure_ssh_port` splices in a `~/.ssh/config` `Port` that `docker.utils.parse_host()` would otherwise hardcode to 22 before `SSHHTTPAdapter` ever sees it; `_ensure_reachable_family` works around paramiko's own connect() only retrying the next resolved address family on `ECONNREFUSED`/`EHOSTUNREACH` (not `ETIMEDOUT`, what a broken IPv6 route actually produces) by probe-connecting itself via `_ssh_proxy.connect_socket_with_family_fallback` and splicing the address that answered into the URL as a literal IP — no monkeypatching of docker-py internals. A URL that's already a literal address, or where every candidate fails, is left unchanged. When reviewing a change near ssh:// connection handling, check whether both `_ensure_ssh_port` and `_ensure_reachable_family` are still being composed at every call site that builds a docker-py client from a URL.
|
|
39
|
+
- **Per-call host selection (no modal active-host state).** Every daemon-targeting tool declares `host: str | None = None` and threads it to `_get_client(host)` / `run_docker(..., host=host)`. The `@tool()` decorator does **display-only schema surgery** (`_apply_host_schema`, like `_slim_schema`, gated on `_hosts.is_multi()`) — strip `host` in single-host mode (footprint-neutral), or constrain it to an `enum` of the labels and mark it required for writes in multi-host mode — and wraps the tool with `_enforce_host_guard` (multi-host: writes require an explicit `host`, unknown labels rejected, writes to an `(ro)` host refused, DESTRUCTIVE calls to an `(nd)` host refused; read-only tools and the `_CONNECTION_CONTROL` set `system_close`/`system_reconnect`/`system_login`/`system_logout` may omit `host`). The guard is wrapped on when `_host_guard_needed()` — multi-host **or a single host flagged `(ro)` or `(nd)`**: a lone `(ro)`/`(nd)` host has its `host` param stripped (footprint-neutral) but its refusals still apply, so the per-host markers are honored even in single-host mode (distinct from `DOCKER_MCP_SERVER_READONLY`/`_NO_DESTRUCTIVE`, which drop tools entirely). A host with both markers is refused by `(ro)` first — it's strictly stronger, so `(nd)` never fires for it. A single *unrestricted* host wires no guard. **Excluded** (no `host` param): `registry`/`hub_*` (HTTPS, no daemon) and `context` (manages the host's CLI contexts).
|
|
40
|
+
- **Client / CLI.** `system.py` keeps a lazy pool `_clients` keyed by label with tiered per-host TLS (`(tls=)` dir → global `DOCKER_CERT_PATH`/`DOCKER_TLS_VERIFY` → plaintext); the legacy single host still uses `_build_default_client`/`from_env` unchanged. **Every `from_env` call must pass `use_context=False`** — docker-py 7.2.0 (the declared floor, for this reason) made `from_env` resolve the active Docker CLI context when the environment yields no `base_url`, duplicating resolution `_hosts.py` already does and pins at `load()`; a new `from_env` call site without it is a review finding, as is relaxing the `docker[ssh]>=7.2.0` floor. **`system_reconnect(host=None)` is rebuild-only** — it can't retarget to an arbitrary URL (edit the registry + restart), closing a trust-expansion hole. `system_close(host=None)` closes all/one. `startup_preflight` pings the default host but detects self-id against the *self host* (first local-transport entry, which can differ from a remote default); `guard_not_self(container, host=)` only fires on the self host. `_cli.py:_apply_host_env` injects the resolved `DOCKER_HOST` + per-host TLS into the child env for an explicit host. Every `ssh://` URL is run through `_ensure_reachable_family(_ensure_ssh_port(url))` before being handed to docker-py: `_ensure_ssh_port` splices in a `~/.ssh/config` `Port` that `docker.utils.parse_host()` would otherwise hardcode to 22 before `SSHHTTPAdapter` ever sees it; `_ensure_reachable_family` works around paramiko's own connect() only retrying the next resolved address family on `ECONNREFUSED`/`EHOSTUNREACH` (not `ETIMEDOUT`, what a broken IPv6 route actually produces) by probe-connecting itself via `_ssh_proxy.connect_socket_with_family_fallback` and splicing the address that answered into the URL as a literal IP — no monkeypatching of docker-py internals. A URL that's already a literal address, or where every candidate fails, is left unchanged. When reviewing a change near ssh:// connection handling, check whether both `_ensure_ssh_port` and `_ensure_reachable_family` are still being composed at every call site that builds a docker-py client from a URL.
|
|
41
41
|
- **Surfaces.** `host_list` tool + `docker-mcp://hosts` resource expose the resolved registry; the router gains a multi-host caveat; the container observability resources go host-aware (empty-authority `docker:///…` for the default + `docker://{host}/…` variants); a `prompt(multi_host=True)` gate plus the `survey_hosts` prompt register only with 2+ hosts.
|
|
42
42
|
|
|
43
43
|
**When reviewing a PR that changes the host grammar, env precedence, or the per-tool/resource/prompt host surface, this section is the spec.**
|
|
@@ -127,7 +127,7 @@ All publishing runs through one workflow on each **published GitHub Release**: `
|
|
|
127
127
|
|
|
128
128
|
### Provenance labels
|
|
129
129
|
|
|
130
|
-
Resources this server **creates** are stamped with `docker-mcp-server.*` provenance labels (`.managed=true`, `.version`, `.tool`, `.created`) so the agent/operator can later enumerate that footprint (the `managed_only=True` arg on `container_list` / `network_list` / `volume_list` / `service_list`, or `--filter label=docker-mcp-server.managed=true`; the `prune_managed` prompt removes only the managed footprint). On by default; opt out with `DOCKER_MCP_SERVER_NO_LABELS=1`. When adding a new create tool that accepts a `labels` dict, route it through `docker_mcp/tools/_labels.py:with_provenance(labels, "<tool_name>")` — it merges provenance without overwriting caller keys and returns `None` (drop it via `drop_none`) when stamping is disabled and the caller passed nothing. **Image builds are intentionally not stamped** (a build label changes the image digest).
|
|
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.
|
|
131
131
|
|
|
132
132
|
### CLI shell-out policy
|
|
133
133
|
|
|
@@ -262,16 +262,14 @@ Always verify the exact method name, parameter names, and return type at https:/
|
|
|
262
262
|
|
|
263
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.
|
|
264
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
|
+
|
|
265
269
|
Docker SDK docs: https://docker-py.readthedocs.io/en/stable/index.html
|
|
266
270
|
Docker SDK low-level API: https://docker-py.readthedocs.io/en/stable/api.html
|
|
267
271
|
Docker SDK GitHub: https://github.com/docker/docker-py
|
|
268
272
|
|
|
269
273
|
### Review Notes
|
|
270
274
|
|
|
271
|
-
There
|
|
272
|
-
Changing this line to a later version of cryptography can break installs on Intel (x86_64) macOS, which premerge CI does not cover (the canary workflow checks wheels-only resolution for x86_64 macOS — weekly and on PRs into main — and the pin guard in tests/test_pyproject_pins.py is the merge-time enforcement).
|
|
273
|
-
From version 49, cryptography no longer ships a universal2 macOS wheel; on Intel macOS the resolver typically falls back to a source build that requires a newer Rust toolchain and may fail.
|
|
274
|
-
Changing to a later version of cryptography for other platforms is fine, but this pin should remain for macOS x86_64.
|
|
275
|
-
Do not change this line. If a PR touches this line, please add a strong comment and do not allow the change to be merged without a strong justification and approval from the repo owner.
|
|
276
|
-
|
|
277
|
-
There used to also be a major-version cap on `mcp` (`"mcp>=1.27.1,<2"`), a hotfix for mcp 2.0.0 (2026-07-28) removing `mcp.server.fastmcp`, which `server.py` imported `FastMCP` from — premerge CI could not see this because it installs `--locked`, so the lockfile's 1.x kept every test green while a *fresh* resolve broke at import (the published 2.2.0 shipped uncapped and `uvx docker-mcp-server` failed at import on a clean machine; 2.2.1 was the hotfix). `server.py` has since been ported to `mcp.server.mcpserver.MCPServer` and the cap removed; `mcp` is now unpinned above `2.0.0`. Rather than a version cap, `tests/test_pyproject_pins.py::test_the_declared_mcp_bound_matches_what_the_code_imports` asserts the module `server.py` imports its server class from is one the installed mcp actually provides — a permanent guard against a future mcp release removing that import path, with no cap to remember to add first. If a PR touches this import or that test, check the change is not silently narrowing that guard.
|
|
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
|
|
@@ -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 }}
|
|
@@ -55,20 +55,15 @@ non-required `Check docs mirror` job flags a PR that edits `CLAUDE.md` or
|
|
|
55
55
|
`.github/copilot-instructions.md` without the other (see the MIRROR RULE above) — it's a prompt to
|
|
56
56
|
double-check, not a merge blocker.
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
the cap lifted. Rather than re-adding a cap for the next major (there is no known 3.x
|
|
68
|
-
incompatibility to guard against), `tests/test_pyproject_pins.py::
|
|
69
|
-
test_the_declared_mcp_bound_matches_what_the_code_imports` is a living guard: it fails whenever
|
|
70
|
-
the installed mcp stops providing the import path `server.py` actually uses, with no reliance on
|
|
71
|
-
remembering to add a cap first.
|
|
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.
|
|
72
67
|
|
|
73
68
|
**A required `Check fresh resolve still imports` job** (`premerge.yaml`) closes the blind spot that
|
|
74
69
|
let the 2.2.0 incident through: every job above installs with `uv sync --locked`, so none of them
|
|
@@ -127,15 +122,15 @@ A handful of tools have **no domain at all** — `_NO_DOMAIN_TOOLS` (today just
|
|
|
127
122
|
|
|
128
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:
|
|
129
124
|
|
|
130
|
-
- **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.
|
|
131
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`.
|
|
132
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).
|
|
133
128
|
|
|
134
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()`):
|
|
135
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.
|
|
136
|
-
- **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).
|
|
137
132
|
|
|
138
|
-
**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.
|
|
139
134
|
|
|
140
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))`:
|
|
141
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.
|
|
@@ -226,7 +221,7 @@ All publishing runs through one workflow on each **published GitHub Release** (n
|
|
|
226
221
|
|
|
227
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`.
|
|
228
223
|
|
|
229
|
-
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`.
|
|
230
225
|
|
|
231
226
|
## CLI shell-out policy
|
|
232
227
|
|
|
@@ -415,6 +410,42 @@ Do not assume any method exists because it sounds plausible. If you cannot confi
|
|
|
415
410
|
|
|
416
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.
|
|
417
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
|
+
|
|
418
449
|
Docker SDK docs: https://docker-py.readthedocs.io/en/stable/index.html
|
|
419
450
|
Docker SDK low-level API: https://docker-py.readthedocs.io/en/stable/api.html
|
|
420
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>.**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docker-mcp-server
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: MCP server for managing Docker resources via the Docker SDK for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/L337-org/docker-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/L337-org/docker-mcp
|
|
@@ -19,8 +19,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries
|
|
20
20
|
Classifier: Topic :: System :: Systems Administration
|
|
21
21
|
Requires-Python: >=3.14
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: docker[ssh]>=7.1.0
|
|
22
|
+
Requires-Dist: docker[ssh]>=7.2.0
|
|
24
23
|
Requires-Dist: httpx>=0.28.1
|
|
25
24
|
Requires-Dist: mcp>=2.0.0
|
|
26
25
|
Requires-Dist: requests>=2.26.0
|
|
@@ -36,12 +35,16 @@ Description-Content-Type: text/markdown
|
|
|
36
35
|
|
|
37
36
|
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.
|
|
38
37
|
|
|
38
|
+
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.
|
|
39
|
+
|
|
39
40
|
For simple cases, you can just install and go with no configuration required - once loaded it will discover your local Docker socket and expose the full command surface to your AI agent. For more advanced users it can [manage multiple Docker daemons](#managing-several-daemons), e.g. both your local dev environment and also a remote production environment [over TCP, TLS or SSH](#talking-to-a-remote-daemon) in a single session. It can also be configured to mark some daemons as read-only, so you can monitor them without the risk of making accidental changes.
|
|
40
41
|
|
|
41
42
|
It can even be run on a machine [without Docker installed](#no-local-docker) and manage remote daemons over SSH, TLS or TCP (some features require SSH). The AI itself does not require shell or SSH access.
|
|
42
43
|
|
|
43
44
|
The MCP server also exposes things like logs and stats as resources so that you can monitor and triage, enabling you to [answer questions](#example-prompts) like 'why did my container crash?', 'what is the state of my swarm?', 'am I suffering memory pressure?', 'what is the disk usage of my volumes?', 'what differences are there between my test and production systems?', and more...
|
|
44
45
|
|
|
46
|
+
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.
|
|
47
|
+
|
|
45
48
|
docker-mcp-server is optimized to work efficiently with the new generation of MCP clients that support lazy tool loading. For clients that still eagerly load all tools, the server can optionally be configured to exclude tools from a subset of domains (e.g. exclude 'swarm' and 'scout' tools) to reduce the tool list size. It's also possible to put the MCP server into 'read-only' or 'no-destructive' modes that prevent any tools with write or destructive capabilities from being registered, which again reduces the footprint.
|
|
46
49
|
|
|
47
50
|
The server runs entirely on your machine, either [natively](#using-the-server), as an [mcpb bundle](#install-as-a-desktop-extension-mcpb), or [containerized](#run-as-a-container), and sends no telemetry. You are entirely in control — see the [Privacy Policy](#privacy-policy).
|
|
@@ -53,6 +56,13 @@ Note: If you're using the containerized MCP server or MCPB bundle, the Python an
|
|
|
53
56
|
- A running Docker daemon reachable from the host that runs the server (the standard `DOCKER_HOST` / unix socket conventions apply)
|
|
54
57
|
- [Python ≥ 3.14](https://www.python.org/downloads/)
|
|
55
58
|
- [uv](https://docs.astral.sh/uv/) for dependency management
|
|
59
|
+
- **Intel (x86_64) macOS only:** installing natively (via `uvx`/`pip`, or the `.mcpb` bundle, both of
|
|
60
|
+
which resolve dependencies locally) requires [Rust](https://rustup.rs/) and OpenSSL 3.x, because
|
|
61
|
+
`cryptography` — a transitive dependency, via `mcp` → `pyjwt[crypto]` — has shipped no x86_64
|
|
62
|
+
macOS wheel since version 49.0.0 and must be built from source there. If you'd rather not install
|
|
63
|
+
a build toolchain, use the [container image](#run-as-a-container) instead — it runs the same
|
|
64
|
+
prebuilt Linux binary regardless of your Mac's CPU architecture, so this doesn't apply to it. See
|
|
65
|
+
[Security considerations](#security-considerations) for more.
|
|
56
66
|
|
|
57
67
|
## Using the server
|
|
58
68
|
|
|
@@ -245,6 +255,7 @@ Everything above targets one daemon. To manage **several in a single session**
|
|
|
245
255
|
|
|
246
256
|
- **`endpoint`** is `auto` (your default context/socket, as [above](#talking-to-a-remote-daemon)), `local` (the platform-local socket, ignoring contexts), or a `unix://` / `tcp://` / `ssh://` / `npipe://` URL. `ssh://` is the recommended remote transport (per-host auth via your SSH keys, no TLS cert plumbing). A `tcp://` daemon over TLS takes a `(tls=<dir>)` marker pointing at a cert directory, e.g. `prod=tcp://prod:2376(tls=/etc/docker/prod)`. That directory must hold **`ca.pem`** (the daemon is always verified against it — so a self-signed daemon works, you just pin its cert here); add **`cert.pem`** and **`key.pem`** only if the daemon requires a client certificate (mutual TLS). There is no unverified-TLS mode — a TLS connection always authenticates the daemon, so encryption never comes without verification.
|
|
247
257
|
- **`(ro)`** after an endpoint marks that host **read-only**: mutating and destructive tools refuse to act on it. This is a per-host guard enforced at call time, independent of the server-wide `DOCKER_MCP_SERVER_READONLY` switch — mark production `(ro)` and the agent can inspect it all day but can't change it, while local stays read-write.
|
|
258
|
+
- **`(nd)`** marks a host **non-destructive**: only destructive tools (removals, prunes, kills) refuse to act on it — reads and ordinary mutations (start/stop/create) still work. This mirrors the server-wide `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` switch, per host. `(ro)` already implies it, so `(ro)(nd)` combines without error but is redundant.
|
|
248
259
|
- **Single daemon, simpler form.** A bare value with no `name=` is shorthand for one host — `DOCKER_MCP_SERVER_HOSTS=ssh://ops@prod` (or `auto`, or blank). So this one field also covers the single-remote case. `DOCKER_HOST` keeps working when `DOCKER_MCP_SERVER_HOSTS` is unset, but **`DOCKER_MCP_SERVER_HOSTS` takes over when set** (`DOCKER_HOST` is then ignored, with a one-time notice to stderr).
|
|
249
260
|
|
|
250
261
|
**How the agent drives it.** With two or more hosts, every daemon-targeting tool gains an optional **`host`** argument constrained to your configured names: read-only tools default to the first host when you omit it, while mutating and destructive tools **require** an explicit `host` (so the agent can't change the wrong daemon by accident). `host_list` (and the `docker-mcp://hosts` resource) report the configured hosts and which is the default; the container/service/node observability resources become host-aware — the **default** host's index is `docker:///containers` (note the empty authority) and a **named** host's is `docker://{host}/containers` (likewise `docker-logs:///{id}` vs `docker-logs://{host}/{id}`, and the same pattern for `docker://services`/`service-logs://`/`service-tasks://` and `docker://nodes`); the single-host bare forms (`docker://containers`, `docker://services`, `docker://nodes`, …) are not registered once several hosts are configured. The `survey_hosts` prompt sweeps every host read-only. The `auto`/`local` endpoints are resolved to concrete URLs and **pinned at startup**, so the SDK and CLI always agree on which daemon a name means — restart to re-resolve after changing a Docker context.
|
|
@@ -254,7 +265,8 @@ Everything above targets one daemon. To manage **several in a single session**
|
|
|
254
265
|
Once loaded, the agent gets MCP tools grouped by Docker domain. A few examples:
|
|
255
266
|
|
|
256
267
|
- **Containers** — `container_run`, `container_list` (`managed_only=True` to list only what this server created — see [Provenance labels](#provenance-labels)), `container_exec`, `container_logs`, `container_stop`, `container_commit`, `container_wait` (block until exit, `until="healthy"` to poll a healthcheck, or `until="log-match"` to poll for a log line containing `pattern`), `container_export` / `container_archive_get_to_file` / `container_archive_put` (stream tar archives to/from a host path)
|
|
257
|
-
- **Images** — `image_build`, `image_pull`, `image_push`, `image_tag`, `image_prune`, `image_save` / `image_load` (stream image tarballs to/from a host path via `dest_path` / `from_file`)
|
|
268
|
+
- **Images** — `image_build`, `image_pull`, `image_push`, `image_tag`, `image_prune`, `image_prune_builds` (clear the daemon's build cache — a separate resource from dangling images), `image_save` / `image_load` (stream image tarballs to/from a host path via `dest_path` / `from_file`)
|
|
269
|
+
- **Plugins** — `plugin_install` (pull from a registry), `plugin_create` (build one from a local `config.json` + `rootfs`), `plugin_push` (publish it back), `plugin_enable` / `plugin_disable`, `plugin_configure`, `plugin_upgrade`, `plugin_list` / `plugin_inspect`, `plugin_remove` *(managed engine plugins — volume/network/logging drivers — not the CLI plugins that extend the `docker` command itself)*
|
|
258
270
|
- **Networks / Volumes** — `network_create`, `network_connect`, `volume_create`, `volume_prune`
|
|
259
271
|
- **Swarm** — `swarm_init`, `swarm_join_tokens` (close the init → join loop), `swarm_update` (rotate join tokens / unlock key), `service_create`, `service_scale`, `service_rollback` (re-apply the previous service spec), `service_wait` (block until tasks converge, or a rolling update completes), `node_list`, `node_wait` (block until a node reaches a target state — e.g. `ready` after joining), `node_remove`, `secret_create`, `config_create`
|
|
260
272
|
- **System** — `system_ping`, `system_info`, `system_version`, `system_df`, `system_events`, `host_list` (the configured daemons and which is the default — see [Managing several daemons](#managing-several-daemons)), `system_login` / `system_logout` (cache or clear registry credentials), `system_reconnect` (rebuild a host's SDK client to recover a wedged connection)
|
|
@@ -419,6 +431,8 @@ Connecting this server to an AI agent grants it the same level of access as a lo
|
|
|
419
431
|
- **CLI shell-out attack surface.** Compose, Stack, Buildx, Scout, and Context tools spawn `docker` subprocesses on the host running this MCP server. Every invocation passes arguments as a list (no shell, no metacharacter interpretation), resolves the binary via `shutil.which`, and runs against a scrubbed environment (DOCKER_HOST and related vars only). Positional values (image refs, service / context / builder names, build contexts, bake targets) are additionally rejected if they start with `-`, so an argument can't be smuggled in as a CLI flag (e.g. a service named `--output=…`); the one deliberate exception is the trailing command in `compose_exec` / `compose_run`, which is meant to be an arbitrary argv. Filesystem paths supplied to `compose_*` (project_dir, files) are read by the docker CLI on the server host — passing an unfamiliar path can expose any compose file the server's user can read. **With no local docker CLI and an `ssh://` target** those subprocesses run on the remote host instead ([above](#talking-to-a-remote-daemon)), which shifts two things: the command executes as the remote SSH user with *its* registry credentials, and the files a command reads are copied to that host's temp directory first — so a `buildx_build` `--secret src=` file, or anything else in a staged directory, exists briefly on the remote disk (mode `0700`, removed when the call returns; only a dropped connection can leave it behind). Point staging-backed tools at directories you would be content to copy.
|
|
420
432
|
- **The daemon set is fixed at startup; pick it deliberately.** When `DOCKER_HOST` / `DOCKER_MCP_SERVER_HOSTS` are unset, the server's *initial* SDK connection follows your active Docker context (`DOCKER_CONTEXT` / `currentContext`) — the same daemon your `docker` CLI targets — so if that context points at a remote or production daemon, the agent connects there too. Set `DOCKER_MCP_SERVER_HOSTS` (or `DOCKER_HOST`, or select a scoped context) before starting the server to pin the target(s) deliberately; with `DOCKER_MCP_SERVER_HOSTS` the `auto`/`local` endpoints are resolved and **pinned at startup**, so they can't drift if a context changes later. After startup, `context_use` only changes the CLI default for subsequent CLI-backed tools; SDK-backed tools keep using the daemon their pooled client connected to. **There is no runtime way to introduce or retarget a daemon at an arbitrary endpoint** — `system_reconnect` only *rebuilds* an already-configured host's client (to recover a wedged connection), it can't point it elsewhere; to add or change a daemon, edit `DOCKER_MCP_SERVER_HOSTS` and restart. This deliberately closes a trust-expansion vector (an agent can't move the root-equivalent boundary to an unvetted endpoint mid-session). `context_create(skip_tls_verify=True)` disables TLS verification for a context; use only against trusted local daemons.
|
|
421
433
|
- **Per-host read-only is an accident guard, not a security boundary.** A host marked `(ro)` in `DOCKER_MCP_SERVER_HOSTS` makes mutating/destructive tools refuse to act on it at call time (and, with several hosts, writes require naming the target host explicitly — so the agent can't change the wrong daemon by omission). Like `guard_not_self`, this is in-process convenience: it constrains the agent through this server's tools, but the daemon itself is still the trust boundary, so for a host the agent must never modify, prefer pointing it at a genuinely read-only or scoped daemon over relying on the marker alone.
|
|
434
|
+
- **Per-host non-destructive is the same accident guard, scoped narrower.** A host marked `(nd)` refuses only destructive tool calls (kills, removals, prunes) while reads and ordinary mutations still go through. Like `(ro)`, this is in-process convenience enforced by this server's guard, not a daemon-level boundary.
|
|
435
|
+
- **`cryptography` (a transitive dependency, via `mcp` → `pyjwt[crypto]`) has shipped no x86_64/universal2 macOS wheel since 49.0.0 — confirmed permanent, not transient — so a native install there (`uvx`, `pip`, or the `.mcpb` bundle) resolves to a current version and builds it from source, needing [Rust](https://rustup.rs/) and OpenSSL 3.x.** This project briefly tried to avoid that build step with its own `cryptography<49` cap scoped to Intel macOS — that cap turned out to hold back *every* platform, not just the one it named (a platform-scoped upper bound alone doesn't make `uv lock` fork the resolution), so it silently left everyone on a version within a later high-severity CVE's range (a PKCS#7 EnvelopedData decryption Bleichenbacher oracle). The cap was removed rather than replaced: this project doesn't decrypt PKCS#7 envelopes itself, but a dependency carrying a known-exploitable flaw is not something to ship knowingly just to keep one platform wheel-only. If you'd rather not install a build toolchain, use the [container image](#run-as-a-container) instead (built centrally on Linux, so it never hits this).
|
|
422
436
|
|
|
423
437
|
## Packages and listings
|
|
424
438
|
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
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.
|
|
10
10
|
|
|
11
|
+
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.
|
|
12
|
+
|
|
11
13
|
For simple cases, you can just install and go with no configuration required - once loaded it will discover your local Docker socket and expose the full command surface to your AI agent. For more advanced users it can [manage multiple Docker daemons](#managing-several-daemons), e.g. both your local dev environment and also a remote production environment [over TCP, TLS or SSH](#talking-to-a-remote-daemon) in a single session. It can also be configured to mark some daemons as read-only, so you can monitor them without the risk of making accidental changes.
|
|
12
14
|
|
|
13
15
|
It can even be run on a machine [without Docker installed](#no-local-docker) and manage remote daemons over SSH, TLS or TCP (some features require SSH). The AI itself does not require shell or SSH access.
|
|
14
16
|
|
|
15
17
|
The MCP server also exposes things like logs and stats as resources so that you can monitor and triage, enabling you to [answer questions](#example-prompts) like 'why did my container crash?', 'what is the state of my swarm?', 'am I suffering memory pressure?', 'what is the disk usage of my volumes?', 'what differences are there between my test and production systems?', and more...
|
|
16
18
|
|
|
19
|
+
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.
|
|
20
|
+
|
|
17
21
|
docker-mcp-server is optimized to work efficiently with the new generation of MCP clients that support lazy tool loading. For clients that still eagerly load all tools, the server can optionally be configured to exclude tools from a subset of domains (e.g. exclude 'swarm' and 'scout' tools) to reduce the tool list size. It's also possible to put the MCP server into 'read-only' or 'no-destructive' modes that prevent any tools with write or destructive capabilities from being registered, which again reduces the footprint.
|
|
18
22
|
|
|
19
23
|
The server runs entirely on your machine, either [natively](#using-the-server), as an [mcpb bundle](#install-as-a-desktop-extension-mcpb), or [containerized](#run-as-a-container), and sends no telemetry. You are entirely in control — see the [Privacy Policy](#privacy-policy).
|
|
@@ -25,6 +29,13 @@ Note: If you're using the containerized MCP server or MCPB bundle, the Python an
|
|
|
25
29
|
- A running Docker daemon reachable from the host that runs the server (the standard `DOCKER_HOST` / unix socket conventions apply)
|
|
26
30
|
- [Python ≥ 3.14](https://www.python.org/downloads/)
|
|
27
31
|
- [uv](https://docs.astral.sh/uv/) for dependency management
|
|
32
|
+
- **Intel (x86_64) macOS only:** installing natively (via `uvx`/`pip`, or the `.mcpb` bundle, both of
|
|
33
|
+
which resolve dependencies locally) requires [Rust](https://rustup.rs/) and OpenSSL 3.x, because
|
|
34
|
+
`cryptography` — a transitive dependency, via `mcp` → `pyjwt[crypto]` — has shipped no x86_64
|
|
35
|
+
macOS wheel since version 49.0.0 and must be built from source there. If you'd rather not install
|
|
36
|
+
a build toolchain, use the [container image](#run-as-a-container) instead — it runs the same
|
|
37
|
+
prebuilt Linux binary regardless of your Mac's CPU architecture, so this doesn't apply to it. See
|
|
38
|
+
[Security considerations](#security-considerations) for more.
|
|
28
39
|
|
|
29
40
|
## Using the server
|
|
30
41
|
|
|
@@ -217,6 +228,7 @@ Everything above targets one daemon. To manage **several in a single session**
|
|
|
217
228
|
|
|
218
229
|
- **`endpoint`** is `auto` (your default context/socket, as [above](#talking-to-a-remote-daemon)), `local` (the platform-local socket, ignoring contexts), or a `unix://` / `tcp://` / `ssh://` / `npipe://` URL. `ssh://` is the recommended remote transport (per-host auth via your SSH keys, no TLS cert plumbing). A `tcp://` daemon over TLS takes a `(tls=<dir>)` marker pointing at a cert directory, e.g. `prod=tcp://prod:2376(tls=/etc/docker/prod)`. That directory must hold **`ca.pem`** (the daemon is always verified against it — so a self-signed daemon works, you just pin its cert here); add **`cert.pem`** and **`key.pem`** only if the daemon requires a client certificate (mutual TLS). There is no unverified-TLS mode — a TLS connection always authenticates the daemon, so encryption never comes without verification.
|
|
219
230
|
- **`(ro)`** after an endpoint marks that host **read-only**: mutating and destructive tools refuse to act on it. This is a per-host guard enforced at call time, independent of the server-wide `DOCKER_MCP_SERVER_READONLY` switch — mark production `(ro)` and the agent can inspect it all day but can't change it, while local stays read-write.
|
|
231
|
+
- **`(nd)`** marks a host **non-destructive**: only destructive tools (removals, prunes, kills) refuse to act on it — reads and ordinary mutations (start/stop/create) still work. This mirrors the server-wide `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` switch, per host. `(ro)` already implies it, so `(ro)(nd)` combines without error but is redundant.
|
|
220
232
|
- **Single daemon, simpler form.** A bare value with no `name=` is shorthand for one host — `DOCKER_MCP_SERVER_HOSTS=ssh://ops@prod` (or `auto`, or blank). So this one field also covers the single-remote case. `DOCKER_HOST` keeps working when `DOCKER_MCP_SERVER_HOSTS` is unset, but **`DOCKER_MCP_SERVER_HOSTS` takes over when set** (`DOCKER_HOST` is then ignored, with a one-time notice to stderr).
|
|
221
233
|
|
|
222
234
|
**How the agent drives it.** With two or more hosts, every daemon-targeting tool gains an optional **`host`** argument constrained to your configured names: read-only tools default to the first host when you omit it, while mutating and destructive tools **require** an explicit `host` (so the agent can't change the wrong daemon by accident). `host_list` (and the `docker-mcp://hosts` resource) report the configured hosts and which is the default; the container/service/node observability resources become host-aware — the **default** host's index is `docker:///containers` (note the empty authority) and a **named** host's is `docker://{host}/containers` (likewise `docker-logs:///{id}` vs `docker-logs://{host}/{id}`, and the same pattern for `docker://services`/`service-logs://`/`service-tasks://` and `docker://nodes`); the single-host bare forms (`docker://containers`, `docker://services`, `docker://nodes`, …) are not registered once several hosts are configured. The `survey_hosts` prompt sweeps every host read-only. The `auto`/`local` endpoints are resolved to concrete URLs and **pinned at startup**, so the SDK and CLI always agree on which daemon a name means — restart to re-resolve after changing a Docker context.
|
|
@@ -226,7 +238,8 @@ Everything above targets one daemon. To manage **several in a single session**
|
|
|
226
238
|
Once loaded, the agent gets MCP tools grouped by Docker domain. A few examples:
|
|
227
239
|
|
|
228
240
|
- **Containers** — `container_run`, `container_list` (`managed_only=True` to list only what this server created — see [Provenance labels](#provenance-labels)), `container_exec`, `container_logs`, `container_stop`, `container_commit`, `container_wait` (block until exit, `until="healthy"` to poll a healthcheck, or `until="log-match"` to poll for a log line containing `pattern`), `container_export` / `container_archive_get_to_file` / `container_archive_put` (stream tar archives to/from a host path)
|
|
229
|
-
- **Images** — `image_build`, `image_pull`, `image_push`, `image_tag`, `image_prune`, `image_save` / `image_load` (stream image tarballs to/from a host path via `dest_path` / `from_file`)
|
|
241
|
+
- **Images** — `image_build`, `image_pull`, `image_push`, `image_tag`, `image_prune`, `image_prune_builds` (clear the daemon's build cache — a separate resource from dangling images), `image_save` / `image_load` (stream image tarballs to/from a host path via `dest_path` / `from_file`)
|
|
242
|
+
- **Plugins** — `plugin_install` (pull from a registry), `plugin_create` (build one from a local `config.json` + `rootfs`), `plugin_push` (publish it back), `plugin_enable` / `plugin_disable`, `plugin_configure`, `plugin_upgrade`, `plugin_list` / `plugin_inspect`, `plugin_remove` *(managed engine plugins — volume/network/logging drivers — not the CLI plugins that extend the `docker` command itself)*
|
|
230
243
|
- **Networks / Volumes** — `network_create`, `network_connect`, `volume_create`, `volume_prune`
|
|
231
244
|
- **Swarm** — `swarm_init`, `swarm_join_tokens` (close the init → join loop), `swarm_update` (rotate join tokens / unlock key), `service_create`, `service_scale`, `service_rollback` (re-apply the previous service spec), `service_wait` (block until tasks converge, or a rolling update completes), `node_list`, `node_wait` (block until a node reaches a target state — e.g. `ready` after joining), `node_remove`, `secret_create`, `config_create`
|
|
232
245
|
- **System** — `system_ping`, `system_info`, `system_version`, `system_df`, `system_events`, `host_list` (the configured daemons and which is the default — see [Managing several daemons](#managing-several-daemons)), `system_login` / `system_logout` (cache or clear registry credentials), `system_reconnect` (rebuild a host's SDK client to recover a wedged connection)
|
|
@@ -391,6 +404,8 @@ Connecting this server to an AI agent grants it the same level of access as a lo
|
|
|
391
404
|
- **CLI shell-out attack surface.** Compose, Stack, Buildx, Scout, and Context tools spawn `docker` subprocesses on the host running this MCP server. Every invocation passes arguments as a list (no shell, no metacharacter interpretation), resolves the binary via `shutil.which`, and runs against a scrubbed environment (DOCKER_HOST and related vars only). Positional values (image refs, service / context / builder names, build contexts, bake targets) are additionally rejected if they start with `-`, so an argument can't be smuggled in as a CLI flag (e.g. a service named `--output=…`); the one deliberate exception is the trailing command in `compose_exec` / `compose_run`, which is meant to be an arbitrary argv. Filesystem paths supplied to `compose_*` (project_dir, files) are read by the docker CLI on the server host — passing an unfamiliar path can expose any compose file the server's user can read. **With no local docker CLI and an `ssh://` target** those subprocesses run on the remote host instead ([above](#talking-to-a-remote-daemon)), which shifts two things: the command executes as the remote SSH user with *its* registry credentials, and the files a command reads are copied to that host's temp directory first — so a `buildx_build` `--secret src=` file, or anything else in a staged directory, exists briefly on the remote disk (mode `0700`, removed when the call returns; only a dropped connection can leave it behind). Point staging-backed tools at directories you would be content to copy.
|
|
392
405
|
- **The daemon set is fixed at startup; pick it deliberately.** When `DOCKER_HOST` / `DOCKER_MCP_SERVER_HOSTS` are unset, the server's *initial* SDK connection follows your active Docker context (`DOCKER_CONTEXT` / `currentContext`) — the same daemon your `docker` CLI targets — so if that context points at a remote or production daemon, the agent connects there too. Set `DOCKER_MCP_SERVER_HOSTS` (or `DOCKER_HOST`, or select a scoped context) before starting the server to pin the target(s) deliberately; with `DOCKER_MCP_SERVER_HOSTS` the `auto`/`local` endpoints are resolved and **pinned at startup**, so they can't drift if a context changes later. After startup, `context_use` only changes the CLI default for subsequent CLI-backed tools; SDK-backed tools keep using the daemon their pooled client connected to. **There is no runtime way to introduce or retarget a daemon at an arbitrary endpoint** — `system_reconnect` only *rebuilds* an already-configured host's client (to recover a wedged connection), it can't point it elsewhere; to add or change a daemon, edit `DOCKER_MCP_SERVER_HOSTS` and restart. This deliberately closes a trust-expansion vector (an agent can't move the root-equivalent boundary to an unvetted endpoint mid-session). `context_create(skip_tls_verify=True)` disables TLS verification for a context; use only against trusted local daemons.
|
|
393
406
|
- **Per-host read-only is an accident guard, not a security boundary.** A host marked `(ro)` in `DOCKER_MCP_SERVER_HOSTS` makes mutating/destructive tools refuse to act on it at call time (and, with several hosts, writes require naming the target host explicitly — so the agent can't change the wrong daemon by omission). Like `guard_not_self`, this is in-process convenience: it constrains the agent through this server's tools, but the daemon itself is still the trust boundary, so for a host the agent must never modify, prefer pointing it at a genuinely read-only or scoped daemon over relying on the marker alone.
|
|
407
|
+
- **Per-host non-destructive is the same accident guard, scoped narrower.** A host marked `(nd)` refuses only destructive tool calls (kills, removals, prunes) while reads and ordinary mutations still go through. Like `(ro)`, this is in-process convenience enforced by this server's guard, not a daemon-level boundary.
|
|
408
|
+
- **`cryptography` (a transitive dependency, via `mcp` → `pyjwt[crypto]`) has shipped no x86_64/universal2 macOS wheel since 49.0.0 — confirmed permanent, not transient — so a native install there (`uvx`, `pip`, or the `.mcpb` bundle) resolves to a current version and builds it from source, needing [Rust](https://rustup.rs/) and OpenSSL 3.x.** This project briefly tried to avoid that build step with its own `cryptography<49` cap scoped to Intel macOS — that cap turned out to hold back *every* platform, not just the one it named (a platform-scoped upper bound alone doesn't make `uv lock` fork the resolution), so it silently left everyone on a version within a later high-severity CVE's range (a PKCS#7 EnvelopedData decryption Bleichenbacher oracle). The cap was removed rather than replaced: this project doesn't decrypt PKCS#7 envelopes itself, but a dependency carrying a known-exploitable flaw is not something to ship knowingly just to keep one platform wheel-only. If you'd rather not install a build toolchain, use the [container image](#run-as-a-container) instead (built centrally on Linux, so it never hits this).
|
|
394
409
|
|
|
395
410
|
## Packages and listings
|
|
396
411
|
|
|
@@ -65,6 +65,7 @@ class Host:
|
|
|
65
65
|
label: str
|
|
66
66
|
url: str | None
|
|
67
67
|
read_only: bool = False
|
|
68
|
+
non_destructive: bool = False
|
|
68
69
|
cert_dir: str | None = None
|
|
69
70
|
|
|
70
71
|
@property
|
|
@@ -108,8 +109,10 @@ def _context_host(name: str) -> str | None:
|
|
|
108
109
|
def resolve_local() -> str | None:
|
|
109
110
|
"""
|
|
110
111
|
The platform-local daemon socket — first existing well-known location, Docker Desktop / rootless
|
|
111
|
-
first. Context-bypassing
|
|
112
|
-
|
|
112
|
+
first. Context-bypassing by design: `local` means the machine's own socket, never wherever a CLI
|
|
113
|
+
context happens to point. Returns None to let from_env() apply its platform default (e.g. the
|
|
114
|
+
Windows named pipe, which has nothing to probe on disk) — which is context-free because that call
|
|
115
|
+
goes through `system._from_env_no_context`.
|
|
113
116
|
"""
|
|
114
117
|
if sys.platform == "win32": # pyright: ignore[reportUnreachable]
|
|
115
118
|
return None
|
|
@@ -135,8 +138,12 @@ def resolve_auto() -> str | None:
|
|
|
135
138
|
"""
|
|
136
139
|
The daemon a context-aware `docker` with no DOCKER_HOST would use: the active CLI context's
|
|
137
140
|
endpoint (DOCKER_CONTEXT / config.json currentContext -> its meta.json Host), else the local-socket
|
|
138
|
-
probe.
|
|
139
|
-
|
|
141
|
+
probe. Returns None to let from_env() apply its own platform default.
|
|
142
|
+
|
|
143
|
+
We resolve the context ourselves rather than delegating. docker-py's from_env() was context-blind
|
|
144
|
+
until 7.2.0 and now does its own resolution, but we keep ours and switch theirs off (see
|
|
145
|
+
`system._from_env_no_context`): the result has to be pinned at `load()` and shared with the docker
|
|
146
|
+
CLI shell-out, neither of which a per-client-build lookup inside docker-py can give us.
|
|
140
147
|
"""
|
|
141
148
|
name = _active_context_name()
|
|
142
149
|
if name and name != "default":
|
|
@@ -153,24 +160,28 @@ def _fail(message: str) -> NoReturn:
|
|
|
153
160
|
raise HostConfigError(message)
|
|
154
161
|
|
|
155
162
|
|
|
156
|
-
def _parse_markers(text: str, context: str) -> tuple[str, bool, str | None]:
|
|
157
|
-
"""Strip trailing (ro)/(tls=<dir>) markers (any order, case-insensitive) off an endpoint
|
|
158
|
-
returning (endpoint, read_only, cert_dir).
|
|
163
|
+
def _parse_markers(text: str, context: str) -> tuple[str, bool, bool, str | None]:
|
|
164
|
+
"""Strip trailing (ro)/(nd)/(tls=<dir>) markers (any order, case-insensitive) off an endpoint
|
|
165
|
+
string, returning (endpoint, read_only, non_destructive, cert_dir). (ro) and (nd) may combine
|
|
166
|
+
with no error — (ro) is strictly stronger and wins at enforcement time (see server.py)."""
|
|
159
167
|
read_only = False
|
|
168
|
+
non_destructive = False
|
|
160
169
|
cert_dir: str | None = None
|
|
161
170
|
while (match := _TRAILING_MARKER.search(text)) is not None:
|
|
162
171
|
body = match.group(1).strip()
|
|
163
172
|
low = body.lower()
|
|
164
173
|
if low == "ro":
|
|
165
174
|
read_only = True
|
|
175
|
+
elif low == "nd":
|
|
176
|
+
non_destructive = True
|
|
166
177
|
elif low.startswith("tls="):
|
|
167
178
|
cert_dir = body[len("tls=") :].strip() # preserve the path's case
|
|
168
179
|
if not cert_dir:
|
|
169
180
|
_fail(f"{context!r}: (tls=) needs a directory, e.g. (tls=/etc/docker/prod)")
|
|
170
181
|
else:
|
|
171
|
-
_fail(f"{context!r}: unknown marker '({body})'; only (ro) and (tls=<dir>) are allowed")
|
|
182
|
+
_fail(f"{context!r}: unknown marker '({body})'; only (ro), (nd), and (tls=<dir>) are allowed")
|
|
172
183
|
text = text[: match.start()].rstrip()
|
|
173
|
-
return text.strip(), read_only, cert_dir
|
|
184
|
+
return text.strip(), read_only, non_destructive, cert_dir
|
|
174
185
|
|
|
175
186
|
|
|
176
187
|
def _readable(path: Path) -> bool:
|
|
@@ -211,7 +222,7 @@ def _validate_cert_dir(label: str, cert_dir: str) -> None:
|
|
|
211
222
|
|
|
212
223
|
def _make_host(label: str, raw_endpoint: str, context: str) -> Host:
|
|
213
224
|
"""Build a Host from one endpoint spec: parse markers, validate, and resolve to a concrete URL."""
|
|
214
|
-
endpoint, read_only, cert_dir = _parse_markers(raw_endpoint.strip(), context)
|
|
225
|
+
endpoint, read_only, non_destructive, cert_dir = _parse_markers(raw_endpoint.strip(), context)
|
|
215
226
|
low = endpoint.lower()
|
|
216
227
|
if cert_dir is not None:
|
|
217
228
|
if not low.startswith("tcp://"):
|
|
@@ -229,7 +240,7 @@ def _make_host(label: str, raw_endpoint: str, context: str) -> Host:
|
|
|
229
240
|
f"host {label!r}: unrecognized endpoint {endpoint!r} "
|
|
230
241
|
f"(use 'auto', 'local', or a unix:// / tcp:// / ssh:// / npipe:// URL)"
|
|
231
242
|
)
|
|
232
|
-
return Host(label=label, url=url, read_only=read_only, cert_dir=cert_dir)
|
|
243
|
+
return Host(label=label, url=url, read_only=read_only, non_destructive=non_destructive, cert_dir=cert_dir)
|
|
233
244
|
|
|
234
245
|
|
|
235
246
|
def _legacy_host() -> Host:
|
|
@@ -341,6 +352,11 @@ def is_read_only(host: str | None = None) -> bool:
|
|
|
341
352
|
return resolve(host).read_only
|
|
342
353
|
|
|
343
354
|
|
|
355
|
+
def is_non_destructive(host: str | None = None) -> bool:
|
|
356
|
+
"""Whether the named (or default) host is flagged non-destructive (blocks DESTRUCTIVE calls only)."""
|
|
357
|
+
return resolve(host).non_destructive
|
|
358
|
+
|
|
359
|
+
|
|
344
360
|
def is_multi() -> bool:
|
|
345
361
|
"""True when 2+ hosts are configured (gates the per-call host param, its enum, and multi-host prompts)."""
|
|
346
362
|
return len(_registry) >= 2
|