docker-mcp-server 2.0.1__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.0.1 → docker_mcp_server-2.1.0}/.github/copilot-instructions.md +4 -2
  2. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/premerge.yaml +6 -0
  3. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/CLAUDE.md +5 -1
  4. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/PKG-INFO +8 -8
  5. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/README.md +7 -7
  6. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/server.py +28 -8
  7. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/containers.py +80 -30
  8. docker_mcp_server-2.1.0/docker_mcp/tools/nodes.py +136 -0
  9. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/prompts.py +29 -14
  10. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/registry.py +63 -0
  11. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/resources.py +239 -4
  12. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/services.py +159 -1
  13. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/system.py +6 -0
  14. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/manifest.json +1 -1
  15. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/pyproject.toml +1 -1
  16. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/conftest.py +11 -2
  17. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_containers.py +24 -0
  18. docker_mcp_server-2.1.0/tests/integration/test_nodes.py +24 -0
  19. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_registry.py +9 -0
  20. docker_mcp_server-2.1.0/tests/integration/test_services.py +54 -0
  21. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_containers.py +91 -0
  22. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_naming.py +1 -0
  23. docker_mcp_server-2.1.0/tests/test_nodes.py +130 -0
  24. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_prompts.py +24 -2
  25. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_registry.py +59 -0
  26. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_resources.py +220 -14
  27. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_server.py +24 -5
  28. docker_mcp_server-2.1.0/tests/test_services.py +417 -0
  29. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/uv.lock +1 -1
  30. docker_mcp_server-2.0.1/docker_mcp/tools/nodes.py +0 -63
  31. docker_mcp_server-2.0.1/tests/test_nodes.py +0 -61
  32. docker_mcp_server-2.0.1/tests/test_services.py +0 -185
  33. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.claude/commands/docker-sdk.md +0 -0
  34. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.claude/settings.json +0 -0
  35. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.dockerignore +0 -0
  36. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/CODEOWNERS +0 -0
  37. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  38. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  39. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  40. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/actions/file-failure-issue/action.yaml +0 -0
  41. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/dependabot.yaml +0 -0
  42. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/release.yml +0 -0
  43. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/canary.yaml +0 -0
  44. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/codeql.yaml +0 -0
  45. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/images.yaml +0 -0
  46. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/publish-homebrew.yaml +0 -0
  47. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/publish.yaml +0 -0
  48. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.gitignore +0 -0
  49. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.mcpbignore +0 -0
  50. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.python-version +0 -0
  51. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/CODE_OF_CONDUCT.md +0 -0
  52. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/CONTRIBUTING.md +0 -0
  53. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/DOCKERHUB.md +0 -0
  54. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/Dockerfile +0 -0
  55. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/LICENSE +0 -0
  56. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/MIGRATION-2.0.md +0 -0
  57. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/PRIVACY.md +0 -0
  58. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/SECURITY.md +0 -0
  59. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/assets/README.md +0 -0
  60. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/assets/icon.png +0 -0
  61. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/__init__.py +0 -0
  62. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/__main__.py +0 -0
  63. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/_env.py +0 -0
  64. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/_hosts.py +0 -0
  65. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/__init__.py +0 -0
  66. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_cli.py +0 -0
  67. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_labels.py +0 -0
  68. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_ssh_proxy.py +0 -0
  69. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_utils.py +0 -0
  70. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/buildx.py +0 -0
  71. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/compose.py +0 -0
  72. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/configs.py +0 -0
  73. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/context.py +0 -0
  74. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/images.py +0 -0
  75. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/networks.py +0 -0
  76. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/plugins.py +0 -0
  77. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/scout.py +0 -0
  78. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/secrets.py +0 -0
  79. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/stack.py +0 -0
  80. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/swarm.py +0 -0
  81. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/volumes.py +0 -0
  82. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/glama.json +0 -0
  83. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/mcpb_run.py +0 -0
  84. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/scripts/build-mcpb.sh +0 -0
  85. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/scripts/docker-mcp-server.rb.tpl +0 -0
  86. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/server.json +0 -0
  87. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/__init__.py +0 -0
  88. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/conftest.py +0 -0
  89. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/__init__.py +0 -0
  90. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_buildx.py +0 -0
  91. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_cli.py +0 -0
  92. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_compose.py +0 -0
  93. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_context.py +0 -0
  94. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_file_payloads.py +0 -0
  95. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_networks.py +0 -0
  96. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_scout.py +0 -0
  97. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_smoke.py +0 -0
  98. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_stack.py +0 -0
  99. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_buildx.py +0 -0
  100. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_cli.py +0 -0
  101. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_compose.py +0 -0
  102. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_configs.py +0 -0
  103. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_context.py +0 -0
  104. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_env.py +0 -0
  105. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_hosts.py +0 -0
  106. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_images.py +0 -0
  107. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_labels.py +0 -0
  108. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_main.py +0 -0
  109. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_networks.py +0 -0
  110. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_plugins.py +0 -0
  111. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_pyproject_pins.py +0 -0
  112. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_scout.py +0 -0
  113. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_secrets.py +0 -0
  114. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_ssh_proxy.py +0 -0
  115. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_stack.py +0 -0
  116. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_swarm.py +0 -0
  117. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_system.py +0 -0
  118. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_utils.py +0 -0
  119. {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_volumes.py +0 -0
@@ -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
 
@@ -184,7 +184,9 @@ def mcp_example(name: str):
184
184
 
185
185
  ### MCP resources
186
186
 
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 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.
188
190
 
189
191
  ### MCP prompts
190
192
 
@@ -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
@@ -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`)
@@ -276,7 +278,9 @@ def mcp_example(name: str):
276
278
 
277
279
  ### MCP resources
278
280
 
279
- `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.
280
284
 
281
285
  ### MCP prompts
282
286
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-mcp-server
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: MCP server for managing Docker resources via the Docker SDK for Python
5
5
  Project-URL: Homepage, https://github.com/GavinLucas/docker-mcp
6
6
  Project-URL: Repository, https://github.com/GavinLucas/docker-mcp
@@ -229,29 +229,29 @@ Everything above targets one daemon. To manage **several in a single session**
229
229
  - **`(ro)`** after an endpoint marks that host **read-only**: mutating and destructive tools refuse to act on it. This is a per-host guard enforced at call time, independent of the server-wide `DOCKER_MCP_SERVER_READONLY` switch — mark production `(ro)` and the agent can inspect it all day but can't change it, while local stays read-write.
230
230
  - **Single daemon, simpler form.** A bare value with no `name=` is shorthand for one host — `DOCKER_MCP_SERVER_HOSTS=ssh://ops@prod` (or `auto`, or blank). So this one field also covers the single-remote case. `DOCKER_HOST` keeps working when `DOCKER_MCP_SERVER_HOSTS` is unset, but **`DOCKER_MCP_SERVER_HOSTS` takes over when set** (`DOCKER_HOST` is then ignored, with a one-time notice to stderr).
231
231
 
232
- **How the agent drives it.** With two or more hosts, every daemon-targeting tool gains an optional **`host`** argument constrained to your configured names: read-only tools default to the first host when you omit it, while mutating and destructive tools **require** an explicit `host` (so the agent can't change the wrong daemon by accident). `host_list` (and the `docker-mcp://hosts` resource) report the configured hosts and which is the default; the container observability resources become host-aware — the **default** host's index is `docker:///containers` (note the empty authority) and a **named** host's is `docker://{host}/containers` (likewise `docker-logs:///{id}` vs `docker-logs://{host}/{id}`); the single-host `docker://containers` form is not registered once several hosts are configured. The `survey_hosts` prompt sweeps every host read-only. The `auto`/`local` endpoints are resolved to concrete URLs and **pinned at startup**, so the SDK and CLI always agree on which daemon a name means — restart to re-resolve after changing a Docker context.
232
+ **How the agent drives it.** With two or more hosts, every daemon-targeting tool gains an optional **`host`** argument constrained to your configured names: read-only tools default to the first host when you omit it, while mutating and destructive tools **require** an explicit `host` (so the agent can't change the wrong daemon by accident). `host_list` (and the `docker-mcp://hosts` resource) report the configured hosts and which is the default; the container/service/node observability resources become host-aware — the **default** host's index is `docker:///containers` (note the empty authority) and a **named** host's is `docker://{host}/containers` (likewise `docker-logs:///{id}` vs `docker-logs://{host}/{id}`, and the same pattern for `docker://services`/`service-logs://`/`service-tasks://` and `docker://nodes`); the single-host bare forms (`docker://containers`, `docker://services`, `docker://nodes`, …) are not registered once several hosts are configured. The `survey_hosts` prompt sweeps every host read-only. The `auto`/`local` endpoints are resolved to concrete URLs and **pinned at startup**, so the SDK and CLI always agree on which daemon a name means — restart to re-resolve after changing a Docker context.
233
233
 
234
234
  ### What the agent can do
235
235
 
236
236
  Once loaded, the agent gets MCP tools grouped by Docker domain. A few examples:
237
237
 
238
- - **Containers** — `container_run`, `container_list` (`managed_only=True` to list only what this server created — see [Provenance labels](#provenance-labels)), `container_exec`, `container_logs`, `container_stop`, `container_commit`, `container_wait` (block until exit, or `until="healthy"` to poll a healthcheck), `container_export` / `container_archive_get_to_file` / `container_archive_put` (stream tar archives to/from a host path)
238
+ - **Containers** — `container_run`, `container_list` (`managed_only=True` to list only what this server created — see [Provenance labels](#provenance-labels)), `container_exec`, `container_logs`, `container_stop`, `container_commit`, `container_wait` (block until exit, `until="healthy"` to poll a healthcheck, or `until="log-match"` to poll for a log line containing `pattern`), `container_export` / `container_archive_get_to_file` / `container_archive_put` (stream tar archives to/from a host path)
239
239
  - **Images** — `image_build`, `image_pull`, `image_push`, `image_tag`, `image_prune`, `image_save` / `image_load` (stream image tarballs to/from a host path via `dest_path` / `from_file`)
240
240
  - **Networks / Volumes** — `network_create`, `network_connect`, `volume_create`, `volume_prune`
241
- - **Swarm** — `swarm_init`, `swarm_join_tokens` (close the init → join loop), `swarm_update` (rotate join tokens / unlock key), `service_create`, `service_scale`, `service_rollback` (re-apply the previous service spec), `node_list`, `node_remove`, `secret_create`, `config_create`
241
+ - **Swarm** — `swarm_init`, `swarm_join_tokens` (close the init → join loop), `swarm_update` (rotate join tokens / unlock key), `service_create`, `service_scale`, `service_rollback` (re-apply the previous service spec), `service_wait` (block until tasks converge, or a rolling update completes), `node_list`, `node_wait` (block until a node reaches a target state — e.g. `ready` after joining), `node_remove`, `secret_create`, `config_create`
242
242
  - **System** — `system_ping`, `system_info`, `system_version`, `system_df`, `system_events`, `host_list` (the configured daemons and which is the default — see [Managing several daemons](#managing-several-daemons)), `system_login` / `system_logout` (cache or clear registry credentials), `system_reconnect` (rebuild a host's SDK client to recover a wedged connection)
243
243
  - **Compose** — `compose_up`, `compose_down`, `compose_stop`, `compose_start`, `compose_restart`, `compose_pause` / `compose_unpause`, `compose_kill`, `compose_ps`, `compose_list`, `compose_images`, `compose_top`, `compose_port`, `compose_logs`, `compose_config`, `compose_build`, `compose_pull`, `compose_run`, `compose_exec`, `compose_cp`, `compose_wait` *(wraps the `docker compose` CLI plugin)*
244
244
  - **Stacks** — `stack_deploy`, `stack_list`, `stack_ps`, `stack_services`, `stack_remove` *(deploy a Compose file to a swarm as a stack; wraps the `docker stack` CLI — requires a swarm manager)*
245
245
  - **Contexts** — `context_list`, `context_inspect`, `context_create`, `context_use`, `context_remove` *(wraps the `docker context` CLI)*
246
- - **Registry / Hub** — `registry_tags`, `registry_manifest`, `registry_image_config` (read an image's env/entrypoint/labels without pulling), `hub_tags`, `hub_repo_info`, `hub_rate_limit` (remaining pull budget) *(HTTPS to OCI v2 registries and the Docker Hub API — no daemon required; transparent retry on a brief 429)*
246
+ - **Registry / Hub** — `registry_tags`, `registry_tag_wait` (block until a specific tag lands — e.g. waiting on a CI push), `registry_manifest`, `registry_image_config` (read an image's env/entrypoint/labels without pulling), `hub_tags`, `hub_repo_info`, `hub_rate_limit` (remaining pull budget) *(HTTPS to OCI v2 registries and the Docker Hub API — no daemon required; transparent retry on a brief 429)*
247
247
  - **Buildx** — `buildx_build`, `buildx_bake`, `buildx_imagetools_inspect`, `buildx_imagetools_create`, `buildx_list`, `buildx_inspect`, `buildx_du`, `buildx_history_list` / `buildx_history_inspect` (drill into past build records), `buildx_prune`, `buildx_create`, `buildx_use`, `buildx_remove` *(wraps the `docker buildx` CLI plugin). Use `buildx_imagetools_*` in place of `docker manifest` — that command is in maintenance mode and lacks support for OCI image indexes and attestations.*
248
248
  - **Scout** — `scout_cves`, `scout_quickview`, `scout_recommendations`, `scout_compare`, `scout_sbom` *(wraps the `docker scout` CLI plugin; most features benefit from `docker login` on the host running this server).*
249
249
 
250
250
  The SDK-backed surface mirrors the [Docker SDK reference](https://docker-py.readthedocs.io/en/stable/) — if it's documented there, it's available here. The Compose and Context surfaces follow the [Compose CLI](https://docs.docker.com/reference/cli/docker/compose/) and [docker context](https://docs.docker.com/reference/cli/docker/context/) references.
251
251
 
252
- The server also publishes the Docker SDK for Python reference and selected Docker CLI / registry references as MCP resources so the agent can consult them at runtime: read `docker-docs://contents` for the section index, then `docker-docs://<section>` (e.g. `docker-docs://containers`, `docker-docs://compose`, `docker-docs://oci-distribution-spec`, `docker-docs://dockerfile`, `docker-docs://build-best-practices`, `docker-docs://engine-security`, `docker-docs://engine-api`) for the rendered page. A further resource, `docker-mcp://tool-catalog`, lists every tool this server knows about with its domain, mutation category, and whether the active configuration registered it — useful for confirming the blast radius of a tool, or why one is absent from the live list.
252
+ The server also publishes the Docker SDK for Python reference and selected Docker CLI / registry references as MCP resources so the agent can consult them at runtime: read `docker-docs://contents` for the section index, then `docker-docs://<section>` (e.g. `docker-docs://containers`, `docker-docs://compose`, `docker-docs://oci-distribution-spec`, `docker-docs://dockerfile`, `docker-docs://build-best-practices`, `docker-docs://engine-security`, `docker-docs://engine-api`) for the rendered page. For MCP clients that can't read resources (e.g. Claude Desktop, Cursor), the `docs_lookup` tool mirrors the same content — call it with no arguments for the section index, or `docs_lookup(section=...)` for a page; it's always available regardless of `DOCKER_MCP_SERVER_DISABLE`. A further resource, `docker-mcp://tool-catalog`, lists every tool this server knows about with its domain, mutation category, and whether the active configuration registered it — useful for confirming the blast radius of a tool, or why one is absent from the live list.
253
253
 
254
- Container observability is also exposed as resources, so a client can attach live container state as context: read **`docker://containers`** for an index of every container (running and stopped) with its status and per-container resource URIs, then **`docker-logs://<id-or-name>`** for a bounded tail of a container's logs (readable even after it exits — handy for diagnosing why) and **`docker-stats://<id-or-name>`** for a computed resource-usage summary (CPU %, memory, network and block I/O) of a running container. These complement the `container_logs` / `container_stats` tools and are hidden when the `containers` domain is disabled.
254
+ Container, service, and node observability are also exposed as resources, so a client can attach live state as context without a tool call: read **`docker://containers`** for an index of every container (running and stopped) with its status and per-container resource URIs, then **`docker-logs://<id-or-name>`** for a bounded tail of a container's logs (readable even after it exits — handy for diagnosing why) and **`docker-stats://<id-or-name>`** for a computed resource-usage summary (CPU %, memory, network and block I/O) of a running container. **`docker://services`** is the same pattern for swarm services — **`service-logs://<id-or-name>`** for a bounded log tail and **`service-tasks://<id-or-name>`** for a computed task/rollout summary (running vs. desired task counts, failing tasks, and the current rolling-update state). **`docker://nodes`** is index-only (state, availability, role, and manager reachability per node) — useful for noticing a node flapping between ready/down without re-querying `node_list`. These complement the equivalent tools (`container_logs`/`container_stats`, `service_logs`, `node_list`) and are hidden when their domain (`containers`/`services`/`nodes`) is disabled.
255
255
 
256
256
  ### Example prompts
257
257
 
@@ -392,7 +392,7 @@ Connecting this server to an AI agent grants it the same level of access as a lo
392
392
  - **Pass-through `extra_kwargs` / `updates` bypass the visible schema.** `container_run`, `container_create`, `service_create` (`extra_kwargs`) and `container_update`, `service_update` (`updates`) forward an arbitrary dict straight into the Docker SDK. A client that gates on, say, `privileged=False` in the tool's declared parameters can still be bypassed via `extra_kwargs={"privileged": True, "pid_mode": "host"}`. These escape hatches are consistent with the "daemon is the trust boundary" model, but any allow/deny policy you build at the MCP-client layer must account for them rather than trusting the named parameters alone.
393
393
  - **Registry credentials.** Many MCP clients log tool calls verbatim, so treat any password or `auth_config` you pass through a tool as exposed.
394
394
  - **SDK-backed tools** (`system_login`, `image_push`, `image_registry_data`) accept credentials directly *and* can reuse credentials cached by `docker login` in `~/.docker/config.json`. Prefer running `docker login` once on the host running this MCP server and leaving the credential parameters unset. (Note: this is the host running the server, not the daemon — relevant when `DOCKER_HOST` points at a remote daemon.) A credential passed to `system_login` is cached in the server's memory for the life of the client; `system_logout` clears that in-memory cache (all registries, or one) without touching `~/.docker/config.json`, and `system_close` / `system_reconnect` clear it by discarding the client. There is no daemon-side session to end — the Engine's `/auth` endpoint only validates.
395
- - **HTTPS-backed registry tools** (`registry_tags`, `registry_manifest`, `registry_image_config`, `hub_tags`, `hub_repo_info`, `hub_rate_limit`) talk to the registry directly over HTTPS and do NOT read `~/.docker/config.json`. The `registry_*` tools accept `username` / `password` for private registries — or, better, read `DOCKER_MCP_SERVER_REGISTRY_USERNAME` / `DOCKER_MCP_SERVER_REGISTRY_PASSWORD` from the server's environment so credentials never transit tool arguments (see [Configuration](#configuration)); the `hub_*` tools currently support public Hub repositories only. If passing credentials as arguments, use a per-invocation token with the minimum required scope rather than a long-lived password. When a registry answers with a `Bearer` auth challenge, the server validates the token `realm` it points at before sending anything: the scheme must be http/https, plaintext http to a non-local host is rejected, and a public registry is not allowed to redirect the credentialed token request at a private/loopback address (an SSRF guard). A genuinely local dev registry (e.g. `localhost:5000`) may still use a local realm.
395
+ - **HTTPS-backed registry tools** (`registry_tags`, `registry_tag_wait`, `registry_manifest`, `registry_image_config`, `hub_tags`, `hub_repo_info`, `hub_rate_limit`) talk to the registry directly over HTTPS and do NOT read `~/.docker/config.json`. The `registry_*` tools accept `username` / `password` for private registries — or, better, read `DOCKER_MCP_SERVER_REGISTRY_USERNAME` / `DOCKER_MCP_SERVER_REGISTRY_PASSWORD` from the server's environment so credentials never transit tool arguments (see [Configuration](#configuration)); the `hub_*` tools currently support public Hub repositories only. If passing credentials as arguments, use a per-invocation token with the minimum required scope rather than a long-lived password. When a registry answers with a `Bearer` auth challenge, the server validates the token `realm` it points at before sending anything: the scheme must be http/https, plaintext http to a non-local host is rejected, and a public registry is not allowed to redirect the credentialed token request at a private/loopback address (an SSRF guard). A genuinely local dev registry (e.g. `localhost:5000`) may still use a local realm.
396
396
  - **Swarm secret material transits tool calls too.** Beyond registry credentials, several swarm tools carry secret material through arguments or return values that MCP clients may log: `secret_create(data=...)` and `config_create(data=...)` take the payload as an argument, `secret_inspect` / `config_inspect` return the stored object, `swarm_join(join_token=...)` and `swarm_unlock(key=...)` take cluster join/unlock secrets, and `swarm_unlock_key` and `swarm_join_tokens` *return* cluster credentials (rotation via `swarm_update` invalidates old tokens) — a manager join token lets its holder join the swarm as a manager (root-equivalent on the cluster). Treat all of these as exposed in any client that records tool traffic, and prefer provisioning swarm secrets and reading join tokens out-of-band on the host rather than through the agent. If an agent never needs to admit nodes, drop the whole surface with `DOCKER_MCP_SERVER_DISABLE=swarm` (see [Configuration](#configuration)).
397
397
  - **`container_exec`, `compose_exec`, and `compose_run` run arbitrary commands.** When any part of the command is derived from agent-controlled input, use an exec-form argv list that does not invoke a shell (e.g. `["python", "-V"]`). A list like `["sh", "-c", template]` that invokes a shell will interpret shell metacharacters in the untrusted substrings.
398
398
  - **Container archive paths.** `container_archive_get` and `container_archive_put` forward the supplied path verbatim to the daemon. The container is the trust boundary — if you do not trust its filesystem, do not assume `..` traversal will be rejected.
@@ -201,29 +201,29 @@ Everything above targets one daemon. To manage **several in a single session**
201
201
  - **`(ro)`** after an endpoint marks that host **read-only**: mutating and destructive tools refuse to act on it. This is a per-host guard enforced at call time, independent of the server-wide `DOCKER_MCP_SERVER_READONLY` switch — mark production `(ro)` and the agent can inspect it all day but can't change it, while local stays read-write.
202
202
  - **Single daemon, simpler form.** A bare value with no `name=` is shorthand for one host — `DOCKER_MCP_SERVER_HOSTS=ssh://ops@prod` (or `auto`, or blank). So this one field also covers the single-remote case. `DOCKER_HOST` keeps working when `DOCKER_MCP_SERVER_HOSTS` is unset, but **`DOCKER_MCP_SERVER_HOSTS` takes over when set** (`DOCKER_HOST` is then ignored, with a one-time notice to stderr).
203
203
 
204
- **How the agent drives it.** With two or more hosts, every daemon-targeting tool gains an optional **`host`** argument constrained to your configured names: read-only tools default to the first host when you omit it, while mutating and destructive tools **require** an explicit `host` (so the agent can't change the wrong daemon by accident). `host_list` (and the `docker-mcp://hosts` resource) report the configured hosts and which is the default; the container observability resources become host-aware — the **default** host's index is `docker:///containers` (note the empty authority) and a **named** host's is `docker://{host}/containers` (likewise `docker-logs:///{id}` vs `docker-logs://{host}/{id}`); the single-host `docker://containers` form is not registered once several hosts are configured. The `survey_hosts` prompt sweeps every host read-only. The `auto`/`local` endpoints are resolved to concrete URLs and **pinned at startup**, so the SDK and CLI always agree on which daemon a name means — restart to re-resolve after changing a Docker context.
204
+ **How the agent drives it.** With two or more hosts, every daemon-targeting tool gains an optional **`host`** argument constrained to your configured names: read-only tools default to the first host when you omit it, while mutating and destructive tools **require** an explicit `host` (so the agent can't change the wrong daemon by accident). `host_list` (and the `docker-mcp://hosts` resource) report the configured hosts and which is the default; the container/service/node observability resources become host-aware — the **default** host's index is `docker:///containers` (note the empty authority) and a **named** host's is `docker://{host}/containers` (likewise `docker-logs:///{id}` vs `docker-logs://{host}/{id}`, and the same pattern for `docker://services`/`service-logs://`/`service-tasks://` and `docker://nodes`); the single-host bare forms (`docker://containers`, `docker://services`, `docker://nodes`, …) are not registered once several hosts are configured. The `survey_hosts` prompt sweeps every host read-only. The `auto`/`local` endpoints are resolved to concrete URLs and **pinned at startup**, so the SDK and CLI always agree on which daemon a name means — restart to re-resolve after changing a Docker context.
205
205
 
206
206
  ### What the agent can do
207
207
 
208
208
  Once loaded, the agent gets MCP tools grouped by Docker domain. A few examples:
209
209
 
210
- - **Containers** — `container_run`, `container_list` (`managed_only=True` to list only what this server created — see [Provenance labels](#provenance-labels)), `container_exec`, `container_logs`, `container_stop`, `container_commit`, `container_wait` (block until exit, or `until="healthy"` to poll a healthcheck), `container_export` / `container_archive_get_to_file` / `container_archive_put` (stream tar archives to/from a host path)
210
+ - **Containers** — `container_run`, `container_list` (`managed_only=True` to list only what this server created — see [Provenance labels](#provenance-labels)), `container_exec`, `container_logs`, `container_stop`, `container_commit`, `container_wait` (block until exit, `until="healthy"` to poll a healthcheck, or `until="log-match"` to poll for a log line containing `pattern`), `container_export` / `container_archive_get_to_file` / `container_archive_put` (stream tar archives to/from a host path)
211
211
  - **Images** — `image_build`, `image_pull`, `image_push`, `image_tag`, `image_prune`, `image_save` / `image_load` (stream image tarballs to/from a host path via `dest_path` / `from_file`)
212
212
  - **Networks / Volumes** — `network_create`, `network_connect`, `volume_create`, `volume_prune`
213
- - **Swarm** — `swarm_init`, `swarm_join_tokens` (close the init → join loop), `swarm_update` (rotate join tokens / unlock key), `service_create`, `service_scale`, `service_rollback` (re-apply the previous service spec), `node_list`, `node_remove`, `secret_create`, `config_create`
213
+ - **Swarm** — `swarm_init`, `swarm_join_tokens` (close the init → join loop), `swarm_update` (rotate join tokens / unlock key), `service_create`, `service_scale`, `service_rollback` (re-apply the previous service spec), `service_wait` (block until tasks converge, or a rolling update completes), `node_list`, `node_wait` (block until a node reaches a target state — e.g. `ready` after joining), `node_remove`, `secret_create`, `config_create`
214
214
  - **System** — `system_ping`, `system_info`, `system_version`, `system_df`, `system_events`, `host_list` (the configured daemons and which is the default — see [Managing several daemons](#managing-several-daemons)), `system_login` / `system_logout` (cache or clear registry credentials), `system_reconnect` (rebuild a host's SDK client to recover a wedged connection)
215
215
  - **Compose** — `compose_up`, `compose_down`, `compose_stop`, `compose_start`, `compose_restart`, `compose_pause` / `compose_unpause`, `compose_kill`, `compose_ps`, `compose_list`, `compose_images`, `compose_top`, `compose_port`, `compose_logs`, `compose_config`, `compose_build`, `compose_pull`, `compose_run`, `compose_exec`, `compose_cp`, `compose_wait` *(wraps the `docker compose` CLI plugin)*
216
216
  - **Stacks** — `stack_deploy`, `stack_list`, `stack_ps`, `stack_services`, `stack_remove` *(deploy a Compose file to a swarm as a stack; wraps the `docker stack` CLI — requires a swarm manager)*
217
217
  - **Contexts** — `context_list`, `context_inspect`, `context_create`, `context_use`, `context_remove` *(wraps the `docker context` CLI)*
218
- - **Registry / Hub** — `registry_tags`, `registry_manifest`, `registry_image_config` (read an image's env/entrypoint/labels without pulling), `hub_tags`, `hub_repo_info`, `hub_rate_limit` (remaining pull budget) *(HTTPS to OCI v2 registries and the Docker Hub API — no daemon required; transparent retry on a brief 429)*
218
+ - **Registry / Hub** — `registry_tags`, `registry_tag_wait` (block until a specific tag lands — e.g. waiting on a CI push), `registry_manifest`, `registry_image_config` (read an image's env/entrypoint/labels without pulling), `hub_tags`, `hub_repo_info`, `hub_rate_limit` (remaining pull budget) *(HTTPS to OCI v2 registries and the Docker Hub API — no daemon required; transparent retry on a brief 429)*
219
219
  - **Buildx** — `buildx_build`, `buildx_bake`, `buildx_imagetools_inspect`, `buildx_imagetools_create`, `buildx_list`, `buildx_inspect`, `buildx_du`, `buildx_history_list` / `buildx_history_inspect` (drill into past build records), `buildx_prune`, `buildx_create`, `buildx_use`, `buildx_remove` *(wraps the `docker buildx` CLI plugin). Use `buildx_imagetools_*` in place of `docker manifest` — that command is in maintenance mode and lacks support for OCI image indexes and attestations.*
220
220
  - **Scout** — `scout_cves`, `scout_quickview`, `scout_recommendations`, `scout_compare`, `scout_sbom` *(wraps the `docker scout` CLI plugin; most features benefit from `docker login` on the host running this server).*
221
221
 
222
222
  The SDK-backed surface mirrors the [Docker SDK reference](https://docker-py.readthedocs.io/en/stable/) — if it's documented there, it's available here. The Compose and Context surfaces follow the [Compose CLI](https://docs.docker.com/reference/cli/docker/compose/) and [docker context](https://docs.docker.com/reference/cli/docker/context/) references.
223
223
 
224
- The server also publishes the Docker SDK for Python reference and selected Docker CLI / registry references as MCP resources so the agent can consult them at runtime: read `docker-docs://contents` for the section index, then `docker-docs://<section>` (e.g. `docker-docs://containers`, `docker-docs://compose`, `docker-docs://oci-distribution-spec`, `docker-docs://dockerfile`, `docker-docs://build-best-practices`, `docker-docs://engine-security`, `docker-docs://engine-api`) for the rendered page. A further resource, `docker-mcp://tool-catalog`, lists every tool this server knows about with its domain, mutation category, and whether the active configuration registered it — useful for confirming the blast radius of a tool, or why one is absent from the live list.
224
+ The server also publishes the Docker SDK for Python reference and selected Docker CLI / registry references as MCP resources so the agent can consult them at runtime: read `docker-docs://contents` for the section index, then `docker-docs://<section>` (e.g. `docker-docs://containers`, `docker-docs://compose`, `docker-docs://oci-distribution-spec`, `docker-docs://dockerfile`, `docker-docs://build-best-practices`, `docker-docs://engine-security`, `docker-docs://engine-api`) for the rendered page. For MCP clients that can't read resources (e.g. Claude Desktop, Cursor), the `docs_lookup` tool mirrors the same content — call it with no arguments for the section index, or `docs_lookup(section=...)` for a page; it's always available regardless of `DOCKER_MCP_SERVER_DISABLE`. A further resource, `docker-mcp://tool-catalog`, lists every tool this server knows about with its domain, mutation category, and whether the active configuration registered it — useful for confirming the blast radius of a tool, or why one is absent from the live list.
225
225
 
226
- Container observability is also exposed as resources, so a client can attach live container state as context: read **`docker://containers`** for an index of every container (running and stopped) with its status and per-container resource URIs, then **`docker-logs://<id-or-name>`** for a bounded tail of a container's logs (readable even after it exits — handy for diagnosing why) and **`docker-stats://<id-or-name>`** for a computed resource-usage summary (CPU %, memory, network and block I/O) of a running container. These complement the `container_logs` / `container_stats` tools and are hidden when the `containers` domain is disabled.
226
+ Container, service, and node observability are also exposed as resources, so a client can attach live state as context without a tool call: read **`docker://containers`** for an index of every container (running and stopped) with its status and per-container resource URIs, then **`docker-logs://<id-or-name>`** for a bounded tail of a container's logs (readable even after it exits — handy for diagnosing why) and **`docker-stats://<id-or-name>`** for a computed resource-usage summary (CPU %, memory, network and block I/O) of a running container. **`docker://services`** is the same pattern for swarm services — **`service-logs://<id-or-name>`** for a bounded log tail and **`service-tasks://<id-or-name>`** for a computed task/rollout summary (running vs. desired task counts, failing tasks, and the current rolling-update state). **`docker://nodes`** is index-only (state, availability, role, and manager reachability per node) — useful for noticing a node flapping between ready/down without re-querying `node_list`. These complement the equivalent tools (`container_logs`/`container_stats`, `service_logs`, `node_list`) and are hidden when their domain (`containers`/`services`/`nodes`) is disabled.
227
227
 
228
228
  ### Example prompts
229
229
 
@@ -364,7 +364,7 @@ Connecting this server to an AI agent grants it the same level of access as a lo
364
364
  - **Pass-through `extra_kwargs` / `updates` bypass the visible schema.** `container_run`, `container_create`, `service_create` (`extra_kwargs`) and `container_update`, `service_update` (`updates`) forward an arbitrary dict straight into the Docker SDK. A client that gates on, say, `privileged=False` in the tool's declared parameters can still be bypassed via `extra_kwargs={"privileged": True, "pid_mode": "host"}`. These escape hatches are consistent with the "daemon is the trust boundary" model, but any allow/deny policy you build at the MCP-client layer must account for them rather than trusting the named parameters alone.
365
365
  - **Registry credentials.** Many MCP clients log tool calls verbatim, so treat any password or `auth_config` you pass through a tool as exposed.
366
366
  - **SDK-backed tools** (`system_login`, `image_push`, `image_registry_data`) accept credentials directly *and* can reuse credentials cached by `docker login` in `~/.docker/config.json`. Prefer running `docker login` once on the host running this MCP server and leaving the credential parameters unset. (Note: this is the host running the server, not the daemon — relevant when `DOCKER_HOST` points at a remote daemon.) A credential passed to `system_login` is cached in the server's memory for the life of the client; `system_logout` clears that in-memory cache (all registries, or one) without touching `~/.docker/config.json`, and `system_close` / `system_reconnect` clear it by discarding the client. There is no daemon-side session to end — the Engine's `/auth` endpoint only validates.
367
- - **HTTPS-backed registry tools** (`registry_tags`, `registry_manifest`, `registry_image_config`, `hub_tags`, `hub_repo_info`, `hub_rate_limit`) talk to the registry directly over HTTPS and do NOT read `~/.docker/config.json`. The `registry_*` tools accept `username` / `password` for private registries — or, better, read `DOCKER_MCP_SERVER_REGISTRY_USERNAME` / `DOCKER_MCP_SERVER_REGISTRY_PASSWORD` from the server's environment so credentials never transit tool arguments (see [Configuration](#configuration)); the `hub_*` tools currently support public Hub repositories only. If passing credentials as arguments, use a per-invocation token with the minimum required scope rather than a long-lived password. When a registry answers with a `Bearer` auth challenge, the server validates the token `realm` it points at before sending anything: the scheme must be http/https, plaintext http to a non-local host is rejected, and a public registry is not allowed to redirect the credentialed token request at a private/loopback address (an SSRF guard). A genuinely local dev registry (e.g. `localhost:5000`) may still use a local realm.
367
+ - **HTTPS-backed registry tools** (`registry_tags`, `registry_tag_wait`, `registry_manifest`, `registry_image_config`, `hub_tags`, `hub_repo_info`, `hub_rate_limit`) talk to the registry directly over HTTPS and do NOT read `~/.docker/config.json`. The `registry_*` tools accept `username` / `password` for private registries — or, better, read `DOCKER_MCP_SERVER_REGISTRY_USERNAME` / `DOCKER_MCP_SERVER_REGISTRY_PASSWORD` from the server's environment so credentials never transit tool arguments (see [Configuration](#configuration)); the `hub_*` tools currently support public Hub repositories only. If passing credentials as arguments, use a per-invocation token with the minimum required scope rather than a long-lived password. When a registry answers with a `Bearer` auth challenge, the server validates the token `realm` it points at before sending anything: the scheme must be http/https, plaintext http to a non-local host is rejected, and a public registry is not allowed to redirect the credentialed token request at a private/loopback address (an SSRF guard). A genuinely local dev registry (e.g. `localhost:5000`) may still use a local realm.
368
368
  - **Swarm secret material transits tool calls too.** Beyond registry credentials, several swarm tools carry secret material through arguments or return values that MCP clients may log: `secret_create(data=...)` and `config_create(data=...)` take the payload as an argument, `secret_inspect` / `config_inspect` return the stored object, `swarm_join(join_token=...)` and `swarm_unlock(key=...)` take cluster join/unlock secrets, and `swarm_unlock_key` and `swarm_join_tokens` *return* cluster credentials (rotation via `swarm_update` invalidates old tokens) — a manager join token lets its holder join the swarm as a manager (root-equivalent on the cluster). Treat all of these as exposed in any client that records tool traffic, and prefer provisioning swarm secrets and reading join tokens out-of-band on the host rather than through the agent. If an agent never needs to admit nodes, drop the whole surface with `DOCKER_MCP_SERVER_DISABLE=swarm` (see [Configuration](#configuration)).
369
369
  - **`container_exec`, `compose_exec`, and `compose_run` run arbitrary commands.** When any part of the command is derived from agent-controlled input, use an exec-form argv list that does not invoke a shell (e.g. `["python", "-V"]`). A list like `["sh", "-c", template]` that invokes a shell will interpret shell metacharacters in the untrusted substrings.
370
370
  - **Container archive paths.** `container_archive_get` and `container_archive_put` forward the supplied path verbatim to the daemon. The container is the trust boundary — if you do not trust its filesystem, do not assume `..` traversal will be rejected.
@@ -114,6 +114,7 @@ TOOL_CATEGORIES: dict[str, ToolCategory] = {
114
114
  "node_list": ToolCategory.READ_ONLY,
115
115
  "node_update": ToolCategory.MUTATING,
116
116
  "node_remove": ToolCategory.DESTRUCTIVE,
117
+ "node_wait": ToolCategory.READ_ONLY,
117
118
  # services
118
119
  "service_create": ToolCategory.MUTATING,
119
120
  "service_inspect": ToolCategory.READ_ONLY,
@@ -124,6 +125,7 @@ TOOL_CATEGORIES: dict[str, ToolCategory] = {
124
125
  "service_logs": ToolCategory.READ_ONLY,
125
126
  "service_scale": ToolCategory.MUTATING,
126
127
  "service_rollback": ToolCategory.MUTATING,
128
+ "service_wait": ToolCategory.READ_ONLY,
127
129
  # swarm
128
130
  "swarm_init": ToolCategory.MUTATING,
129
131
  "swarm_join": ToolCategory.MUTATING,
@@ -198,11 +200,14 @@ TOOL_CATEGORIES: dict[str, ToolCategory] = {
198
200
  "scout_sbom": ToolCategory.READ_ONLY,
199
201
  # registry (HTTPS, no daemon)
200
202
  "registry_tags": ToolCategory.READ_ONLY,
203
+ "registry_tag_wait": ToolCategory.READ_ONLY,
201
204
  "registry_manifest": ToolCategory.READ_ONLY,
202
205
  "registry_image_config": ToolCategory.READ_ONLY,
203
206
  "hub_tags": ToolCategory.READ_ONLY,
204
207
  "hub_repo_info": ToolCategory.READ_ONLY,
205
208
  "hub_rate_limit": ToolCategory.READ_ONLY,
209
+ # docs (no domain — always registered, see _NO_DOMAIN_TOOLS)
210
+ "docs_lookup": ToolCategory.READ_ONLY,
206
211
  }
207
212
 
208
213
  # Destructive tools whose effect is idempotent — re-running has no additional effect (the targets
@@ -246,7 +251,7 @@ class ToolRecord:
246
251
  """What the `@tool()` decorator saw for one tool: its taxonomy and whether it actually registered."""
247
252
 
248
253
  name: str
249
- domain: str
254
+ domain: str | None
250
255
  category: ToolCategory
251
256
  registered: bool
252
257
 
@@ -287,8 +292,20 @@ def is_domain_disabled(domain: str | None) -> bool:
287
292
  return domain is not None and domain in DISABLED_DOMAINS
288
293
 
289
294
 
290
- def _domain_for(func: Callable) -> str:
291
- """Derive a tool's domain from its defining module: docker_mcp.tools.containers -> 'containers'."""
295
+ # Tools with no domain at all — never gated by DOCKER_MCP_SERVER_DISABLE, since their value doesn't
296
+ # correspond to a specific Docker feature area being enabled/disabled. Mirrors `@prompt(domain=None)`'s
297
+ # identical "cross-cutting, always available" semantics for prompts (see `docs_lookup` in resources.py).
298
+ _NO_DOMAIN_TOOLS: frozenset[str] = frozenset({"docs_lookup"})
299
+
300
+
301
+ def _domain_for(func: Callable) -> str | None:
302
+ """Derive a tool's domain from its defining module: docker_mcp.tools.containers -> 'containers'.
303
+
304
+ Returns None for `_NO_DOMAIN_TOOLS` members, which then never get gated by
305
+ DOCKER_MCP_SERVER_DISABLE (see `_domain_enabled`'s call sites).
306
+ """
307
+ if func.__name__ in _NO_DOMAIN_TOOLS:
308
+ return None
292
309
  return (func.__module__ or "").rsplit(".", 1)[-1]
293
310
 
294
311
 
@@ -312,8 +329,10 @@ def tool_catalog() -> dict[str, Any]:
312
329
  switches registered. Drives the `docker-mcp://tool-catalog` resource so a client can see the blast
313
330
  radius of each tool — and which whole domains a server has disabled — before calling anything.
314
331
  """
315
- records = sorted(_tool_registry.values(), key=lambda r: (r.domain, r.name))
316
- domains = sorted({r.domain for r in records})
332
+ # `r.domain or ""` only affects sort order — the stored/reported domain stays None for the
333
+ # handful of `_NO_DOMAIN_TOOLS` (e.g. docs_lookup), sorting before every named domain.
334
+ records = sorted(_tool_registry.values(), key=lambda r: (r.domain or "", r.name))
335
+ domains = sorted({r.domain for r in records}, key=lambda d: d or "")
317
336
  domain_summary = [
318
337
  {
319
338
  "domain": d,
@@ -433,7 +452,8 @@ def build_instructions(registered_domains: set[str] | None = None) -> str:
433
452
  "",
434
453
  "The registered surface changes with env switches; read the `docker-mcp://tool-catalog` resource for "
435
454
  "the live tool/domain/category list and which switches are active. Docs are under "
436
- "`docker-docs://contents`. For multi-step jobs (deploy, troubleshoot, prune, audit, migrate, "
455
+ "`docker-docs://contents`, or call `docs_lookup` if your client can't read resources. For "
456
+ "multi-step jobs (deploy, troubleshoot, prune, audit, migrate, "
437
457
  "multi-arch build, volume backup/restore) prefer the matching MCP prompt.",
438
458
  ]
439
459
  return "\n".join(lines)
@@ -669,8 +689,8 @@ def tool(**kwargs: Any) -> Callable[[Callable], Callable]:
669
689
  name = func.__name__
670
690
  domain = _domain_for(func)
671
691
  category = TOOL_CATEGORIES.get(name, ToolCategory.MUTATING)
672
- registered = _should_register(category, readonly=READONLY, no_destructive=NO_DESTRUCTIVE) and _domain_enabled(
673
- domain, DISABLED_DOMAINS
692
+ registered = _should_register(category, readonly=READONLY, no_destructive=NO_DESTRUCTIVE) and (
693
+ domain is None or _domain_enabled(domain, DISABLED_DOMAINS)
674
694
  )
675
695
  _seen_tool_names.add(name)
676
696
  _tool_registry[name] = ToolRecord(name=name, domain=domain, category=category, registered=registered)
@@ -1,6 +1,7 @@
1
1
  # library of mcp tools relating to container management
2
2
 
3
3
  import math
4
+ import re
4
5
  import threading
5
6
  import time
6
7
  from collections.abc import Iterable
@@ -79,7 +80,8 @@ def container_run(
79
80
  stdin_open - Keep STDIN open
80
81
  mem_limit - Memory limit
81
82
  cpu_count - Number of CPUs
82
- extra_kwargs - Additional keyword arguments forwarded to ContainerCollection.run
83
+ extra_kwargs - Additional keyword arguments forwarded to ContainerCollection.run (call
84
+ `docs_lookup(section="containers")` for the full accepted set)
83
85
  returns: dict | str - Container attrs when detach=True, otherwise stdout/stderr as a string
84
86
  """
85
87
  kwargs: dict = {
@@ -133,7 +135,8 @@ def container_create(
133
135
  Start the created container with `container_start`. Common `extra_kwargs` keys: `name`
134
136
  (str), `environment` (list of "KEY=VAL" or dict), `ports` (dict, e.g.
135
137
  `{"80/tcp": 8080}`), `volumes` (dict, e.g. `{"/host/path": {"bind": "/container/path",
136
- "mode": "rw"}}`), `labels` (dict).
138
+ "mode": "rw"}}`), `labels` (dict). For anything else docker-py's `ContainerCollection.create`
139
+ accepts, call `docs_lookup(section="containers")` rather than guessing a key name.
137
140
 
138
141
  args:
139
142
  image - Image to create the container from, e.g. "nginx:alpine"
@@ -706,6 +709,7 @@ def _wait_result(
706
709
  error: str | None = None,
707
710
  health: str | None = None,
708
711
  status: str | None = None,
712
+ matched_line: str | None = None,
709
713
  ) -> dict:
710
714
  """Build the unified container_wait result snapshot — the same shape for every `until` mode."""
711
715
  return {
@@ -717,48 +721,73 @@ def _wait_result(
717
721
  "error": error,
718
722
  "health": health,
719
723
  "status": status,
724
+ "matched_line": matched_line,
720
725
  "waited_seconds": round(time.monotonic() - start, 2),
721
726
  }
722
727
 
723
728
 
729
+ # Cap each log-match poll's read so a chatty container can't grow the buffer we search unbounded.
730
+ _LOG_MATCH_TAIL_LINES = 1000
731
+
732
+
724
733
  @tool()
725
734
  def container_wait(
726
735
  id_or_name: str,
727
- until: Literal["not-running", "next-exit", "removed", "healthy"] = "not-running",
736
+ until: Literal["not-running", "next-exit", "removed", "healthy", "log-match"] = "not-running",
728
737
  timeout_seconds: float = 600.0,
729
738
  poll_interval: float = 2.0,
739
+ pattern: str | None = None,
740
+ regex: bool = False,
730
741
  host: str | None = None,
731
742
  ) -> dict:
732
743
  """
733
- Block until a container reaches a condition: stopped ("not-running"/"next-exit"/"removed") or "healthy".
744
+ Block until a container reaches a condition: stopped, "healthy", or its logs contain a pattern.
734
745
 
735
746
  One contract for every mode: never raises on timeout — the result always carries `met` (condition
736
- reached) and `timed_out`. The stop conditions use the daemon's blocking wait and fill `status_code`/
737
- `error` (the container's exit info); "healthy" polls the container's HEALTHCHECK every
738
- `poll_interval`s and fills `health`/`status`.
747
+ reached) and `timed_out`. The stop conditions ("not-running"/"next-exit"/"removed") use the
748
+ daemon's blocking wait and fill `status_code`/`error` (the container's exit info); "healthy" polls
749
+ the container's HEALTHCHECK every `poll_interval`s and fills `health`/`status`; "log-match" polls
750
+ recent logs every `poll_interval`s for `pattern` and fills `matched_line`.
739
751
 
740
752
  Health semantics: with no HEALTHCHECK defined, once the container is `running` the tool returns
741
753
  promptly with `health: null` and `met: false` (false = "not confirmed healthy", not "unhealthy" —
742
754
  check `health` to tell them apart). A container that exits before becoming healthy returns its
743
755
  terminal `status` and `met: false`.
744
756
 
757
+ Log-match semantics: `pattern` is matched as a **plain substring** by default — safe against any
758
+ input, including adversarial ones. Pass `regex=True` to match `pattern` as a regular expression
759
+ (via `re.search`) instead; only do this with patterns you trust, since a regex with catastrophic
760
+ backtracking run against attacker-influenced log content can exhaust CPU (ReDoS). Checks stdout
761
+ and stderr, most recent lines first within each poll. If the container exits/dies before the
762
+ pattern ever appears, returns promptly with `met=false` (not `timed_out`) — no further logs can
763
+ arrive, so there's nothing to keep polling for.
764
+
745
765
  args:
746
766
  id_or_name - The container id or name
747
- until - Condition to wait for: "not-running" (default), "next-exit", "removed", or "healthy"
767
+ until - Condition to wait for: "not-running" (default), "next-exit", "removed", "healthy",
768
+ or "log-match" (requires `pattern`)
748
769
  timeout_seconds - Max seconds to wait before returning with timed_out=true (default 600)
749
- poll_interval - "healthy" mode only: seconds between re-inspections (default 2, > 0); capped by
750
- the time left so a large value can't push the total wait past the timeout
770
+ poll_interval - "healthy"/"log-match" only: seconds between re-checks (default 2, > 0);
771
+ capped by the time left so a large value can't push the total wait past the
772
+ timeout
773
+ pattern - "log-match" only: substring (or, with `regex=True`, a regular expression) to look
774
+ for in the container's logs
775
+ regex - "log-match" only: treat `pattern` as a regular expression instead of a plain substring
751
776
  returns: dict - {"container", "until", "met", "timed_out", "status_code", "error", "health",
752
- "status", "waited_seconds"}; stop modes fill status_code/error, "healthy" fills
753
- health ("starting"/"healthy"/"unhealthy", or null with no healthcheck) and status.
777
+ "status", "matched_line", "waited_seconds"}; stop modes fill status_code/error,
778
+ "healthy" fills health ("starting"/"healthy"/"unhealthy", or null with no
779
+ healthcheck) and status, "log-match" fills matched_line when met and status if
780
+ the container exited without matching.
754
781
  """
755
782
  if timeout_seconds < 0:
756
783
  raise ValueError(f"timeout_seconds must be >= 0, got {timeout_seconds}.")
757
- if until == "healthy" and poll_interval <= 0:
784
+ if until in ("healthy", "log-match") and poll_interval <= 0:
758
785
  raise ValueError(f"poll_interval must be > 0, got {poll_interval}.")
786
+ if until == "log-match" and not pattern:
787
+ raise ValueError("`pattern` is required when until='log-match'.")
759
788
  container = _get_client(host).containers.get(id_or_name)
760
789
  start = time.monotonic()
761
- if until != "healthy":
790
+ if until not in ("healthy", "log-match"):
762
791
  try:
763
792
  # The daemon wait takes whole seconds; round up so a small fractional timeout still
764
793
  # waits (int() would truncate 0.5 to an immediate 0s timeout).
@@ -774,27 +803,48 @@ def container_wait(
774
803
  error=(result.get("Error") or {}).get("Message") if isinstance(result.get("Error"), dict) else None,
775
804
  )
776
805
  deadline = start + timeout_seconds
806
+ if until == "healthy":
807
+ while True:
808
+ container.reload()
809
+ state = container.attrs.get("State", {}) or {}
810
+ status = state.get("Status") # created / running / exited / dead / paused / restarting
811
+ health = (state.get("Health") or {}).get("Status") # starting / healthy / unhealthy, or None
812
+
813
+ if health == "healthy":
814
+ return _wait_result(id_or_name, until, met=True, start=start, health=health, status=status)
815
+ if health == "unhealthy":
816
+ return _wait_result(id_or_name, until, met=False, start=start, health=health, status=status)
817
+ if status in ("exited", "dead"):
818
+ # Stopped before ever becoming healthy.
819
+ return _wait_result(id_or_name, until, met=False, start=start, health=health, status=status)
820
+ if health is None and status == "running":
821
+ # No HEALTHCHECK defined: there's nothing to converge to, so don't poll to the timeout.
822
+ return _wait_result(id_or_name, until, met=False, start=start, health=health, status=status)
823
+ # Otherwise still settling (health "starting", or status created/restarting/paused): keep polling.
824
+ remaining = deadline - time.monotonic()
825
+ if remaining <= 0:
826
+ return _wait_result(
827
+ id_or_name, until, met=False, start=start, timed_out=True, health=health, status=status
828
+ )
829
+ # Bound the sleep by the time left so a large poll_interval can't push past the timeout.
830
+ time.sleep(min(poll_interval, remaining))
831
+ # "log-match"
832
+ matcher = (lambda line: re.search(cast(str, pattern), line)) if regex else (lambda line: pattern in line)
777
833
  while True:
834
+ output = container.logs(stdout=True, stderr=True, stream=False, tail=_LOG_MATCH_TAIL_LINES)
835
+ text = output.decode("utf-8", errors="replace") if isinstance(output, bytes) else str(output)
836
+ for line in reversed(text.splitlines()):
837
+ if matcher(line):
838
+ return _wait_result(id_or_name, until, met=True, start=start, matched_line=line)
778
839
  container.reload()
779
- state = container.attrs.get("State", {}) or {}
780
- status = state.get("Status") # created / running / exited / dead / paused / restarting
781
- health = (state.get("Health") or {}).get("Status") # starting / healthy / unhealthy, or None
782
-
783
- if health == "healthy":
784
- return _wait_result(id_or_name, until, met=True, start=start, health=health, status=status)
785
- if health == "unhealthy":
786
- return _wait_result(id_or_name, until, met=False, start=start, health=health, status=status)
840
+ status = (container.attrs.get("State", {}) or {}).get("Status")
787
841
  if status in ("exited", "dead"):
788
- # Stopped before ever becoming healthy.
789
- return _wait_result(id_or_name, until, met=False, start=start, health=health, status=status)
790
- if health is None and status == "running":
791
- # No HEALTHCHECK defined: there's nothing to converge to, so don't poll to the timeout.
792
- return _wait_result(id_or_name, until, met=False, start=start, health=health, status=status)
793
- # Otherwise still settling (health "starting", or status created/restarting/paused): keep polling.
842
+ # Stopped without ever matching — no further logs will arrive, don't poll to the timeout.
843
+ return _wait_result(id_or_name, until, met=False, start=start, status=status)
794
844
  remaining = deadline - time.monotonic()
795
845
  if remaining <= 0:
796
- return _wait_result(id_or_name, until, met=False, start=start, timed_out=True, health=health, status=status)
797
- # Bound the sleep by the time left so a large poll_interval can't block past the timeout.
846
+ return _wait_result(id_or_name, until, met=False, start=start, timed_out=True, status=status)
847
+ # Bound the sleep by the time left so a large poll_interval can't push past the timeout.
798
848
  time.sleep(min(poll_interval, remaining))
799
849
 
800
850