docker-mcp-server 2.2.0__tar.gz → 2.2.2__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.0 → docker_mcp_server-2.2.2}/.github/copilot-instructions.md +11 -7
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/premerge.yaml +47 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/CLAUDE.md +36 -5
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/PKG-INFO +4 -3
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/README.md +2 -1
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/server.py +31 -15
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_cli.py +15 -5
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_ssh_proxy.py +224 -5
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/compose.py +138 -22
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/system.py +57 -3
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/manifest.json +1 -1
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/pyproject.toml +10 -2
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_cli.py +9 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_compose.py +235 -26
- docker_mcp_server-2.2.2/tests/test_pyproject_pins.py +153 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_remote_exec.py +242 -2
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_resources.py +1 -1
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_server.py +33 -11
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_ssh_proxy.py +117 -1
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_system.py +68 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/uv.lock +70 -38
- docker_mcp_server-2.2.0/tests/test_pyproject_pins.py +0 -93
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.claude/commands/docker-sdk.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.claude/settings.json +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.dockerignore +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/CODEOWNERS +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/actions/file-failure-issue/action.yaml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/dependabot.yaml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/release.yml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/canary.yaml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/codeql.yaml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/images.yaml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/publish-homebrew.yaml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/publish.yaml +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.gitignore +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.mcpbignore +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.python-version +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/CODE_OF_CONDUCT.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/CONTRIBUTING.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/DOCKERHUB.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/Dockerfile +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/LICENSE +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/MIGRATION-2.0.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/PRIVACY.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/SECURITY.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/assets/README.md +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/assets/icon.png +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/__init__.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/__main__.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/_env.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/_hosts.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/__init__.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_labels.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_utils.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/buildx.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/configs.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/containers.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/context.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/images.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/networks.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/nodes.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/plugins.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/prompts.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/registry.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/resources.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/scout.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/secrets.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/services.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/stack.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/swarm.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/volumes.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/glama.json +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/mcpb_run.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/scripts/build-mcpb.sh +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/scripts/docker-mcp-server.rb.tpl +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/server.json +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/__init__.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/conftest.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/__init__.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/conftest.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_buildx.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_cli.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_compose.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_containers.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_context.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_file_payloads.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_networks.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_nodes.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_registry.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_remote_exec.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_scout.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_services.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_smoke.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_stack.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_buildx.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_configs.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_containers.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_context.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_docs.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_env.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_hosts.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_images.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_labels.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_main.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_naming.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_networks.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_nodes.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_plugins.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_prompts.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_registry.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_scout.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_secrets.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_services.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_stack.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_swarm.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_utils.py +0 -0
- {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_volumes.py +0 -0
|
@@ -6,7 +6,7 @@ This file provides guidance to GitHub Copilot when working with code in this rep
|
|
|
6
6
|
|
|
7
7
|
`docker-mcp` is a Python MCP (Model Context Protocol) server that exposes the Docker SDK for Python — plus selected docker CLI features (Compose, Stack, Buildx, Scout, Context) and direct OCI-registry HTTPS access — as MCP tools. It requires Python >=3.14 and is managed with `uv`. It is published to PyPI as **`docker-mcp-server`** and as a container image to GHCR (`ghcr.io/l337-org/docker-mcp-server`), mirrored to Docker Hub (`gavinlucas/docker-mcp-server`) when the opt-in `DOCKERHUB_*` release secrets are configured (the import package stays `docker_mcp`, the repo stays `…/docker-mcp`); two console scripts — `docker-mcp` and `docker-mcp-server` — both target `docker_mcp:main`. A third distribution channel packages the server as a **Claude Desktop Extension (`.mcpb`)** attached to each GitHub Release (see "Desktop Extension (MCPB)" below). A fourth channel (**Homebrew tap**) exists in `L337-org/homebrew-tap` but is currently **paused** — see "Homebrew tap" below.
|
|
8
8
|
|
|
9
|
-
The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works via a pure-Python transport (no system `ssh` binary; works in the container images). docker-py auto-selects paramiko for `ssh://`, so there's no transport code — only the `ssh://` branch in `system._connection_help`. CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to `docker`, which would otherwise need the system `ssh` client — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection and runs `docker system dial-stdio` over it, so the CLI authenticates the same way the docker-py-backed tools do, with no system `ssh` binary involved (except a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — for both tool families alike). With no local `docker` binary (or plugin) at all, all of those except the `context_*` tools (which manage *this* host's own CLI contexts) fall back to running the command on the `ssh://` host itself — see "SSH remote-exec fallback" under the CLI shell-out policy.
|
|
9
|
+
The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works via a pure-Python transport (no system `ssh` binary; works in the container images). docker-py auto-selects paramiko for `ssh://`, so there's no transport code — only the `ssh://` branch in `system._connection_help`. CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to `docker`, which would otherwise need the system `ssh` client — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection and runs `docker system dial-stdio` over it, so the CLI authenticates the same way the docker-py-backed tools do, with no system `ssh` binary involved (except a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — for both tool families alike). With no local `docker` binary (or plugin) at all, all of those except the `context_*` tools (which manage *this* host's own CLI contexts) fall back to running the command on the `ssh://` host itself — see "SSH remote-exec fallback" under the CLI shell-out policy. Both the docker-py-backed and CLI-backed SSH connections fall back from IPv6 to IPv4 on any connect failure (not just paramiko's own narrower retry) via `_ssh_proxy.py:connect_socket_with_family_fallback` — see "Client / CLI" below.
|
|
10
10
|
|
|
11
11
|
## Architecture
|
|
12
12
|
|
|
@@ -14,11 +14,11 @@ The `docker` dep uses the `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` w
|
|
|
14
14
|
The `docker_mcp` package is the entry point. `docker_mcp/__init__.py` defines `main()` and side-effect-imports the `server` and `tools` submodules (which registers all `@tool()` decorators); `docker_mcp/__main__.py` calls `main()` so `python -m docker_mcp` works.
|
|
15
15
|
|
|
16
16
|
### Server singleton (`docker_mcp/server.py`)
|
|
17
|
-
`docker_mcp/server.py` instantiates `
|
|
17
|
+
`docker_mcp/server.py` instantiates `MCPServer` (from `mcp.server.mcpserver`) and exports three things:
|
|
18
18
|
|
|
19
19
|
- **`tool`** — the registration decorator every tool module uses. **Always import `tool` from `docker_mcp.server`** and decorate with `@tool()`; never import from the `mcp` package directly in tool files (circular import) and never use `@mcp.tool()` in tool modules.
|
|
20
20
|
- **`prompt`** — the prompt registration decorator `prompts.py` uses (`@prompt(description=..., domain=...)`), analogous to `tool` and gating on `DOCKER_MCP_SERVER_DISABLE`; never use `@mcp.prompt()` directly in `prompts.py`.
|
|
21
|
-
- **`mcp`** — the
|
|
21
|
+
- **`mcp`** — the MCPServer singleton, imported by `resources.py` for `@mcp.resource()`.
|
|
22
22
|
|
|
23
23
|
```python
|
|
24
24
|
from docker_mcp.server import tool # tool modules
|
|
@@ -26,9 +26,9 @@ from docker_mcp.server import prompt # prompt modules (with domain=...)
|
|
|
26
26
|
from docker_mcp.server import mcp # resource modules only
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`server.py` also owns **`TOOL_CATEGORIES`**, the central map classifying every tool as `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`. The `@tool()` decorator uses it to attach MCP `ToolAnnotations` and to skip registration under the env switches `DOCKER_MCP_SERVER_READONLY` (register only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (register everything except destructive). It also records each tool's **domain** (its defining module's leaf, e.g. `containers`) so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop whole feature areas — including that domain's **prompts** (via the `prompt(domain=...)` helper) and its **doc-resource sections** (via `_SECTION_DOMAINS` in `resources.py`), not just its tools; the live snapshot is the `docker-mcp://tool-catalog` resource (`server.tool_catalog()`). A handful of tools (`_NO_DOMAIN_TOOLS`, e.g. `docs_lookup`) have **no domain at all** — `_domain_for` returns `None`, which short-circuits `DOCKER_MCP_SERVER_DISABLE` entirely (mirrors `@prompt(domain=None)`'s same always-available semantics) — for tools whose value isn't tied to one feature area. **Every new tool needs a `TOOL_CATEGORIES` entry** — `tests/test_server.py` fails the build if the map drifts from the registered set. The decorator also runs `_slim_schema` on the advertised `inputSchema` to drop three information-free patterns (~18% of schema tokens): pydantic `title` annotations, the `{"type":"null"}` branch of a nullable `anyOf` (gated on a sibling `default`), and redundant `additionalProperties: true`; it's display-only (validation runs off `fn_metadata`), and `tests/test_server.py` asserts none survive. All server tunables are namespaced `DOCKER_MCP_SERVER_*`; read them through `docker_mcp/_env.py` (`read_env` / `env_flag`). The pre-rename `DOCKER_MCP_*` alias spellings were removed in 2.0; the alias-fallback mechanism remains for any future rename.
|
|
29
|
+
`server.py` also owns **`TOOL_CATEGORIES`**, the central map classifying every tool as `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`. The `@tool()` decorator uses it to attach MCP `ToolAnnotations` — including `title`, mechanically derived from the tool name by `_title_for` (e.g. `container_list` → "Container List"), with a small `_TITLE_ACRONYMS` fixup list so `scout_cves`/`scout_sbom` title-case to "Scout CVEs"/"Scout SBOM" rather than "Cves"/"Sbom" — and to skip registration under the env switches `DOCKER_MCP_SERVER_READONLY` (register only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (register everything except destructive). It also records each tool's **domain** (its defining module's leaf, e.g. `containers`) so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop whole feature areas — including that domain's **prompts** (via the `prompt(domain=...)` helper) and its **doc-resource sections** (via `_SECTION_DOMAINS` in `resources.py`), not just its tools; the live snapshot is the `docker-mcp://tool-catalog` resource (`server.tool_catalog()`). A handful of tools (`_NO_DOMAIN_TOOLS`, e.g. `docs_lookup`) have **no domain at all** — `_domain_for` returns `None`, which short-circuits `DOCKER_MCP_SERVER_DISABLE` entirely (mirrors `@prompt(domain=None)`'s same always-available semantics) — for tools whose value isn't tied to one feature area. **Every new tool needs a `TOOL_CATEGORIES` entry** — `tests/test_server.py` fails the build if the map drifts from the registered set. The decorator also runs `_slim_schema` on the advertised `inputSchema` to drop three information-free patterns (~18% of schema tokens): pydantic `title` annotations, the `{"type":"null"}` branch of a nullable `anyOf` (gated on a sibling `default`), and redundant `additionalProperties: true`; it's display-only (validation runs off `fn_metadata`), and `tests/test_server.py` asserts none survive. All server tunables are namespaced `DOCKER_MCP_SERVER_*`; read them through `docker_mcp/_env.py` (`read_env` / `env_flag`). The pre-rename `DOCKER_MCP_*` alias spellings were removed in 2.0; the alias-fallback mechanism remains for any future rename.
|
|
30
30
|
|
|
31
|
-
`server.py` also builds the
|
|
31
|
+
`server.py` also builds the MCPServer **`instructions`** string — pre-loaded into a client's context with the server name and tool names, *before* any per-tool schema, so for a lazy-loading client (e.g. Claude Code, which fetches tool schemas on demand) it's the main always-in-context surface. It's written as a **router** (per-domain keyword one-liners + a few tool-selection caveats), not docs, and does not enumerate tools (that's `docker-mcp://tool-catalog`). `build_instructions()` renders it from `_DOMAIN_BLURBS`, emitting a domain's line **only when that domain has a registered tool**, so `DOCKER_MCP_SERVER_DISABLE` / `_READONLY` / `_NO_DESTRUCTIVE` are honored via the one registration flag. `finalize_instructions()` (called from `docker_mcp/__init__.py` after all tools import) writes it through to `mcp._lowlevel_server.instructions` (MCPServer's `instructions` is a read-only property read at `run()` time, so a late write propagates; the reach-in is guarded). **A new tool domain needs a `_DOMAIN_BLURBS` entry** or the router silently omits it.
|
|
32
32
|
|
|
33
33
|
### Multi-daemon host registry (`docker_mcp/_hosts.py`)
|
|
34
34
|
|
|
@@ -37,7 +37,7 @@ from docker_mcp.server import mcp # resource modules only
|
|
|
37
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.
|
|
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
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 writes are still refused, so the per-host `(ro)` marker is honored even in single-host mode (distinct from `DOCKER_MCP_SERVER_READONLY`, which drops write tools entirely); a single *writable* 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. **`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.
|
|
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.
|
|
41
41
|
- **Surfaces.** `host_list` tool + `docker-mcp://hosts` resource expose the resolved registry; the router gains a multi-host caveat; the container observability resources go host-aware (empty-authority `docker:///…` for the default + `docker://{host}/…` variants); a `prompt(multi_host=True)` gate plus the `survey_hosts` prompt register only with 2+ hosts.
|
|
42
42
|
|
|
43
43
|
**When reviewing a PR that changes the host grammar, env precedence, or the per-tool/resource/prompt host surface, this section is the spec.**
|
|
@@ -78,6 +78,8 @@ Each `docker_mcp/tools/<module>.py` has a corresponding `tests/test_<module>.py`
|
|
|
78
78
|
|
|
79
79
|
CI (`.github/workflows/premerge.yaml`) enforces `pytest`, `ruff check`, `ruff format --check`, and `pyright` on every PR and push to main — all via `uv run`, so the dev-group pins in `pyproject.toml` (bumped by Dependabot's monthly uv pass) are the single tool-version source; the pre-commit hooks are local hooks running `uv run ruff …` for the same reason. CI installs with `uv sync --locked`, which fails if `uv.lock` disagrees with `pyproject.toml` instead of silently re-locking — so when reviewing a PR that touches only `uv.lock`, check its recorded `requires-dist` specifiers still match pyproject (a Dependabot lock rewrite once raised the cryptography cap in the lock alone). A non-required `Check docs mirror` job flags a PR that edits `CLAUDE.md` or `.github/copilot-instructions.md` without the other (the MIRROR RULE) — when reviewing such a PR, check whether the one-sided edit was intentional.
|
|
80
80
|
|
|
81
|
+
A required `Check fresh resolve still imports` job (same workflow) covers what every `--locked` job above cannot: it resolves `pyproject.toml` with `uv pip install` (the pip-compatible interface, which never touches `uv.lock`) into a throwaway venv, then runs `import docker_mcp` and `docker-mcp-server --version` against that fresh install — the same set a `uvx`/`pip install` would actually get today, not the pinned known-good set in `uv.lock`. It reports a resolution failure (specifier unsatisfiable) separately from an import failure (resolved fine, broke on import), since a PR fixing one looks different from a PR fixing the other. When reviewing a dependency-cap change (raising or removing a cap, e.g. on `mcp` or `cryptography`), this job's result is the one that tells you whether the wider range still imports — a green `pytest-run` does not, since it stays on the locked pins.
|
|
82
|
+
|
|
81
83
|
A **weekly canary** (`.github/workflows/canary.yaml`, Mondays + dispatch) hunts platform/ecosystem drift premerge CI can't see: wheels-only (`--only-binary :all:`) dependency resolution for Intel macOS / ARM macOS / Windows against both the repo `pyproject.toml` and the latest published PyPI release (the check that would have caught cryptography 49 dropping its x86_64-macOS wheel), plus real install smokes of the published package on `macos-latest`, `macos-15-intel` (Intel runner label retires Aug 2027), and `windows-latest` — `import docker_mcp` and `uvx docker-mcp-server --version`. PRs into main also run the repo-pyproject resolution leg (the only part exercising PR content); the published-package legs and issue filing stay schedule/dispatch-only. Failures on unattended runs file a deduplicated `ci-failure` + `wf:canary` issue via `.github/actions/file-failure-issue`. `main()` handles `--version` (print the installed version, exit) before any daemon/network contact — the canary's entry-point smoke depends on it.
|
|
82
84
|
|
|
83
85
|
### Container image (`Dockerfile`)
|
|
@@ -135,7 +137,7 @@ Any tool wrapping a `docker` CLI feature MUST go through `docker_mcp/tools/_cli.
|
|
|
135
137
|
- Always pass an explicit `timeout=` to `run_docker` (generous for build/pull, short for queries).
|
|
136
138
|
- Error convention (intentional — do not "unify"): **action tools** return the raw `{"returncode", "stdout", "stderr", "truncated"}` dict and never raise on a non-zero exit; **parsed-query tools** (`context_list`, `buildx_list`/`du`, `compose_list`) raise `RuntimeError` via `raise_on_cli_failure` because they cannot return a useful partial parse; `compose_ps`/`compose_config` are the sanctioned hybrids (parsed view plus `raw`, never raise).
|
|
137
139
|
|
|
138
|
-
**SSH remote-exec fallback.** The dial-stdio proxy still needs a local `docker` binary to point at the remote daemon; with none, CLI-backed tools fail at `_resolve("docker")` even though the docker-py-backed tools work against that host. So when the target host resolves to `ssh://` **and** nothing local can serve the call, the command runs on that host via `_ssh_proxy.py:run_remote_exec` (paramiko, `PosixDialect` wrapping the argv in an `sh -c` script that enforces the timeout itself and reports `124`, mapped back to `subprocess.TimeoutExpired`; both streams drained concurrently). Rules: the decision lives in each module's shared `_run_*` wrapper via `_cli.py:should_remote_exec(host, plugin=...)` (`plugin=None` = core-CLI subcommand) routing to `remote_exec_cli(host, args, timeout=)`, which returns the same `CliResult` so the error conventions above need no remote branch — never probe ad hoc in a tool body; and it is a fallback, never a preference (a usable local CLI always wins, a non-`ssh://` host is never eligible, and `remote_exec_cli` raises rather than degrading if called for one). Consequences to state in the docstring of any tool that gains this path: it runs as the **remote** SSH user, so registry credentials come from *its* `~/.docker/config.json` (`system_login` never writes the remote CLI's config); `stdin`/`extra_env` are rejected rather than dropped; the remote must be POSIX (`uname -s` allow-list — sshd inside WSL is accepted, a Windows-side cmd/PowerShell sshd or MSYS/Cygwin is refused). **Wired in: every CLI-backed domain except `context`** (excluded permanently — its tools manage *this* host's CLI context registry): `scout` is exec-only (`scout_compare`'s `to` is refused when it names an existing local path); `compose` stages `project_dir` (or the server's cwd) via `remote_stage_and_exec`, except `compose_list`, which asks the daemon and takes the exec-only path, and **`compose_cp`, which is
|
|
140
|
+
**SSH remote-exec fallback.** The dial-stdio proxy still needs a local `docker` binary to point at the remote daemon; with none, CLI-backed tools fail at `_resolve("docker")` even though the docker-py-backed tools work against that host. So when the target host resolves to `ssh://` **and** nothing local can serve the call, the command runs on that host via `_ssh_proxy.py:run_remote_exec` (paramiko, `PosixDialect` wrapping the argv in an `sh -c` script that enforces the timeout itself and reports `124`, mapped back to `subprocess.TimeoutExpired`; both streams drained concurrently). Rules: the decision lives in each module's shared `_run_*` wrapper via `_cli.py:should_remote_exec(host, plugin=...)` (`plugin=None` = core-CLI subcommand) routing to `remote_exec_cli(host, args, timeout=)`, which returns the same `CliResult` so the error conventions above need no remote branch — never probe ad hoc in a tool body; and it is a fallback, never a preference (a usable local CLI always wins, a non-`ssh://` host is never eligible, and `remote_exec_cli` raises rather than degrading if called for one). Consequences to state in the docstring of any tool that gains this path: it runs as the **remote** SSH user, so registry credentials come from *its* `~/.docker/config.json` (`system_login` never writes the remote CLI's config); `stdin`/`extra_env` are rejected rather than dropped; the remote must be POSIX (`uname -s` allow-list — sshd inside WSL is accepted, a Windows-side cmd/PowerShell sshd or MSYS/Cygwin is refused). **Wired in: every CLI-backed domain except `context`** (excluded permanently — its tools manage *this* host's CLI context registry): `scout` is exec-only (`scout_compare`'s `to` is refused when it names an existing local path); `compose` stages `project_dir` (or the server's cwd) via `remote_stage_and_exec`, except `compose_list`, which asks the daemon and takes the exec-only path, and **`compose_cp`, which is bespoke** — one side of the copy is a local path outside the compose file's working directory, which `remote_stage_and_exec` has no concept of relaying, so it stages `project_dir` the same way and then uses `_split_cp_arg` (mirroring `docker compose cp`'s own `splitCpArg`, verified against `docker/compose`'s `pkg/compose/cp.go`) to identify which of `source`/`dest` is the container reference: a local source is staged like `project_dir`; a local destination gets a fresh path from `RemoteStagingSession.reserve_path()` for the remote `docker compose cp` to write into, fetched back via `RemoteStagingSession.fetch_path()` once the copy succeeds (`test -d` decides file vs. directory; a directory is packed remotely with `tar`, downloaded, and extracted locally with `tarfile`'s `filter="data"` — what actually stops an escaping member — under the same size/count bounds `_enforce_stage_limits` applies to uploads). Every parameter carries over unchanged since the real CLI always executes the copy; the one gap is a container→host copy whose local destination already exists, refused rather than merged into or overwritten. When neither or both sides look like `SERVICE:PATH`, the call passes through unchanged and the real remote CLI's own validation error surfaces. `unix://`/`tcp://`+TLS with no local plugin still raise via `require_plugin`, whose message now also names pointing the host at an `ssh://` endpoint via `DOCKER_MCP_SERVER_HOSTS` as an alternative to installing the plugin (shared by `compose`/`buildx`/`scout`); `stack` gained the shared `_run_stack` wrapper, where only `stack_deploy` passes `stage_cwd=True` (explicit, *not* inferred from `cwd is None`, since a Compose-style `cwd=None` still means "stage the server's cwd"); `buildx` splits three ways — queries exec-only, `buildx_bake` stages its working directory (with `path_values=files` passed **explicitly**, because bake appends caller-supplied targets that an argv scan could match, and those targets now go through `safe_positional`), `buildx_create --config` / `buildx_imagetools_create --file` stage only the files they name via `stage_cwd=False`, and **`buildx_build` is bespoke** (see below).
|
|
139
141
|
|
|
140
142
|
**`buildx_build`** drives a session directly (`remote_cli_session` + `run_in_session`): its context needs `.dockerignore`-aware tarring and its `--build-context`/`--secret` values carry paths inside composite `key=value` tokens, rewritten flag-anchored via `_spec_component`/`_replace_spec_component`. The context is staged only when it names an **existing local directory** — the inverse test to recognising URL syntax — so a Git/HTTP context passes through. **buildx resolves `--file` against the CLI's working directory, not the context** (verified empirically; the pre-2.2.0 docstring claimed the opposite), so the remote command gets **no cwd** and every rewritten path is absolute — an in-context Dockerfile becomes `<staged context>/<relative>` via `RemoteStagingSession.join`, one outside it (or beside a URL context) is staged separately. A remote cwd would let a relative `--file` the local CLI cannot find resolve inside the copied context: the same build failing locally and succeeding remotely. It **refuses** (before connecting) a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, and any `ssh=` — each would resolve on the remote machine (losing the output, caching to the wrong disk, silently building uncached since a missing local cache import is non-fatal, or reading the remote user's SSH agent). `dest=-` is stdout and passes. The `instructions` router names the fallback for the domains in `_REMOTE_EXEC_DOMAINS` that registered, so a `context`-only surface never advertises it.
|
|
141
143
|
|
|
@@ -271,3 +273,5 @@ Changing this line to a later version of cryptography can break installs on Inte
|
|
|
271
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.
|
|
272
274
|
Changing to a later version of cryptography for other platforms is fine, but this pin should remain for macOS x86_64.
|
|
273
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.
|
|
@@ -20,6 +20,53 @@ concurrency:
|
|
|
20
20
|
# Jobs
|
|
21
21
|
jobs:
|
|
22
22
|
|
|
23
|
+
fresh-resolve-import:
|
|
24
|
+
# Answers a question `uv sync --locked` (used by every other job here) cannot: would a
|
|
25
|
+
# CLEAN install of what we're about to ship actually import? `uv sync --locked` only ever
|
|
26
|
+
# sees the pinned, known-good set recorded in uv.lock, never the set a fresh `uvx`/`pip
|
|
27
|
+
# install` would resolve from the bare pyproject.toml specifiers today. mcp 2.0.0
|
|
28
|
+
# (2026-07-28) proved the gap: it resolved cleanly against our then-uncapped `mcp>=1.27.1`
|
|
29
|
+
# and removed `mcp.server.fastmcp`, so `import docker_mcp` died on every fresh install while
|
|
30
|
+
# every uv.lock-pinned CI job here stayed green (see the mcp cap comment in pyproject.toml).
|
|
31
|
+
# `uv pip install` is the pip-compatible interface — unlike `uv sync`, it never
|
|
32
|
+
# reads or writes uv.lock, so it resolves purely from [project.dependencies].
|
|
33
|
+
runs-on: [ubuntu-latest]
|
|
34
|
+
name: Check fresh resolve still imports
|
|
35
|
+
steps:
|
|
36
|
+
- name: Check out source repository
|
|
37
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
38
|
+
|
|
39
|
+
- name: Set up Python environment
|
|
40
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.14"
|
|
43
|
+
|
|
44
|
+
- name: Set up uv
|
|
45
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
46
|
+
|
|
47
|
+
- name: Resolve and install from pyproject.toml, ignoring uv.lock
|
|
48
|
+
# A failure here is a resolve/build/install failure (an unsatisfiable specifier, a
|
|
49
|
+
# sdist needing a toolchain we don't have, a transient fetch error) — distinct from
|
|
50
|
+
# the import failure checked in the next step. The log above this line has the cause.
|
|
51
|
+
run: |
|
|
52
|
+
uv venv .venv-fresh
|
|
53
|
+
if ! uv pip install --python .venv-fresh/bin/python . ; then
|
|
54
|
+
echo "::error::Fresh resolve/install of pyproject.toml FAILED (see the uv output above for the cause) — this is a resolve/install failure, not an import failure."
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Import the freshly-resolved install
|
|
59
|
+
run: |
|
|
60
|
+
if ! .venv-fresh/bin/python -c "import docker_mcp"; then
|
|
61
|
+
echo "::error::pyproject.toml resolved fine, but 'import docker_mcp' FAILED against the freshly-resolved dependency set. An admitted dependency release broke our import surface (see the mcp 2.0.0 incident in CLAUDE.md) — tighten the offending dependency's cap."
|
|
62
|
+
exit 1
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
- name: Run the entry point against the freshly-resolved install
|
|
66
|
+
# main() handles --version (print version, exit) before any daemon/network contact,
|
|
67
|
+
# so this needs no Docker and catches a failure in main() itself, not just the import.
|
|
68
|
+
run: .venv-fresh/bin/docker-mcp-server --version
|
|
69
|
+
|
|
23
70
|
pytest-run:
|
|
24
71
|
runs-on: [ubuntu-latest]
|
|
25
72
|
name: Run pytest
|
|
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
13
13
|
|
|
14
14
|
`docker-mcp` is a Python MCP server (requires Python >=3.14) managed with `uv` that exposes the Docker SDK for Python as MCP tools. The entry point is the `docker_mcp` package, run with `python -m docker_mcp` or via the installed console script. It is **published to PyPI as `docker-mcp-server`** (the `docker-mcp` name was already taken) and as a container image to GHCR (`ghcr.io/l337-org/docker-mcp-server`), mirrored to Docker Hub (`gavinlucas/docker-mcp-server`) when the opt-in `DOCKERHUB_*` release secrets are configured; the import package stays `docker_mcp` and the repo stays `…/docker-mcp`. Two console scripts are installed — `docker-mcp` and `docker-mcp-server` — both targeting `docker_mcp:main`. A third channel packages the server as a **Claude Desktop Extension (`.mcpb`)** attached to each GitHub Release — see "Desktop Extension (MCPB bundle)" below. A fourth channel (**Homebrew tap**) exists in `L337-org/homebrew-tap` but is currently **paused** — see "Homebrew tap" below.
|
|
15
15
|
|
|
16
|
-
The `docker` dependency is pulled with its `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works through a pure-Python transport — no system `ssh` binary, identical on the host and in the container images. docker-py auto-selects paramiko for `ssh://` when present, so there is no transport code to maintain (just the `ssh://` branch in `system._connection_help`). CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to `docker`, which would otherwise use the *system* `ssh` — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection (mirroring docker-py's `SSHHTTPAdapter` defaults) and runs `docker system dial-stdio` over it, so the CLI authenticates identically to the docker-py-backed tools with no system `ssh` binary involved (the one exception being a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — same as it would for the docker-py-backed tools). Where there is no local `docker` binary (or plugin) at all, all of those except the `context_*` tools (which manage *this* host's own CLI contexts) fall back to running the command on the `ssh://` host itself — see "SSH remote-exec fallback" under the CLI shell-out policy.
|
|
16
|
+
The `docker` dependency is pulled with its `[ssh]` extra (paramiko), so `DOCKER_HOST=ssh://…` works through a pure-Python transport — no system `ssh` binary, identical on the host and in the container images. docker-py auto-selects paramiko for `ssh://` when present, so there is no transport code to maintain (just the `ssh://` branch in `system._connection_help`). CLI-backed tools (Compose, Stack, Buildx, Scout, Context) shell out to `docker`, which would otherwise use the *system* `ssh` — instead, `_cli.py:run_docker` detects `DOCKER_HOST=ssh://…` and routes the subprocess through a per-call local TCP proxy (`docker_mcp/tools/_ssh_proxy.py`) that opens its own paramiko connection (mirroring docker-py's `SSHHTTPAdapter` defaults) and runs `docker system dial-stdio` over it, so the CLI authenticates identically to the docker-py-backed tools with no system `ssh` binary involved (the one exception being a `ProxyCommand` in `~/.ssh/config` for bastion/jump-host setups, which paramiko runs as an external command — commonly `ssh -W %h:%p ...` — same as it would for the docker-py-backed tools). Where there is no local `docker` binary (or plugin) at all, all of those except the `context_*` tools (which manage *this* host's own CLI contexts) fall back to running the command on the `ssh://` host itself — see "SSH remote-exec fallback" under the CLI shell-out policy. Both the docker-py-backed and CLI-backed SSH connections fall back from IPv6 to IPv4 on any connect failure (not just paramiko's own narrower retry) via `_ssh_proxy.py:connect_socket_with_family_fallback` — see "Client side" under the multi-daemon host registry below.
|
|
17
17
|
|
|
18
18
|
## Commands
|
|
19
19
|
|
|
@@ -55,6 +55,33 @@ 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
|
+
**A dependency cap exists because a fresh resolve can break what `--locked` CI cannot see**,
|
|
59
|
+
enforced by `tests/test_pyproject_pins.py`: `cryptography<49` on Intel macOS (49 dropped the
|
|
60
|
+
universal2 wheel). When adding a direct dependency whose *import surface* we touch, consider
|
|
61
|
+
whether a major-version cap plus a pin guard belongs with it.
|
|
62
|
+
|
|
63
|
+
An `mcp<2` cap of this kind existed briefly: mcp 2.0.0 removed `mcp.server.fastmcp`, which
|
|
64
|
+
`server.py` imported `FastMCP` from, and an uncapped 2.2.0 shipped dead on arrival at import while
|
|
65
|
+
every CI job stayed green, because CI installs `--locked` against a lockfile pinning mcp 1.x;
|
|
66
|
+
2.2.1 hotfixed the cap. `server.py` has since been ported to `mcp.server.mcpserver.MCPServer` and
|
|
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.
|
|
72
|
+
|
|
73
|
+
**A required `Check fresh resolve still imports` job** (`premerge.yaml`) closes the blind spot that
|
|
74
|
+
let the 2.2.0 incident through: every job above installs with `uv sync --locked`, so none of them
|
|
75
|
+
ever resolve what a fresh `uvx`/`pip install` actually gets from the bare `pyproject.toml`
|
|
76
|
+
specifiers — only the pinned, known-good set in `uv.lock`. This job does, via `uv pip install`
|
|
77
|
+
(the pip-compatible interface, which never reads or writes `uv.lock`) into a throwaway venv, then
|
|
78
|
+
runs `import docker_mcp` and `docker-mcp-server --version` against that install. It reports a
|
|
79
|
+
resolution failure (a specifier no longer satisfiable) and an import failure (resolved fine, but
|
|
80
|
+
broke on import) as distinct errors, since they call for different fixes. This is a PR/push gate,
|
|
81
|
+
not a schedule — it complements rather than replaces the weekly canary's published-package install
|
|
82
|
+
smoke below, which exercises the actual shipped artefact rather than a hypothetical resolve of the
|
|
83
|
+
current tree.
|
|
84
|
+
|
|
58
85
|
A **weekly canary** (`.github/workflows/canary.yaml`, Mondays + dispatch) hunts platform/ecosystem
|
|
59
86
|
drift premerge CI can't see: wheels-only (`--only-binary :all:`) dependency resolution for Intel
|
|
60
87
|
macOS / ARM macOS / Windows against both the repo `pyproject.toml` and the latest published PyPI
|
|
@@ -74,7 +101,7 @@ contact — the canary's entry-point smoke depends on it.
|
|
|
74
101
|
The `docker_mcp` package is the entry point. `docker_mcp/__init__.py` defines `main()` and side-effect-imports the `server` and `tools` submodules (which registers all `@tool()` decorators). `docker_mcp/__main__.py` calls `main()` so `python -m docker_mcp` works; the installed `docker-mcp` console script also targets `docker_mcp:main`.
|
|
75
102
|
|
|
76
103
|
### Server singleton (`docker_mcp/server.py`)
|
|
77
|
-
Instantiates `
|
|
104
|
+
Instantiates `MCPServer` (from `mcp.server.mcpserver`), exports the `mcp` object, and exports the `tool` and `prompt` registration helpers. **Tool modules import `tool`; prompt modules import `prompt`** — both gate on `DOCKER_MCP_SERVER_DISABLE` (never import from `mcp` directly in those modules — that would create circular imports). `@mcp.resource()` modules still import `mcp` (plus `is_domain_disabled` / `register_resource_domains` for section gating).
|
|
78
105
|
|
|
79
106
|
```python
|
|
80
107
|
from docker_mcp.server import tool # tool modules
|
|
@@ -82,7 +109,7 @@ from docker_mcp.server import prompt # prompt modules (with domain=...)
|
|
|
82
109
|
from docker_mcp.server import mcp # resource modules
|
|
83
110
|
```
|
|
84
111
|
|
|
85
|
-
`server.py` also owns the central **`TOOL_CATEGORIES`** map (every tool name → `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`). The `@tool()` decorator uses it to (a) attach `ToolAnnotations` (`readOnlyHint` / `destructiveHint`,
|
|
112
|
+
`server.py` also owns the central **`TOOL_CATEGORIES`** map (every tool name → `READ_ONLY` / `MUTATING` / `DESTRUCTIVE`). The `@tool()` decorator uses it to (a) attach `ToolAnnotations` (`title` — mechanically derived from the tool name by `_title_for`, e.g. `container_list` → "Container List", with a small `_TITLE_ACRONYMS` fixup list so names like `scout_cves`/`scout_sbom` title-case to "Scout CVEs"/"Scout SBOM" rather than "Cves"/"Sbom"; plus `readOnlyHint` / `destructiveHint`, and `idempotentHint` for the prune family) and (b) skip registration entirely under the read-only env switches `DOCKER_MCP_SERVER_READONLY` (only read-only tools) and `DOCKER_MCP_SERVER_NO_DESTRUCTIVE` (everything except destructive). Every registered tool must have a `TOOL_CATEGORIES` entry — `tests/test_server.py` fails if the map and the registered set drift. The `title` annotation exists because some external directories (e.g. the Claude Connectors Directory) mechanically require one on every tool, independent of description quality — see the Docstring quality standard below, point 2's "annotations don't substitute for prose" is the opposite failure mode, not a contradiction.
|
|
86
113
|
|
|
87
114
|
**Env-var naming.** All server tunables are namespaced `DOCKER_MCP_SERVER_*` (matching the published package/image name `docker-mcp-server`); the pre-rename `DOCKER_MCP_*` alias spellings were removed in 2.0. Read env vars through `docker_mcp/_env.py` — `read_env("DOCKER_MCP_SERVER_NAME")` or `env_flag(...)`. The helper still supports alias fallbacks (`read_env(canonical, *aliases)` with a one-time stderr deprecation notice) for any future rename; no alias is currently registered. `_env.py` lives at the package root (not under `tools/`) so `server.py` can import it without pulling in `docker_mcp.tools`, which would be a circular import at registration time; `_utils.py` re-exports `env_flag` / `read_env` for tool modules. A new tunable adds a canonical `DOCKER_MCP_SERVER_*` name.
|
|
88
115
|
|
|
@@ -92,7 +119,7 @@ The decorator also records each tool's **domain** — the leaf of its defining m
|
|
|
92
119
|
|
|
93
120
|
A handful of tools have **no domain at all** — `_NO_DOMAIN_TOOLS` (today just `docs_lookup`) — because their value isn't tied to any single Docker feature area being enabled or disabled. `_domain_for` returns `None` for these, and `None` short-circuits the `_domain_enabled` check entirely, so `DOCKER_MCP_SERVER_DISABLE` can never drop them (not even by their own name). This mirrors `@prompt(domain=None)`'s identical "cross-cutting, always available" semantics for prompts. They still register/deregister normally under `DOCKER_MCP_SERVER_READONLY`/`_NO_DESTRUCTIVE` based on their own category (a domain-less tool should still be `READ_ONLY` for this to matter in practice).
|
|
94
121
|
|
|
95
|
-
**Server `instructions` router.** `server.py` also builds the
|
|
122
|
+
**Server `instructions` router.** `server.py` also builds the MCPServer `instructions` string — the text a client pre-loads into context alongside the server name and tool names, *before* any per-tool schema. For a lazy-loading client (e.g. Claude Code, which fetches tool schemas on demand) that's the main always-in-context surface we control, so it's written as a **router**, not docs: a per-domain one-liner mapping user vocabulary onto the domain keyword a tool search will hit, plus a few tool-selection caveats. It deliberately does not enumerate tools (that's the `docker-mcp://tool-catalog` resource). It's built dynamically by `build_instructions()` from `_DOMAIN_BLURBS`, emitting a domain's line **only when that domain has a registered tool** — so `DOCKER_MCP_SERVER_DISABLE` / `_READONLY` / `_NO_DESTRUCTIVE` are all honored through the one registration flag, and the router never advertises a domain whose tools didn't register. `finalize_instructions()` (called from `docker_mcp/__init__.py` *after* every tool module imports) writes the result through to `mcp._lowlevel_server.instructions` — MCPServer's `instructions` is a read-only property whose value is read at `run()` time, so a late write propagates to the MCP initialize handshake; the `_lowlevel_server` reach-in is guarded like `_slim_schema`. **A new tool *domain* needs a `_DOMAIN_BLURBS` entry** or the router silently omits it (`tests/test_server.py` checks the router tracks the registered domain set).
|
|
96
123
|
|
|
97
124
|
### Multi-daemon host registry (`docker_mcp/_hosts.py`)
|
|
98
125
|
|
|
@@ -110,6 +137,10 @@ A handful of tools have **no domain at all** — `_NO_DOMAIN_TOOLS` (today just
|
|
|
110
137
|
|
|
111
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.
|
|
112
139
|
|
|
140
|
+
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
|
+
- **`_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.
|
|
142
|
+
- **`_ensure_reachable_family`**: `paramiko.SSHClient.connect()` (which `SSHHTTPAdapter` calls internally) resolves both address families but only retries the next one on `ECONNREFUSED`/`EHOSTUNREACH` — a timed-out or black-holed IPv6 route (`ETIMEDOUT`, what a broken IPv6 path actually produces) is never retried, so a host that's perfectly reachable over IPv4 fails outright (`tcp://` doesn't have this problem — `urllib3`'s `create_connection` catches any `OSError` per attempt). Rather than reaching into `SSHHTTPAdapter` internals, this probes every resolved address itself with `_ssh_proxy.connect_socket_with_family_fallback` (the same broad-`OSError`-per-attempt helper `connect_ssh_client` passes to paramiko as `sock=` for the CLI-backed tools) and splices whichever address actually answered into the URL as a literal IP, so docker-py's own connect resolves trivially with nothing left to get wrong. One extra short-lived probe connection per client build, absorbed by the pool (once per host, not per call). A URL that's already a literal address, or where every candidate fails, is left unchanged.
|
|
143
|
+
|
|
113
144
|
**Surfaces.** `host_list` (READ_ONLY) tool + the `docker-mcp://hosts` resource expose the resolved registry (the default is observable but not selectable). The router (`build_instructions`) adds a multi-host caveat, the container observability resources switch to empty-authority / host-qualified URIs (see MCP resources below), and a `prompt(multi_host=True)` gate plus the `survey_hosts` prompt register only when 2+ hosts are configured. **When changing the host grammar, the env-var precedence, the per-tool/resource/prompt host surface, or the resolution semantics, update this section.**
|
|
114
145
|
|
|
115
146
|
### Tools package (`docker_mcp/tools/`)
|
|
@@ -227,7 +258,7 @@ Two rules for tool modules:
|
|
|
227
258
|
Consequences to state in the docstring of any tool that gains this path: the command runs as the **remote** SSH user, so registry credentials come from *its* `~/.docker/config.json` (`system_login` talks to the daemon through the SDK and never writes the remote CLI's config); `stdin`/`extra_env` are rejected rather than dropped; and the remote must be POSIX (`uname -s` allow-list — sshd inside WSL is Linux and accepted; a Windows-side cmd/PowerShell sshd, or MSYS/Cygwin on a Windows host, is refused by name). **Wired in: every CLI-backed domain except `context`** — `scout`, `compose`, `stack`, `buildx`. `context.py` is excluded permanently, not pending: its tools manage *this* host's CLI context registry, which a remote host knows nothing about.
|
|
228
259
|
|
|
229
260
|
- **`scout`** takes image references and reads nothing locally, so it goes through `remote_exec_cli`. The one exception, `scout_compare`'s `to` (which may name a local directory or archive), is refused when the value exists locally rather than being resolved against the remote filesystem.
|
|
230
|
-
- **`compose`** reads its files from a working directory, so `_run_compose` routes through `remote_stage_and_exec`, which stages `project_dir` (or the server's cwd) and runs there. `compose_list` is the exception — it asks the daemon, so it takes the exec-only path. **`compose_cp` is
|
|
261
|
+
- **`compose`** reads its files from a working directory, so `_run_compose` routes through `remote_stage_and_exec`, which stages `project_dir` (or the server's cwd) and runs there. `compose_list` is the exception — it asks the daemon, so it takes the exec-only path. **`compose_cp` is bespoke** rather than going through `remote_stage_and_exec` (one side of the copy is a local path outside the compose file's working directory, which that helper has no concept of relaying): it stages `project_dir` the same way, then uses `_split_cp_arg` (mirroring `docker compose cp`'s own `splitCpArg`, verified against `docker/compose`'s `pkg/compose/cp.go`) to identify which of `source`/`dest` is the container reference. A local source is staged like `project_dir` (`stage_file`/`stage_tree`); a local destination gets a fresh path from `RemoteStagingSession.reserve_path()` for the remote `docker compose cp` to write into, fetched back via `RemoteStagingSession.fetch_path()` once the copy succeeds (`test -d` decides file vs. directory; a directory is packed remotely with `tar`, downloaded, and extracted locally with `tarfile`'s `filter="data"`, which is what actually stops an escaping member — the same size/count bounds `_enforce_stage_limits` applies to uploads apply to a fetch, checked against the packed archive's `stat` size before download and its member count after). Because the real CLI always executes the copy, every parameter (`--all`, `--index`, `files`) carries over unchanged; the one gap with no remote equivalent is a container→host copy whose local destination already exists, which is refused rather than merged into or overwritten, since only this host knows that state. When neither or both sides look like `SERVICE:PATH`, nothing beyond `project_dir` is staged and the call passes through unchanged, so the real remote CLI's own validation error surfaces exactly as it would locally. `unix://`/`tcp://`+TLS with no local plugin are not covered (no shell to run the CLI on) and still raise via `require_plugin`, whose message now also names pointing the host at an `ssh://` endpoint via `DOCKER_MCP_SERVER_HOSTS` as an alternative to installing the plugin — a message shared by `compose`/`buildx`/`scout`, all of which support this fallback.
|
|
231
262
|
- **`stack`** gained the shared `_run_stack` wrapper it lacked (5 direct `run_docker` calls before). Only `stack_deploy` reads local files, so `stage_cwd=True` is explicit there and the four query/removal tools take the exec-only path — the distinction is *not* inferred from `cwd is None`, since a Compose-style `cwd=None` still means "stage the server's cwd".
|
|
232
263
|
- **`buildx`** splits three ways. The query/lifecycle tools are exec-only; `buildx_bake` stages a working directory (`stage_cwd=True`, `path_values=files` passed **explicitly** — bake appends caller-supplied target names, so an argv scan for `-f` could match one, and those targets now go through `safe_positional` too); `buildx_create --config` and `buildx_imagetools_create --file` stage only the files they name (`stage_cwd=False`, which stages every existing `path_values` entry individually and gives the remote command no cwd). **`buildx_build` is bespoke**: it drives a session via `remote_cli_session` / `run_in_session`, because its context needs `.dockerignore`-aware tarring and its `--build-context` / `--secret` values carry paths *inside* composite `key=value` tokens (rewritten via `_spec_component` / `_replace_spec_component`, flag-anchored rather than whole-token). The context is staged only when it names an **existing local directory** — the inverse test to recognising URL syntax, which cannot be done reliably from the string — so a Git/HTTP context passes through untouched. **buildx resolves `--file` against the CLI's working directory, not the context** (verified empirically; the pre-2.2.0 docstring claimed the opposite), so the remote command gets **no working directory** and every path rewritten here is absolute — an in-context Dockerfile becomes `<staged context>/<relative>` via `RemoteStagingSession.join`, one outside it (or beside a URL context) is staged on its own. Running in the staged context instead would let a relative `--file` the local CLI cannot find resolve *there*, so the same build would fail locally and succeed remotely. `buildx_build` **refuses** (RuntimeError, before connecting) a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, and any `ssh=` — each would resolve on the remote machine, losing the output, writing cache to the wrong disk, silently building uncached (a missing local cache import is non-fatal to BuildKit), or reading the remote user's agent. `dest=-` is stdout and passes.
|
|
233
264
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docker-mcp-server
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.2
|
|
4
4
|
Summary: MCP server for managing Docker resources via the Docker SDK for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/L337-org/docker-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/L337-org/docker-mcp
|
|
@@ -22,7 +22,7 @@ Requires-Python: >=3.14
|
|
|
22
22
|
Requires-Dist: cryptography<49; platform_system == 'Darwin' and platform_machine == 'x86_64'
|
|
23
23
|
Requires-Dist: docker[ssh]>=7.1.0
|
|
24
24
|
Requires-Dist: httpx>=0.28.1
|
|
25
|
-
Requires-Dist: mcp>=
|
|
25
|
+
Requires-Dist: mcp>=2.0.0
|
|
26
26
|
Requires-Dist: requests>=2.26.0
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
@@ -228,7 +228,8 @@ Worth knowing:
|
|
|
228
228
|
- **Local files a command reads are copied onto the remote first** — a Compose project directory, a bake file, a build context (honouring `.dockerignore`) — into a private `0700` temp directory that is removed when the call returns — best-effort, since a dropped SSH connection leaves nothing able to run the cleanup. A survivor is named `docker-mcp-server.stage.*` and the failure is logged.
|
|
229
229
|
- **The remote host's credentials apply.** Registry logins come from *its* `~/.docker/config.json`, so a private-registry `compose_pull`, a `stack deploy --with-registry-auth`, or most Scout operations may need `docker login` there. `system_login` talks to the daemon and does not write the remote CLI's config.
|
|
230
230
|
- **The whole working directory is copied**, because nothing can tell which files a Compose file references (`build:`, `env_file:`, `include:` all name arbitrary paths). Point tools at a project directory rather than a large parent, or the call is refused with a size-limit error (200 MiB / 50 000 entries).
|
|
231
|
-
-
|
|
231
|
+
- **`compose_cp` relays whichever side of the copy is local**, over the same SSH connection: a host source is staged like any other input, and a container result is fetched back to the real destination once the copy succeeds — the actual copy still runs through the real `docker compose cp` on the far host, so every parameter behaves as it does locally. The one difference: a container-to-host copy is refused if the local destination already exists, since only this host knows that.
|
|
232
|
+
- **A few things are refused rather than half-done**: for `buildx_build` a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, or any `ssh=` — each would resolve on the remote machine.
|
|
232
233
|
- **The remote must present a POSIX shell.** Any Linux/macOS/BSD host qualifies, and **sshd running inside a WSL distro is supported** (it is a Linux target — and with Docker Desktop's WSL2 backend the daemon lives there anyway). A Windows-side `cmd`/PowerShell sshd is refused with an explanatory error, as is a `uname` from MSYS/MinGW/Cygwin; a Windows dialect is architected but not implemented. A Windows sshd whose shell is `wsl.exe` runs commands in WSL while its SFTP subsystem stays on the Windows side, so file-copying tools are refused there — the ones that only run a command still work.
|
|
233
234
|
- **`context_*` tools never fall back.** They manage *this* host's CLI context registry, which a remote host knows nothing about.
|
|
234
235
|
|
|
@@ -200,7 +200,8 @@ Worth knowing:
|
|
|
200
200
|
- **Local files a command reads are copied onto the remote first** — a Compose project directory, a bake file, a build context (honouring `.dockerignore`) — into a private `0700` temp directory that is removed when the call returns — best-effort, since a dropped SSH connection leaves nothing able to run the cleanup. A survivor is named `docker-mcp-server.stage.*` and the failure is logged.
|
|
201
201
|
- **The remote host's credentials apply.** Registry logins come from *its* `~/.docker/config.json`, so a private-registry `compose_pull`, a `stack deploy --with-registry-auth`, or most Scout operations may need `docker login` there. `system_login` talks to the daemon and does not write the remote CLI's config.
|
|
202
202
|
- **The whole working directory is copied**, because nothing can tell which files a Compose file references (`build:`, `env_file:`, `include:` all name arbitrary paths). Point tools at a project directory rather than a large parent, or the call is refused with a size-limit error (200 MiB / 50 000 entries).
|
|
203
|
-
-
|
|
203
|
+
- **`compose_cp` relays whichever side of the copy is local**, over the same SSH connection: a host source is staged like any other input, and a container result is fetched back to the real destination once the copy succeeds — the actual copy still runs through the real `docker compose cp` on the far host, so every parameter behaves as it does locally. The one difference: a container-to-host copy is refused if the local destination already exists, since only this host knows that.
|
|
204
|
+
- **A few things are refused rather than half-done**: for `buildx_build` a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, or any `ssh=` — each would resolve on the remote machine.
|
|
204
205
|
- **The remote must present a POSIX shell.** Any Linux/macOS/BSD host qualifies, and **sshd running inside a WSL distro is supported** (it is a Linux target — and with Docker Desktop's WSL2 backend the daemon lives there anyway). A Windows-side `cmd`/PowerShell sshd is refused with an explanatory error, as is a `uname` from MSYS/MinGW/Cygwin; a Windows dialect is architected but not implemented. A Windows sshd whose shell is `wsl.exe` runs commands in WSL while its SFTP subsystem stays on the Windows side, so file-copying tools are refused there — the ones that only run a command still work.
|
|
205
206
|
- **`context_*` tools never fall back.** They manage *this* host's CLI context registry, which a remote host knows nothing about.
|
|
206
207
|
|
|
@@ -12,13 +12,13 @@ from dataclasses import dataclass
|
|
|
12
12
|
from enum import Enum
|
|
13
13
|
from typing import Any, NoReturn
|
|
14
14
|
|
|
15
|
-
from mcp.server.
|
|
15
|
+
from mcp.server.mcpserver import MCPServer
|
|
16
16
|
from mcp.types import ToolAnnotations
|
|
17
17
|
|
|
18
18
|
import docker_mcp._hosts as _hosts
|
|
19
19
|
from docker_mcp._env import env_flag, read_env
|
|
20
20
|
|
|
21
|
-
mcp =
|
|
21
|
+
mcp = MCPServer("docker-mcp-server")
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class ToolCategory(Enum):
|
|
@@ -211,7 +211,7 @@ TOOL_CATEGORIES: dict[str, ToolCategory] = {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
# Destructive tools whose effect is idempotent — re-running has no additional effect (the targets
|
|
214
|
-
# are already gone). Surfaced via ToolAnnotations.
|
|
214
|
+
# are already gone). Surfaced via ToolAnnotations.idempotent_hint so clients can treat retries as safe.
|
|
215
215
|
_IDEMPOTENT_TOOLS = frozenset({"container_prune", "image_prune", "network_prune", "volume_prune", "buildx_prune"})
|
|
216
216
|
|
|
217
217
|
# The optional per-call parameter that selects which configured host a daemon-targeting tool acts on.
|
|
@@ -486,23 +486,39 @@ def finalize_instructions() -> None:
|
|
|
486
486
|
Set the server's `instructions` from the actually-registered surface — called once after every tool
|
|
487
487
|
module has imported (docker_mcp/__init__.py), so the switch-dependent registration is already known.
|
|
488
488
|
|
|
489
|
-
|
|
490
|
-
read at run() time (create_initialization_options), so writing it through here after registration
|
|
491
|
-
propagates to the MCP initialize handshake. Reaching into `
|
|
492
|
-
schema-title strip below:
|
|
489
|
+
MCPServer.instructions is a read-only property backed by the low-level server's `instructions`, which
|
|
490
|
+
is read at run() time (create_initialization_options), so writing it through here after registration
|
|
491
|
+
propagates to the MCP initialize handshake. Reaching into `_lowlevel_server` is guarded the same way as
|
|
492
|
+
the schema-title strip below: an MCPServer refactor degrades to "instructions stay unset" rather than
|
|
493
|
+
raising.
|
|
493
494
|
"""
|
|
494
495
|
try:
|
|
495
|
-
mcp.
|
|
496
|
+
mcp._lowlevel_server.instructions = build_instructions()
|
|
496
497
|
except AttributeError:
|
|
497
498
|
pass
|
|
498
499
|
|
|
499
500
|
|
|
501
|
+
# Acronyms that a naive title-case of a snake_case tool name gets wrong (e.g. "scout_cves" ->
|
|
502
|
+
# "Scout Cves"). Keyed by the title-cased word so `_title_for` can substitute in place.
|
|
503
|
+
_TITLE_ACRONYMS: dict[str, str] = {"Cves": "CVEs", "Sbom": "SBOM"}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _title_for(name: str) -> str:
|
|
507
|
+
"""Human-readable display title for a tool, mechanically derived from its snake_case name
|
|
508
|
+
(e.g. "container_list" -> "Container List") so every tool has one without hand-authoring ~150
|
|
509
|
+
of them. Distinct from the schema `title` `_slim_schema` strips — this is the ToolAnnotations
|
|
510
|
+
field some directories (e.g. the Claude Connectors Directory) require independent of prose."""
|
|
511
|
+
words = name.replace("_", " ").title().split(" ")
|
|
512
|
+
return " ".join(_TITLE_ACRONYMS.get(word, word) for word in words)
|
|
513
|
+
|
|
514
|
+
|
|
500
515
|
def _annotations_for(name: str, category: ToolCategory) -> ToolAnnotations:
|
|
501
516
|
"""Build the ToolAnnotations a client uses to auto-allow reads and gate destructive calls."""
|
|
502
517
|
return ToolAnnotations(
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
518
|
+
title=_title_for(name),
|
|
519
|
+
read_only_hint=category is ToolCategory.READ_ONLY,
|
|
520
|
+
destructive_hint=category is ToolCategory.DESTRUCTIVE,
|
|
521
|
+
idempotent_hint=True if name in _IDEMPOTENT_TOOLS else None,
|
|
506
522
|
)
|
|
507
523
|
|
|
508
524
|
|
|
@@ -667,7 +683,7 @@ def _host_guard_needed() -> bool:
|
|
|
667
683
|
|
|
668
684
|
def _wrap_with_host_guard(func: Callable, name: str, category: ToolCategory) -> Callable:
|
|
669
685
|
"""Wrap a daemon-targeting tool so the host guard runs before it (when `_host_guard_needed()` —
|
|
670
|
-
multi-host, or a single host flagged (ro)). Preserves the signature so
|
|
686
|
+
multi-host, or a single host flagged (ro)). Preserves the signature so MCPServer builds the same
|
|
671
687
|
schema/fn_metadata, and matches the func's sync/async-ness."""
|
|
672
688
|
signature = inspect.signature(func)
|
|
673
689
|
|
|
@@ -720,7 +736,7 @@ def tool(**kwargs: Any) -> Callable[[Callable], Callable]:
|
|
|
720
736
|
return func
|
|
721
737
|
# Daemon-targeting tools (those declaring a `host` param) get a call-time host guard when there's
|
|
722
738
|
# something to enforce — multiple hosts, or a single host flagged (ro); wrap before registering so
|
|
723
|
-
#
|
|
739
|
+
# MCPServer builds the schema from the wrapper, whose signature mirrors the original. A single
|
|
724
740
|
# writable host (and host-agnostic tools) register func unchanged.
|
|
725
741
|
target = func
|
|
726
742
|
if _has_host_param(func) and _host_guard_needed():
|
|
@@ -728,8 +744,8 @@ def tool(**kwargs: Any) -> Callable[[Callable], Callable]:
|
|
|
728
744
|
decorated = mcp.tool(annotations=_annotations_for(name, category), **kwargs)(target)
|
|
729
745
|
# Slim the advertised input schema (drop information-free titles, nullable-anyOf null branches,
|
|
730
746
|
# and redundant `additionalProperties: true`), then apply the host-param surgery (enum + required
|
|
731
|
-
# in multi-host, or strip it in single-host). Both reach into
|
|
732
|
-
# (`_tool_manager.get_tool(...).parameters`); guard it so a future
|
|
747
|
+
# in multi-host, or strip it in single-host). Both reach into MCPServer internals
|
|
748
|
+
# (`_tool_manager.get_tool(...).parameters`); guard it so a future MCPServer refactor degrades to
|
|
733
749
|
# "schema not slimmed" (a test catches that) rather than crashing the server at import time.
|
|
734
750
|
try:
|
|
735
751
|
registered_tool = mcp._tool_manager.get_tool(kwargs.get("name") or name)
|
|
@@ -264,13 +264,23 @@ def has_plugin(name: str) -> bool:
|
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def require_plugin(name: str) -> None:
|
|
267
|
-
"""
|
|
267
|
+
"""
|
|
268
|
+
Raise RuntimeError with an actionable message if the named CLI plugin is unavailable.
|
|
269
|
+
|
|
270
|
+
Every caller reaches this only after `should_remote_exec` has already returned False for the
|
|
271
|
+
target host, which means that host is not reached over ssh:// (an ssh:// host with no local
|
|
272
|
+
plugin runs the call there instead of raising here). So alongside installing the plugin, pointing
|
|
273
|
+
this host at an ssh:// endpoint that already has it is always a live alternative — named in the
|
|
274
|
+
message for the three plugins that share this helper (compose, buildx, scout), all of which
|
|
275
|
+
support that fallback.
|
|
276
|
+
"""
|
|
268
277
|
if not has_plugin(name):
|
|
269
278
|
raise RuntimeError(
|
|
270
|
-
f"Docker CLI plugin {name!r} is not installed or not available on PATH. "
|
|
271
|
-
f"
|
|
272
|
-
f"
|
|
273
|
-
f"
|
|
279
|
+
f"Docker CLI plugin {name!r} is not installed or not available on PATH. Install it "
|
|
280
|
+
f"(Docker Desktop ships it by default; on a plain Docker Engine install, use your "
|
|
281
|
+
f"distribution's docker-{name}-plugin package, or follow the upstream docs) — or point "
|
|
282
|
+
f"this host at an ssh:// endpoint that already has it, via DOCKER_MCP_SERVER_HOSTS: the "
|
|
283
|
+
f"call then runs there automatically instead."
|
|
274
284
|
)
|
|
275
285
|
|
|
276
286
|
|