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.
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/copilot-instructions.md +4 -2
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/premerge.yaml +6 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/CLAUDE.md +5 -1
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/PKG-INFO +8 -8
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/README.md +7 -7
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/server.py +28 -8
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/containers.py +80 -30
- docker_mcp_server-2.1.0/docker_mcp/tools/nodes.py +136 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/prompts.py +29 -14
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/registry.py +63 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/resources.py +239 -4
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/services.py +159 -1
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/system.py +6 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/manifest.json +1 -1
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/pyproject.toml +1 -1
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/conftest.py +11 -2
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_containers.py +24 -0
- docker_mcp_server-2.1.0/tests/integration/test_nodes.py +24 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_registry.py +9 -0
- docker_mcp_server-2.1.0/tests/integration/test_services.py +54 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_containers.py +91 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_naming.py +1 -0
- docker_mcp_server-2.1.0/tests/test_nodes.py +130 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_prompts.py +24 -2
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_registry.py +59 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_resources.py +220 -14
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_server.py +24 -5
- docker_mcp_server-2.1.0/tests/test_services.py +417 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/uv.lock +1 -1
- docker_mcp_server-2.0.1/docker_mcp/tools/nodes.py +0 -63
- docker_mcp_server-2.0.1/tests/test_nodes.py +0 -61
- docker_mcp_server-2.0.1/tests/test_services.py +0 -185
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.claude/commands/docker-sdk.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.claude/settings.json +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.dockerignore +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/CODEOWNERS +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/actions/file-failure-issue/action.yaml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/dependabot.yaml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/release.yml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/canary.yaml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/codeql.yaml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/images.yaml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/publish-homebrew.yaml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.github/workflows/publish.yaml +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.gitignore +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.mcpbignore +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/.python-version +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/CODE_OF_CONDUCT.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/CONTRIBUTING.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/DOCKERHUB.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/Dockerfile +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/LICENSE +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/MIGRATION-2.0.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/PRIVACY.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/SECURITY.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/assets/README.md +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/assets/icon.png +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/__init__.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/__main__.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/_env.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/_hosts.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/__init__.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_cli.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_labels.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_ssh_proxy.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/_utils.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/buildx.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/compose.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/configs.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/context.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/images.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/networks.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/plugins.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/scout.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/secrets.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/stack.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/swarm.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/docker_mcp/tools/volumes.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/glama.json +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/mcpb_run.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/scripts/build-mcpb.sh +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/scripts/docker-mcp-server.rb.tpl +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/server.json +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/__init__.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/conftest.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/__init__.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_buildx.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_cli.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_compose.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_context.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_file_payloads.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_networks.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_scout.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_smoke.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/integration/test_stack.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_buildx.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_cli.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_compose.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_configs.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_context.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_env.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_hosts.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_images.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_labels.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_main.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_networks.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_plugins.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_pyproject_pins.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_scout.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_secrets.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_ssh_proxy.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_stack.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_swarm.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_system.py +0 -0
- {docker_mcp_server-2.0.1 → docker_mcp_server-2.1.0}/tests/test_utils.py +0 -0
- {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
|
|
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
|
|
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
|
|
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`
|
|
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="
|
|
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
|
|
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`
|
|
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="
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
737
|
-
`error` (the container's exit info); "healthy" polls
|
|
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",
|
|
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"
|
|
750
|
-
the time left so a large value can't push the total wait past the
|
|
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,
|
|
753
|
-
health ("starting"/"healthy"/"unhealthy", or null with no
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
789
|
-
return _wait_result(id_or_name, until, met=False, start=start,
|
|
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,
|
|
797
|
-
# Bound the sleep by the time left so a large poll_interval can't
|
|
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
|
|