docker-mcp-server 2.0.0__tar.gz → 2.1.0__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 (119) hide show
  1. docker_mcp_server-2.1.0/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  2. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/copilot-instructions.md +25 -4
  3. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/workflows/canary.yaml +8 -0
  4. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/workflows/codeql.yaml +3 -3
  5. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/workflows/images.yaml +5 -5
  6. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/workflows/premerge.yaml +6 -0
  7. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/workflows/publish.yaml +5 -5
  8. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.mcpbignore +2 -0
  9. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/CLAUDE.md +18 -1
  10. docker_mcp_server-2.1.0/CODE_OF_CONDUCT.md +83 -0
  11. docker_mcp_server-2.1.0/CONTRIBUTING.md +188 -0
  12. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/PKG-INFO +10 -145
  13. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/README.md +9 -144
  14. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/server.py +28 -8
  15. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/configs.py +6 -2
  16. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/containers.py +98 -35
  17. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/images.py +10 -3
  18. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/networks.py +23 -7
  19. docker_mcp_server-2.1.0/docker_mcp/tools/nodes.py +136 -0
  20. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/plugins.py +19 -9
  21. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/prompts.py +29 -14
  22. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/registry.py +63 -0
  23. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/resources.py +239 -4
  24. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/secrets.py +15 -5
  25. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/services.py +169 -4
  26. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/swarm.py +33 -10
  27. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/system.py +6 -0
  28. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/volumes.py +17 -6
  29. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/manifest.json +1 -1
  30. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/pyproject.toml +1 -1
  31. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/conftest.py +11 -2
  32. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_containers.py +24 -0
  33. docker_mcp_server-2.1.0/tests/integration/test_nodes.py +24 -0
  34. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_registry.py +9 -0
  35. docker_mcp_server-2.1.0/tests/integration/test_services.py +54 -0
  36. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_containers.py +91 -0
  37. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_naming.py +1 -0
  38. docker_mcp_server-2.1.0/tests/test_nodes.py +130 -0
  39. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_prompts.py +24 -2
  40. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_registry.py +59 -0
  41. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_resources.py +220 -14
  42. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_server.py +24 -5
  43. docker_mcp_server-2.1.0/tests/test_services.py +417 -0
  44. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/uv.lock +1 -1
  45. docker_mcp_server-2.0.0/docker_mcp/tools/nodes.py +0 -63
  46. docker_mcp_server-2.0.0/tests/test_nodes.py +0 -61
  47. docker_mcp_server-2.0.0/tests/test_services.py +0 -185
  48. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.claude/commands/docker-sdk.md +0 -0
  49. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.claude/settings.json +0 -0
  50. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.dockerignore +0 -0
  51. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/CODEOWNERS +0 -0
  52. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  53. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  54. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/actions/file-failure-issue/action.yaml +0 -0
  55. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/dependabot.yaml +0 -0
  56. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/release.yml +0 -0
  57. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.github/workflows/publish-homebrew.yaml +0 -0
  58. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.gitignore +0 -0
  59. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/.python-version +0 -0
  60. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/DOCKERHUB.md +0 -0
  61. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/Dockerfile +0 -0
  62. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/LICENSE +0 -0
  63. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/MIGRATION-2.0.md +0 -0
  64. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/PRIVACY.md +0 -0
  65. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/SECURITY.md +0 -0
  66. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/assets/README.md +0 -0
  67. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/assets/icon.png +0 -0
  68. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/__init__.py +0 -0
  69. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/__main__.py +0 -0
  70. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/_env.py +0 -0
  71. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/_hosts.py +0 -0
  72. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/__init__.py +0 -0
  73. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/_cli.py +0 -0
  74. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/_labels.py +0 -0
  75. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/_ssh_proxy.py +0 -0
  76. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/_utils.py +0 -0
  77. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/buildx.py +0 -0
  78. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/compose.py +0 -0
  79. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/context.py +0 -0
  80. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/scout.py +0 -0
  81. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/docker_mcp/tools/stack.py +0 -0
  82. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/glama.json +0 -0
  83. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/mcpb_run.py +0 -0
  84. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/scripts/build-mcpb.sh +0 -0
  85. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/scripts/docker-mcp-server.rb.tpl +0 -0
  86. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/server.json +0 -0
  87. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/__init__.py +0 -0
  88. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/conftest.py +0 -0
  89. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/__init__.py +0 -0
  90. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_buildx.py +0 -0
  91. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_cli.py +0 -0
  92. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_compose.py +0 -0
  93. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_context.py +0 -0
  94. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_file_payloads.py +0 -0
  95. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_networks.py +0 -0
  96. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_scout.py +0 -0
  97. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_smoke.py +0 -0
  98. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/integration/test_stack.py +0 -0
  99. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_buildx.py +0 -0
  100. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_cli.py +0 -0
  101. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_compose.py +0 -0
  102. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_configs.py +0 -0
  103. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_context.py +0 -0
  104. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_env.py +0 -0
  105. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_hosts.py +0 -0
  106. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_images.py +0 -0
  107. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_labels.py +0 -0
  108. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_main.py +0 -0
  109. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_networks.py +0 -0
  110. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_plugins.py +0 -0
  111. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_pyproject_pins.py +0 -0
  112. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_scout.py +0 -0
  113. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_secrets.py +0 -0
  114. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_ssh_proxy.py +0 -0
  115. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_stack.py +0 -0
  116. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_swarm.py +0 -0
  117. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_system.py +0 -0
  118. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_utils.py +0 -0
  119. {docker_mcp_server-2.0.0 → docker_mcp_server-2.1.0}/tests/test_volumes.py +0 -0
@@ -0,0 +1,14 @@
1
+ ## Summary
2
+
3
+ <!-- What does this change do, and why? -->
4
+
5
+ ## Test plan
6
+
7
+ <!-- How did you verify this? e.g. `uv run pytest -v`, `uv run ruff check . && uv run ruff format --check .`, `uv run pyright`, manual steps. -->
8
+
9
+ ## Checklist
10
+
11
+ - [ ] `uv run pytest -v`, `uv run ruff check .`, `uv run ruff format --check .`, and `uv run pyright` all pass locally
12
+ - [ ] If this adds or changes a tool: the ["Checklist when adding a new tool module"](https://github.com/GavinLucas/docker-mcp/blob/main/CONTRIBUTING.md#checklist-when-adding-a-new-tool-module) in `CONTRIBUTING.md` has been followed (tests, prompts, resources, README, naming convention)
13
+ - [ ] If this changes project structure, conventions, env vars, or the tool/prompt/resource surface: both `CLAUDE.md` and `.github/copilot-instructions.md` are updated (see the mirror rule at the top of `CLAUDE.md`)
14
+ - [ ] If this changes a dependency: `uv.lock` is updated (`uv lock`) and committed alongside `pyproject.toml`
@@ -26,7 +26,7 @@ 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()`). **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` 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
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.
32
32
 
@@ -148,15 +148,34 @@ def mcp_example(name: str):
148
148
  """
149
149
  Say hello to someone by name.
150
150
 
151
- args: name: str - The name to say hello to
151
+ args: name - The name to say hello to
152
152
  returns: str - The greeting
153
153
  """
154
154
  return f"Hello, {name}!"
155
155
  ```
156
156
 
157
157
  - One-line summary sentence, then a blank line
158
- - `args:` section lists each parameter as `name: type - description`
158
+ - `args:` section lists each parameter as `name - description`. Do **not** repeat the parameter's
159
+ type — the type annotation already lands in the tool's `inputSchema`, which the client sees
160
+ alongside the description, so a `name: type - ...` form just duplicates it as prose tokens. (The
161
+ `returns:` line keeps its type, since the return shape is not in the input schema.)
159
162
  - `returns:` line documents the return type and what it contains
163
+ - Keep descriptions terse: state every functional fact (defaults, accepted formats/values, return
164
+ keys, important caveats) but cut redundancy and verbose phrasing. The docstring is the entire
165
+ tool `description` the client pays tokens for on every session.
166
+ - **A one-line summary + bare `args`/`returns` is not enough for any tool with non-obvious
167
+ behavior.** If the tool has side effects, preconditions, a non-obvious failure mode, or overlaps
168
+ with another tool a caller could reach for instead, add a short paragraph (2-5 sentences,
169
+ between the summary and `args:`) covering: when to use it vs. the alternative, side
170
+ effects/preconditions, and concrete parameter formats/values — not just restating the signature.
171
+ This directly maps to what a low-scoring tool is missing on Glama's per-tool quality rubric
172
+ (`Behavior`, `Usage Guidelines`, `Parameters` sub-scores) — three separate PRs (#97, the 2.0
173
+ rename, #129) have had to chase down thin docstrings written without this paragraph. **Write it
174
+ this way the first time a tool is added or its behavior changes** — don't wait for a future
175
+ Glama pass to catch it. Verify every factual claim in that paragraph against the live docker-py
176
+ docs / Engine API spec per the Docker SDK Policy below — an unverified claim about identifier
177
+ semantics (e.g. "name or id" for a resource actually addressed by name only) is exactly the kind
178
+ of thing PR review catches late.
160
179
 
161
180
  ### Bounding rules
162
181
 
@@ -165,7 +184,9 @@ def mcp_example(name: str):
165
184
 
166
185
  ### MCP resources
167
186
 
168
- `docker_mcp/tools/resources.py` exposes `@mcp.resource(uri, mime_type=...)` endpoints (not tools) for read-only data: the Docker SDK for Python documentation under the `docker-docs://` URI scheme, `docker-mcp://tool-catalog` (the live tool/domain/category snapshot), `docker-mcp://hosts` (the resolved host registry), and the container-observability resources `docker://containers` / `docker-logs://{id_or_name}` / `docker-stats://{id_or_name}` (the last reuse `containers.py`'s private `_read_log_tail` / `_read_stats_summary`, now `host=`-aware, and refuse when the `containers` domain is disabled). **In multi-host mode these container resources are host-aware** — the default forms become empty-authority (`docker:///containers`, …) and host-qualified variants (`docker://{host}/containers`, …) register alongside, disambiguated by path-segment count; single-host keeps the bare forms. Registration is gated on `_hosts.is_multi()`. `_SECTION_DOMAINS` maps each doc section to a domain so `DOCKER_MCP_SERVER_DISABLE` hides a disabled domain's sections (registered with the server via `register_resource_domains`). Use the same docstring format as tools.
187
+ `docker_mcp/tools/resources.py` exposes `@mcp.resource(uri, mime_type=...)` endpoints (not tools) for read-only data: the Docker SDK for Python documentation under the `docker-docs://` URI scheme, `docker-mcp://tool-catalog` (the live tool/domain/category snapshot), `docker-mcp://hosts` (the resolved host registry), and three "watch this specific thing over time" observability families — **containers** (`docker://containers` / `docker-logs://{id_or_name}` / `docker-stats://{id_or_name}`, reusing `containers.py`'s private `_read_log_tail` / `_read_stats_summary`), **services** (`docker://services` / `service-logs://{id_or_name}` / `service-tasks://{id_or_name}`, reusing `services.py`'s private `_read_service_log_tail` / `_read_service_task_summary` — the latter also backs `service_wait`'s `running` mode), and **nodes** (`docker://nodes` index only — deliberately no per-node child resource, since that would need an unbounded per-service task fan-out with no single cheap call). All are `host=`-aware and refuse when their domain (`containers`/`services`/`nodes`) is disabled. **In multi-host mode all three families are host-aware** — the default forms become empty-authority (`docker:///containers`, `service-tasks:///{id}`, …) and host-qualified variants (`docker://{host}/containers`, `service-logs://{host}/{id}`, …) register alongside, disambiguated by path-segment count; single-host keeps the bare forms. Registration is gated on `_hosts.is_multi()`. `_SECTION_DOMAINS` maps each doc section to a domain so `DOCKER_MCP_SERVER_DISABLE` hides a disabled domain's sections (registered with the server via `register_resource_domains`). Use the same docstring format as tools.
188
+
189
+ `resources.py` also has one `@tool()`: **`docs_lookup(section=None)`** — a tool-callable mirror of `docker-docs://` for clients that can't read MCP resources (Claude Desktop, Cursor). It wraps `list_docs_sections()`/`get_docs_section()` directly (same behavior either way, including per-section domain refusal) and is one of `_NO_DOMAIN_TOOLS` — always registered regardless of `DOCKER_MCP_SERVER_DISABLE`. Several `extra_kwargs`-heavy tool docstrings and docs-reliant prompts point at it as the resource-read fallback — do the same for new ones in that shape.
169
190
 
170
191
  ### MCP prompts
171
192
 
@@ -42,6 +42,11 @@ jobs:
42
42
 
43
43
  - name: Set up uv
44
44
  uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
45
+ with:
46
+ # Pinned (matching the Dockerfile's uv image tag) so this step never needs the
47
+ # live "resolve latest" fetch to raw.githubusercontent.com — that network call
48
+ # failed transiently on 2026-07-06 and blocked an unrelated PR (#130).
49
+ version: "0.11.21"
45
50
 
46
51
  - name: Resolve runtime deps as wheels-only for each platform
47
52
  # --only-binary :all: makes resolution fail if any dependency lacks a wheel for the
@@ -103,6 +108,9 @@ jobs:
103
108
  # repo — so disable the repo-keyed cache and the empty-workdir warning it triggers.
104
109
  enable-cache: false
105
110
  ignore-empty-workdir: true
111
+ # Pinned (matching the Dockerfile's uv image tag) so this step never needs the
112
+ # live "resolve latest" fetch to raw.githubusercontent.com.
113
+ version: "0.11.21"
106
114
 
107
115
  - name: Import smoke (full tool-registration path, no daemon needed)
108
116
  run: uv run --no-project --python 3.14 --with docker-mcp-server python -c "import docker_mcp"
@@ -31,12 +31,12 @@ jobs:
31
31
  uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
32
32
 
33
33
  - name: Initialize CodeQL
34
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
34
+ uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
35
35
  with:
36
36
  languages: ${{ matrix.language }}
37
37
 
38
38
  - name: Autobuild
39
- uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
39
+ uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
40
40
 
41
41
  - name: Perform CodeQL Analysis
42
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
42
+ uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
@@ -50,10 +50,10 @@ jobs:
50
50
  uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
51
51
 
52
52
  - name: Set up Docker Buildx
53
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
53
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
54
54
 
55
55
  - name: Build image (${{ matrix.variant }})
56
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
56
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
57
57
  with:
58
58
  context: .
59
59
  platforms: linux/amd64
@@ -85,13 +85,13 @@ jobs:
85
85
  uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
86
86
 
87
87
  - name: Set up QEMU
88
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
88
+ uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
89
89
 
90
90
  - name: Set up Docker Buildx
91
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
91
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
92
92
 
93
93
  - name: Build full variant for amd64 + arm64
94
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
94
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
95
95
  with:
96
96
  context: .
97
97
  platforms: linux/amd64,linux/arm64
@@ -176,6 +176,12 @@ jobs:
176
176
  docker buildx version
177
177
  docker scout version 2>/dev/null || echo "scout not installed; scout integration tests will skip"
178
178
 
179
+ - name: Initialize a single-node swarm
180
+ # Needed for service_wait / node_wait integration tests (skip_if_no_swarm), which
181
+ # otherwise skip cleanly on a non-swarm daemon. No teardown needed — the runner is
182
+ # ephemeral and torn down after the job.
183
+ run: docker swarm init
184
+
179
185
  - name: Optional Docker Hub login to dodge anonymous rate limits
180
186
  # Opt-in: set DOCKERHUB_USER / DOCKERHUB_TOKEN as repo secrets to authenticate.
181
187
  # Without them, the job runs anonymous and the existing skip-on-pull-failure
@@ -219,17 +219,17 @@ jobs:
219
219
  echo "Publishing \`${{ matrix.variant }}\` as: $tags" >> "$GITHUB_STEP_SUMMARY"
220
220
 
221
221
  - name: Set up QEMU
222
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
222
+ uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
223
223
  with:
224
224
  # The two matrix jobs race to cache the same binfmt image; skip the cache to avoid the
225
225
  # harmless "unable to reserve cache" warning while keeping both builds parallel.
226
226
  cache-image: false
227
227
 
228
228
  - name: Set up Docker Buildx
229
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
229
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
230
230
 
231
231
  - name: Log in to GHCR
232
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
232
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
233
233
  with:
234
234
  registry: ghcr.io
235
235
  username: ${{ github.actor }}
@@ -237,13 +237,13 @@ jobs:
237
237
 
238
238
  - name: Log in to Docker Hub
239
239
  if: env.DOCKERHUB_USER != '' && env.DOCKERHUB_TOKEN != ''
240
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
240
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
241
241
  with:
242
242
  username: ${{ secrets.DOCKERHUB_USER }}
243
243
  password: ${{ secrets.DOCKERHUB_TOKEN }}
244
244
 
245
245
  - name: Build and push ${{ matrix.variant }}
246
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
246
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
247
247
  with:
248
248
  context: .
249
249
  platforms: linux/amd64,linux/arm64
@@ -27,6 +27,8 @@ DOCKERHUB.md
27
27
  # Agent / project docs not needed inside the bundle.
28
28
  CLAUDE.md
29
29
  SECURITY.md
30
+ CONTRIBUTING.md
31
+ CODE_OF_CONDUCT.md
30
32
 
31
33
  # Caches and VCS.
32
34
  __pycache__/
@@ -90,6 +90,8 @@ After registering each tool the decorator also calls `_slim_schema` on the tool'
90
90
 
91
91
  The decorator also records each tool's **domain** — the leaf of its defining module (`docker_mcp.tools.containers` → `containers`) — so the orthogonal `DOCKER_MCP_SERVER_DISABLE=<domains>` switch can drop a whole feature area (e.g. `swarm,plugins`) from the registered surface regardless of category. A tool registers only if its category survives the read-only switches *and* its domain is not disabled. `DOCKER_MCP_SERVER_DISABLE` reaches beyond tools: the `prompt(domain=...)` helper skips a disabled domain's prompts, and `resources.py` hides a disabled domain's doc sections — so disabling e.g. `scout` drops its tools, its prompts, and its `docker-docs://scout` sections together. The full picture (every tool's domain/category, plus the `prompts` list and `disabled_doc_sections`) is exposed via `tool_catalog()` and the `docker-mcp://tool-catalog` resource, so the classification is auditable at runtime, not just in the source map.
92
92
 
93
+ 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
+
93
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).
94
96
 
95
97
  ### Multi-daemon host registry (`docker_mcp/_hosts.py`)
@@ -260,10 +262,25 @@ def mcp_example(name: str):
260
262
  - Keep descriptions terse: state every functional fact (defaults, accepted formats/values, return
261
263
  keys, important caveats) but cut redundancy and verbose phrasing. The docstring is the entire
262
264
  tool `description` the client pays tokens for on every session.
265
+ - **A one-line summary + bare `args`/`returns` is not enough for any tool with non-obvious
266
+ behavior.** If the tool has side effects, preconditions, a non-obvious failure mode, or overlaps
267
+ with another tool a caller could reach for instead, add a short paragraph (2-5 sentences,
268
+ between the summary and `args:`) covering: when to use it vs. the alternative, side
269
+ effects/preconditions, and concrete parameter formats/values — not just restating the signature.
270
+ This directly maps to what a low-scoring tool is missing on Glama's per-tool quality rubric
271
+ (`Behavior`, `Usage Guidelines`, `Parameters` sub-scores) — see [[project_glama_docstring_quality]]
272
+ in memory for the three rounds of cleanup (#97, the 2.0 rename, #129) this has already taken to
273
+ chase down thin docstrings written without this paragraph. **Write it this way the first time a
274
+ tool is added or its behavior changes** — don't wait for a future Glama pass to catch it. Verify
275
+ every factual claim in that paragraph against the live docker-py docs / Engine API spec per the
276
+ Docker SDK Policy below — an unverified claim about identifier semantics (e.g. "name or id" for a
277
+ resource actually addressed by name only) is exactly the kind of thing PR review catches late.
263
278
 
264
279
  ### MCP resources
265
280
 
266
- `docker_mcp/tools/resources.py` exposes `@mcp.resource(uri, mime_type=...)` endpoints (not tools) for read-only data: the Docker SDK for Python documentation under the `docker-docs://` URI scheme, plus `docker-mcp://tool-catalog` (the live tool/domain/category snapshot from `server.tool_catalog()`), `docker-mcp://hosts` (the resolved host registry, mirroring `host_list`), and the container-observability resources `docker://containers` (index of all containers + per-container URIs), `docker-logs://{id_or_name}` (bounded log tail), and `docker-stats://{id_or_name}` (computed usage summary). **In multi-host mode these container resources are host-aware:** the default-host forms become empty-authority (`docker:///containers`, `docker-logs:///{id}`, `docker-stats:///{id}`) and host-qualified variants (`docker://{host}/containers`, `docker-logs://{host}/{id}`, …) are registered alongside, disambiguated by path-segment count; single-host keeps the bare forms unchanged. Registration is gated on `_hosts.is_multi()`, and the index emits child `logs`/`stats` URIs matching its own scheme. The container resources reuse the private `_read_log_tail` / `_read_stats_summary` helpers in `containers.py` (which take a `host=`) and refuse at read time when the `containers` domain is disabled (mirroring `get_docs_section`). Each doc section maps to a domain via `_SECTION_DOMAINS` (registered with the server through `register_resource_domains`), so `DOCKER_MCP_SERVER_DISABLE` hides a disabled domain's sections from `docker-docs://contents` and makes `get_docs_section` refuse them. Resources follow the same docstring format as tools and are also star-imported via `docker_mcp/tools/__init__.py`.
281
+ `docker_mcp/tools/resources.py` exposes `@mcp.resource(uri, mime_type=...)` endpoints (not tools) for read-only data: the Docker SDK for Python documentation under the `docker-docs://` URI scheme, plus `docker-mcp://tool-catalog` (the live tool/domain/category snapshot from `server.tool_catalog()`), `docker-mcp://hosts` (the resolved host registry, mirroring `host_list`), and three families of "watch this specific thing over time" observability resources — **containers** (`docker://containers` index, `docker-logs://{id_or_name}` bounded log tail, `docker-stats://{id_or_name}` computed usage summary), **services** (`docker://services` index, `service-logs://{id_or_name}` bounded log tail, `service-tasks://{id_or_name}` computed task/rollout summary — running vs. desired task counts, failing tasks, and `UpdateStatus.State` if a rolling update is in progress), and **nodes** (`docker://nodes` index only — state/availability/role/reachability per node; deliberately no per-node child resource, since a "tasks on this node" view would need an unbounded fan-out across every service's tasks with no single cheap call, unlike the other two families). **In multi-host mode all three families are host-aware:** the default-host forms become empty-authority (`docker:///containers`, `docker-logs:///{id}`, `service-tasks:///{id}`, …) and host-qualified variants (`docker://{host}/containers`, `service-logs://{host}/{id}`, …) are registered alongside, disambiguated by path-segment count; single-host keeps the bare forms unchanged. Registration is gated on `_hosts.is_multi()`, and each index emits child `logs`/`stats`/`tasks` URIs matching its own scheme. Container resources reuse the private `_read_log_tail` / `_read_stats_summary` helpers in `containers.py`; service resources reuse `_read_service_log_tail` / `_read_service_task_summary` in `services.py` (the latter also backs `service_wait`'s `running` mode — see Swarm tools below); all refuse at read time when their domain (`containers`/`services`/`nodes`) is disabled (mirroring `get_docs_section`). Each doc section maps to a domain via `_SECTION_DOMAINS` (registered with the server through `register_resource_domains`), so `DOCKER_MCP_SERVER_DISABLE` hides a disabled domain's sections from `docker-docs://contents` and makes `get_docs_section` refuse them. Resources follow the same docstring format as tools and are also star-imported via `docker_mcp/tools/__init__.py`.
282
+
283
+ `resources.py` also has one `@tool()`: **`docs_lookup(section=None)`** — a tool-callable mirror of the `docker-docs://` family for clients that can't read MCP resources (e.g. Claude Desktop, Cursor). It calls `list_docs_sections()`/`get_docs_section()` directly rather than duplicating their logic, so behavior (including the per-section domain refusal above) is identical either way. It's one of `_NO_DOMAIN_TOOLS` (see "Server singleton" above) — always registered regardless of `DOCKER_MCP_SERVER_DISABLE` — since looking something up isn't tied to any one feature area. Several tool docstrings (`container_run`/`container_create`/`service_create`'s `extra_kwargs`) and prompts (`lookup_docker_docs`, `verify_docker_method`, `review_dockerfile`, `audit_container_security`) point at it as the fallback when a client can't read the equivalent resource — a new passthrough-heavy tool or docs-reliant prompt should do the same.
267
284
 
268
285
  ### MCP prompts
269
286
 
@@ -0,0 +1,83 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
6
+
7
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8
+
9
+ ## Our Standards
10
+
11
+ Examples of behavior that contributes to a positive environment for our community include:
12
+
13
+ * Demonstrating empathy and kindness toward other people
14
+ * Being respectful of differing opinions, viewpoints, and experiences
15
+ * Giving and gracefully accepting constructive feedback
16
+ * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17
+ * Focusing on what is best not just for us as individuals, but for the overall community
18
+
19
+ Examples of unacceptable behavior include:
20
+
21
+ * The use of sexualized language or imagery, and sexual attention or advances of any kind
22
+ * Trolling, insulting or derogatory comments, and personal or political attacks
23
+ * Public or private harassment
24
+ * Publishing others' private information, such as a physical or email address, without their explicit permission
25
+ * Other conduct which could reasonably be considered inappropriate in a professional setting
26
+
27
+ ## Enforcement Responsibilities
28
+
29
+ Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
30
+
31
+ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
32
+
33
+ ## Scope
34
+
35
+ This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
36
+
37
+ ## Enforcement
38
+
39
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Gavin@L337.org](mailto:Gavin@L337.org). All complaints will be reviewed and investigated promptly and fairly.
40
+
41
+ All community leaders are obligated to respect the privacy and security of the reporter of any incident.
42
+
43
+ ## Enforcement Guidelines
44
+
45
+ Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
46
+
47
+ ### 1. Correction
48
+
49
+ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
50
+
51
+ **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
52
+
53
+ ### 2. Warning
54
+
55
+ **Community Impact**: A violation through a single incident or series of actions.
56
+
57
+ **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
58
+
59
+ ### 3. Temporary Ban
60
+
61
+ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
62
+
63
+ **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
64
+
65
+ ### 4. Permanent Ban
66
+
67
+ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
68
+
69
+ **Consequence**: A permanent ban from any sort of public interaction within the community.
70
+
71
+ ## Attribution
72
+
73
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
74
+
75
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
76
+
77
+ For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
78
+
79
+ [homepage]: https://www.contributor-covenant.org
80
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
81
+ [Mozilla CoC]: https://github.com/mozilla/diversity
82
+ [FAQ]: https://www.contributor-covenant.org/faq
83
+ [translations]: https://www.contributor-covenant.org/translations
@@ -0,0 +1,188 @@
1
+ # Contributing to docker-mcp-server
2
+
3
+ Contributions are welcome. The project values a tight mapping between the Docker SDK's public surface and the MCP tools we expose. By participating, you're expected to uphold the [Code of Conduct](CODE_OF_CONDUCT.md).
4
+
5
+ ## Before you start
6
+
7
+ This is an [MCP](https://modelcontextprotocol.io) server: it exposes Docker operations as `@tool()`-decorated Python functions that an AI agent calls over the Model Context Protocol. If that sentence is new to you, the short version is: each function in `docker_mcp/tools/` becomes one callable "tool" an agent can invoke, with the function's docstring as the description the agent sees and its type-annotated parameters as the input schema.
8
+
9
+ The practical mental model for finding your way around:
10
+
11
+ - One file per Docker feature area (`containers.py`, `images.py`, `swarm.py`, …), each backed by either the [docker-py SDK](https://docker-py.readthedocs.io/en/stable/) or a `docker` CLI shell-out (`compose.py`, `buildx.py`, …). See the tree below for the full map.
12
+ - Every tool function is registered centrally in `docker_mcp/server.py`, which is also where read-only/destructive classification, naming enforcement, and the domain-disable switches live.
13
+ - `CLAUDE.md` (repo root) is the full architecture reference — written to brief Claude Code, but equally the canonical source for humans. This file (`CONTRIBUTING.md`) covers the practical day-to-day: setup, the checklist for adding a tool, testing, and submitting changes. If something isn't covered here, it's almost certainly in `CLAUDE.md`.
14
+
15
+ ## Project layout
16
+
17
+ ```
18
+ .
19
+ ├── docker_mcp/ # the package — `python -m docker_mcp` runs the server
20
+ │ ├── __init__.py # defines `main()`; side-effect-imports `server` and `tools`
21
+ │ ├── __main__.py # calls `main()` so `python -m docker_mcp` works
22
+ │ ├── server.py # creates the FastMCP singleton (`mcp`) shared by every tool module
23
+ │ └── tools/ # one file per Docker SDK domain or CLI/registry feature
24
+ │ ├── _cli.py # cross-platform subprocess helper for docker CLI shell-outs (private)
25
+ │ ├── _utils.py # shared helpers (drop_none, join_bounded, MAX_PAYLOAD_BYTES) (private)
26
+ │ ├── system.py # DockerClient connection + lazy `_get_client()` helper
27
+ │ ├── containers.py
28
+ │ ├── images.py
29
+ │ ├── networks.py
30
+ │ ├── volumes.py
31
+ │ ├── configs.py
32
+ │ ├── secrets.py
33
+ │ ├── nodes.py
34
+ │ ├── services.py
35
+ │ ├── swarm.py
36
+ │ ├── plugins.py
37
+ │ ├── compose.py # `docker compose` CLI plugin (shells out via _cli.py)
38
+ │ ├── stack.py # `docker stack` (Compose-on-Swarm) CLI (shells out via _cli.py)
39
+ │ ├── context.py # `docker context` CLI (shells out via _cli.py)
40
+ │ ├── buildx.py # `docker buildx` CLI plugin (shells out via _cli.py)
41
+ │ ├── scout.py # `docker scout` CLI plugin (shells out via _cli.py)
42
+ │ ├── registry.py # OCI v2 registries + Docker Hub HTTPS APIs (no daemon)
43
+ │ ├── prompts.py # @prompt(...) templates for common docker workflows
44
+ │ └── resources.py # @mcp.resource() endpoints exposing SDK + CLI + registry docs
45
+ ├── tests/ # pytest suite, mirrors `docker_mcp/tools/` one-to-one
46
+ │ └── integration/ # tests that hit a real Docker daemon or docker.io
47
+ ├── assets/ # bundle assets (e.g. the .mcpb icon) packed into the Desktop Extension
48
+ ├── scripts/ # developer convenience scripts (not used by CI) — e.g. build-mcpb.sh
49
+ └── dist/ # build output (git-ignored) — local .mcpb test bundles land here
50
+ ```
51
+
52
+ Each `docker_mcp/tools/<file>.py` has a matching `tests/test_<file>.py`. New modules must be added to `docker_mcp/tools/__init__.py` and have a corresponding test file. Tool modules that wrap CLI features must funnel every subprocess call through `docker_mcp/tools/_cli.py` so the cross-platform safety concerns (binary discovery, no shell, UTF-8 decoding, output capping, Windows console suppression, env scrubbing) live in one place.
53
+
54
+ New Docker functionality goes in the matching existing file (e.g. a new volume operation goes in `volumes.py`), not a new file — a new `docker_mcp/tools/<domain>.py` is only for a Docker feature area that doesn't exist in the tree above.
55
+
56
+ ## Conventions
57
+
58
+ Tool functions are decorated with `@tool()` (the project's wrapper around `@mcp.tool()`, imported from `docker_mcp.server`) and follow this docstring style:
59
+
60
+ ```python
61
+ from docker_mcp.server import tool
62
+
63
+
64
+ @tool()
65
+ def mcp_example(name: str):
66
+ """
67
+ Say hello to someone by name.
68
+
69
+ args: name - The name to say hello to
70
+ returns: str - The greeting
71
+ """
72
+ return f"Hello, {name}!"
73
+ ```
74
+
75
+ - Tool modules import `tool` from `docker_mcp.server`; prompt modules import `prompt` from `docker_mcp.server`. Only resource modules (`@mcp.resource(...)`) import `mcp` directly — never import `mcp` directly in a tool or prompt module, that creates a circular import.
76
+ - Every tool needs a `TOOL_CATEGORIES` entry in `docker_mcp/server.py` (`READ_ONLY` / `MUTATING` / `DESTRUCTIVE`); the central map drives the tool's `ToolAnnotations` and the read-only env switches, and `tests/test_server.py` fails if it drifts from the registered set. A tool's *domain* (for `DOCKER_MCP_SERVER_DISABLE` and the tool catalog) is derived automatically from its module name, so putting a tool in the right `docker_mcp/tools/<domain>.py` file is all that's needed.
77
+ - Tool names follow `<management-command>_<verb>`, anchored to the docker CLI's own management-command structure (`docker container ls` → `container_list`), with long-form verbs (`list`/`remove`/`inspect`, never `ls`/`rm`/`get`). `tests/test_naming.py` enforces this — a new tool with a short-form verb or an unapproved prefix fails CI. See `CLAUDE.md`'s "Tool naming convention" section for the full rule, including identifier parameter naming (`id_or_name`, `name`/`names`, `repository`).
78
+ - Line length is 120 characters (enforced by ruff).
79
+ - CLI shell-outs must go through `docker_mcp/tools/_cli.py:run_docker` — never call `subprocess.run` directly from a tool module. The helper enforces `shell=False`, resolves the binary via `shutil.which` (cross-platform), decodes output as UTF-8 with replace, caps the captured bytes, scrubs the environment, and suppresses console pop-ups on Windows.
80
+ - A tool with non-obvious behavior (side effects, preconditions, a non-obvious failure mode, or overlap with another tool) needs more than a one-line summary — add a short paragraph covering when to use it vs. the alternative, side effects/preconditions, and concrete parameter formats. Verify any factual claim against the live docker-py docs / Engine API spec rather than assuming; see [`CLAUDE.md`](CLAUDE.md)'s "Tool function format" section for the full convention and why it matters.
81
+
82
+ ## Verifying the SDK before writing code
83
+
84
+ To prevent hallucinated method names, the project includes a `/docker-sdk` Claude Code skill that fetches the live Docker SDK for Python documentation, inventories what's already exposed, and produces a gap analysis. Run it before adding new tools:
85
+
86
+ ```
87
+ /docker-sdk # full gap analysis
88
+ /docker-sdk containers # focus on a single domain
89
+ ```
90
+
91
+ If you're not using Claude Code, check method names and signatures directly against the [docker-py docs](https://docker-py.readthedocs.io/en/stable/index.html) (or, for SDK gaps, the [low-level `APIClient`](https://docker-py.readthedocs.io/en/stable/api.html)) before writing a call — don't assume a method exists because it sounds plausible.
92
+
93
+ ## Testing conventions
94
+
95
+ Unit tests mock the Docker client rather than hitting a real daemon, patching `_get_client` (or, for CLI-backed modules, `run_docker`) at the point the tool module looks it up. A minimal example, from `tests/test_volumes.py`:
96
+
97
+ ```python
98
+ from unittest.mock import MagicMock, patch
99
+
100
+ from docker_mcp.tools.volumes import volume_inspect
101
+
102
+
103
+ def test_volume_inspect():
104
+ volume = MagicMock()
105
+ volume.attrs = {"Name": "myvol"}
106
+ with patch("docker_mcp.tools.volumes._get_client") as mock_client:
107
+ mock_client.return_value.volumes.get.return_value = volume
108
+ assert volume_inspect("myvol") == {"Name": "myvol"}
109
+ ```
110
+
111
+ Integration tests (`tests/integration/`) call the real tool function against an actual Docker daemon and are auto-marked `@pytest.mark.integration` by `tests/integration/conftest.py`, which also provides an autouse `skip_if_no_daemon` fixture — they're skipped automatically (not failed) when no daemon is reachable, so it's safe to run the full suite without Docker running. They're excluded from the default `pytest` invocation (`-m 'not integration'` in `pyproject.toml`); run them explicitly with `uv run pytest -m integration -v`.
112
+
113
+ ## Checklist when adding a new tool module
114
+
115
+ When you add a new `docker_mcp/tools/<domain>.py`, also update:
116
+
117
+ 1. **`docker_mcp/tools/__init__.py`** — star-import the module (private helpers prefixed with `_` are excluded).
118
+ 2. **`tests/test_<domain>.py`** — unit tests using mocks (no real daemon).
119
+ 3. **`tests/integration/test_<domain>.py`** — at least one happy-path test against a real daemon (or override the `skip_if_no_daemon` fixture if the module doesn't need one).
120
+ 4. **`docker_mcp/tools/prompts.py`** — at least one `@prompt(...)` template that exercises the new tools end-to-end.
121
+ 5. **`docker_mcp/tools/resources.py`** — add an entry under `SDK_SECTIONS` or `EXTERNAL_SECTIONS` if the new domain has authoritative docs the agent should be able to read at runtime.
122
+ 6. **README.md** — append to the "What the agent can do" list and (if relevant) the "Security considerations" section.
123
+ 7. **SECURITY.md** — only if the new module exposes a new class of risk not already covered by the README's Security section.
124
+
125
+ If you're only adding a tool or two to an *existing* domain file rather than a whole new module, items 1 and 7 don't apply, but 2-6 still do wherever relevant (e.g. a new tool still needs unit + integration test cases, even if the test file itself already exists).
126
+
127
+ ## Local development
128
+
129
+ ```bash
130
+ # install dependencies (creates .venv, installs runtime + dev deps)
131
+ uv sync
132
+
133
+ # run the server
134
+ uv run python -m docker_mcp
135
+ # …or via the installed console script
136
+ uv run docker-mcp
137
+
138
+ # unit tests (integration tests are excluded by default)
139
+ uv run pytest -v
140
+
141
+ # integration tests — require a running Docker daemon at $DOCKER_HOST
142
+ uv run pytest -m integration -v
143
+
144
+ # lint, format, type-check
145
+ uv run ruff check .
146
+ uv run ruff format .
147
+ uv run pyright
148
+
149
+ # install the pre-commit hook (one-time, runs ruff on every commit)
150
+ uv run pre-commit install
151
+
152
+ # add a runtime dependency
153
+ uv add <package>
154
+
155
+ # add a development dependency
156
+ uv add --group dev <package>
157
+ ```
158
+
159
+ ## Submitting your change
160
+
161
+ CI (`.github/workflows/premerge.yaml`) runs on every pull request and push to `main`, and must pass before merging:
162
+
163
+ - `pytest` — two separate jobs: unit tests (`uv run pytest -v`, same as local) and integration tests (`uv run pytest -m integration -v`) against the real Docker Engine the `ubuntu-latest` runner ships with. You don't need a local daemon to contribute — `uv run pytest -v` alone (which excludes integration tests by default) is enough to check your change; the integration job will exercise it in CI.
164
+ - `ruff check` and `ruff format --check` — lint and formatting; run `uv run ruff check . && uv run ruff format .` locally to fix both before committing (the pre-commit hook from `uv run pre-commit install` runs `ruff check --fix` and `ruff format` automatically on each commit, so this is usually already handled for you).
165
+ - `pyright` — type-check.
166
+ - CI installs with `uv sync --locked`, which **fails** if `uv.lock` disagrees with `pyproject.toml` rather than silently re-locking. If you change a dependency, run `uv lock` and commit the updated lockfile alongside `pyproject.toml`.
167
+
168
+ A separate, non-blocking `Check docs mirror` job flags a PR that edits `CLAUDE.md` or `.github/copilot-instructions.md` without the other — the two files intentionally mirror each other (Copilot's own PR review is driven by `copilot-instructions.md`), so a change to project structure, conventions, or the tool surface should update both.
169
+
170
+ Keep PRs focused — one logical change per PR is easier to review than a bundle of unrelated fixes. For anything beyond a small, self-contained fix, consider opening an issue first (see "Reporting issues" below) so the approach can be discussed before you invest time in an implementation.
171
+
172
+ ## Building a local Desktop Extension (.mcpb)
173
+
174
+ To smoke-test the Claude Desktop Extension locally, pack a bundle with the developer helper in `scripts/`:
175
+
176
+ ```bash
177
+ # pack dist/docker-mcp-server-<version>.mcpb (auto-increments -1, -2, … if it exists)
178
+ scripts/build-mcpb.sh
179
+
180
+ # …or give it an explicit name (a .mcpb extension is added if missing)
181
+ scripts/build-mcpb.sh my-test-bundle
182
+ ```
183
+
184
+ It reads the version from `pyproject.toml`, creates `dist/` if needed, writes a `.sha256` alongside the bundle, and packs via Anthropic's `mcpb` CLI (a global `mcpb`, else `npx @anthropic-ai/mcpb`; see `--help` for the `MCPB=` override). The official release bundle is built separately by the `mcpb` job in `.github/workflows/publish.yaml` — this script is for local testing only and is **not** used by CI.
185
+
186
+ ## Reporting issues
187
+
188
+ Bug reports and feature requests have templates that you can choose when you [`create an issue`](https://github.com/GavinLucas/docker-mcp/issues/new/choose). Please select the correct issue type and follow the template.