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.
Files changed (120) hide show
  1. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/copilot-instructions.md +11 -7
  2. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/premerge.yaml +47 -0
  3. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/CLAUDE.md +36 -5
  4. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/PKG-INFO +4 -3
  5. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/README.md +2 -1
  6. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/server.py +31 -15
  7. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_cli.py +15 -5
  8. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_ssh_proxy.py +224 -5
  9. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/compose.py +138 -22
  10. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/system.py +57 -3
  11. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/manifest.json +1 -1
  12. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/pyproject.toml +10 -2
  13. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_cli.py +9 -0
  14. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_compose.py +235 -26
  15. docker_mcp_server-2.2.2/tests/test_pyproject_pins.py +153 -0
  16. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_remote_exec.py +242 -2
  17. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_resources.py +1 -1
  18. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_server.py +33 -11
  19. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_ssh_proxy.py +117 -1
  20. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_system.py +68 -0
  21. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/uv.lock +70 -38
  22. docker_mcp_server-2.2.0/tests/test_pyproject_pins.py +0 -93
  23. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.claude/commands/docker-sdk.md +0 -0
  24. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.claude/settings.json +0 -0
  25. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.dockerignore +0 -0
  26. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/CODEOWNERS +0 -0
  27. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  28. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  29. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  30. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/actions/file-failure-issue/action.yaml +0 -0
  31. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/dependabot.yaml +0 -0
  32. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/release.yml +0 -0
  33. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/canary.yaml +0 -0
  34. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/codeql.yaml +0 -0
  35. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/images.yaml +0 -0
  36. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/publish-homebrew.yaml +0 -0
  37. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.github/workflows/publish.yaml +0 -0
  38. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.gitignore +0 -0
  39. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.mcpbignore +0 -0
  40. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/.python-version +0 -0
  41. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/CODE_OF_CONDUCT.md +0 -0
  42. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/CONTRIBUTING.md +0 -0
  43. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/DOCKERHUB.md +0 -0
  44. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/Dockerfile +0 -0
  45. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/LICENSE +0 -0
  46. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/MIGRATION-2.0.md +0 -0
  47. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/PRIVACY.md +0 -0
  48. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/SECURITY.md +0 -0
  49. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/assets/README.md +0 -0
  50. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/assets/icon.png +0 -0
  51. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/__init__.py +0 -0
  52. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/__main__.py +0 -0
  53. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/_env.py +0 -0
  54. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/_hosts.py +0 -0
  55. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/__init__.py +0 -0
  56. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_labels.py +0 -0
  57. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/_utils.py +0 -0
  58. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/buildx.py +0 -0
  59. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/configs.py +0 -0
  60. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/containers.py +0 -0
  61. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/context.py +0 -0
  62. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/images.py +0 -0
  63. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/networks.py +0 -0
  64. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/nodes.py +0 -0
  65. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/plugins.py +0 -0
  66. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/prompts.py +0 -0
  67. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/registry.py +0 -0
  68. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/resources.py +0 -0
  69. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/scout.py +0 -0
  70. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/secrets.py +0 -0
  71. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/services.py +0 -0
  72. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/stack.py +0 -0
  73. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/swarm.py +0 -0
  74. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/docker_mcp/tools/volumes.py +0 -0
  75. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/glama.json +0 -0
  76. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/mcpb_run.py +0 -0
  77. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/scripts/build-mcpb.sh +0 -0
  78. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/scripts/docker-mcp-server.rb.tpl +0 -0
  79. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/server.json +0 -0
  80. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/__init__.py +0 -0
  81. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/conftest.py +0 -0
  82. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/__init__.py +0 -0
  83. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/conftest.py +0 -0
  84. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_buildx.py +0 -0
  85. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_cli.py +0 -0
  86. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_compose.py +0 -0
  87. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_containers.py +0 -0
  88. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_context.py +0 -0
  89. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_file_payloads.py +0 -0
  90. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_networks.py +0 -0
  91. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_nodes.py +0 -0
  92. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_registry.py +0 -0
  93. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_remote_exec.py +0 -0
  94. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_scout.py +0 -0
  95. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_services.py +0 -0
  96. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_smoke.py +0 -0
  97. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/integration/test_stack.py +0 -0
  98. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_buildx.py +0 -0
  99. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_configs.py +0 -0
  100. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_containers.py +0 -0
  101. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_context.py +0 -0
  102. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_docs.py +0 -0
  103. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_env.py +0 -0
  104. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_hosts.py +0 -0
  105. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_images.py +0 -0
  106. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_labels.py +0 -0
  107. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_main.py +0 -0
  108. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_naming.py +0 -0
  109. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_networks.py +0 -0
  110. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_nodes.py +0 -0
  111. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_plugins.py +0 -0
  112. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_prompts.py +0 -0
  113. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_registry.py +0 -0
  114. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_scout.py +0 -0
  115. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_secrets.py +0 -0
  116. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_services.py +0 -0
  117. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_stack.py +0 -0
  118. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_swarm.py +0 -0
  119. {docker_mcp_server-2.2.0 → docker_mcp_server-2.2.2}/tests/test_utils.py +0 -0
  120. {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 `FastMCP` and exports three things:
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 FastMCP singleton, imported by `resources.py` for `@mcp.resource()`.
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 FastMCP **`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._mcp_server.instructions` (FastMCP'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.
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 refused outright** — one side of the copy is a path on *this* host, and `container_archive_put` / `container_archive_get_to_file` already do that job against any daemon with no local CLI; `stack` gained the shared `_run_stack` wrapper, where only `stack_deploy` passes `stage_cwd=True` (explicit, *not* inferred from `cwd is None`, since a Compose-style `cwd=None` still means "stage the server's cwd"); `buildx` splits three ways — queries exec-only, `buildx_bake` stages its working directory (with `path_values=files` passed **explicitly**, because bake appends caller-supplied targets that an argv scan could match, and those targets now go through `safe_positional`), `buildx_create --config` / `buildx_imagetools_create --file` stage only the files they name via `stage_cwd=False`, and **`buildx_build` is bespoke** (see below).
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 `FastMCP`, 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).
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`, plus `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.
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 FastMCP `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._mcp_server.instructions` — FastMCP'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 `_mcp_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).
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 refused outright** under remote-exec: one side of the copy is a path on *this* host, and `container_archive_put` / `container_archive_get_to_file` already do that job against any daemon with no local CLI, so the refusal names them instead of adding a download-shaped second mechanism.
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.0
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>=1.27.1
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
- - **A few things are refused rather than half-done**: `compose_cp` (its other side is a path on *this* hostuse `container_archive_put` / `container_archive_get_to_file`, which need no CLI at all), and for `buildx_build` a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, or any `ssh=` each would resolve on the remote machine.
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
- - **A few things are refused rather than half-done**: `compose_cp` (its other side is a path on *this* hostuse `container_archive_put` / `container_archive_get_to_file`, which need no CLI at all), and for `buildx_build` a filesystem `dest=` in `output`/`cache_to`, a local `src=` in `cache_from`, or any `ssh=` each would resolve on the remote machine.
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.fastmcp import FastMCP
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 = FastMCP("docker-mcp-server")
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.idempotentHint so clients can treat retries as safe.
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
- FastMCP.instructions is a read-only property backed by the low-level server's `instructions`, which is
490
- read at run() time (create_initialization_options), so writing it through here after registration
491
- propagates to the MCP initialize handshake. Reaching into `_mcp_server` is guarded the same way as the
492
- schema-title strip below: a FastMCP refactor degrades to "instructions stay unset" rather than raising.
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._mcp_server.instructions = build_instructions()
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
- readOnlyHint=category is ToolCategory.READ_ONLY,
504
- destructiveHint=category is ToolCategory.DESTRUCTIVE,
505
- idempotentHint=True if name in _IDEMPOTENT_TOOLS else None,
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 FastMCP builds the same
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
- # FastMCP builds the schema from the wrapper, whose signature mirrors the original. A single
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 FastMCP internals
732
- # (`_tool_manager.get_tool(...).parameters`); guard it so a future FastMCP refactor degrades to
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
- """Raise RuntimeError with an actionable message if the named CLI plugin is unavailable."""
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"On Docker Desktop it ships by default; on a plain Docker Engine install, "
272
- f"install it via your distribution's docker-{name}-plugin package "
273
- f"(or follow the upstream docs)."
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